Initial commit from agent-native create
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { AgentToggleButton } from "@agent-native/core/client/agent-chat";
|
||||
import { useT } from "@agent-native/core/client/i18n";
|
||||
import {
|
||||
useHeaderTitle,
|
||||
useHeaderActions,
|
||||
} from "@agent-native/toolkit/app-shell";
|
||||
import { IconMenu2 } from "@tabler/icons-react";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
import { APP_TITLE } from "@/lib/app-config";
|
||||
|
||||
const pageTitleKeys: Record<string, string> = {
|
||||
"/": "navigation.chat",
|
||||
"/observability": "navigation.observability",
|
||||
"/agent": "settings.agentTitle",
|
||||
"/settings": "navigation.settings",
|
||||
};
|
||||
|
||||
function resolveTitle(pathname: string, t: (key: string) => string): string {
|
||||
if (pageTitleKeys[pathname]) return t(pageTitleKeys[pathname]);
|
||||
if (pathname.startsWith("/extensions")) return t("navigation.extensions");
|
||||
return APP_TITLE;
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
onOpenMobileSidebar?: () => void;
|
||||
}
|
||||
|
||||
export function Header({ onOpenMobileSidebar }: HeaderProps) {
|
||||
const location = useLocation();
|
||||
const t = useT();
|
||||
const title = useHeaderTitle();
|
||||
const actions = useHeaderActions();
|
||||
|
||||
return (
|
||||
<header className="flex h-12 items-center gap-3 border-b border-border bg-background px-4 lg:px-6 shrink-0">
|
||||
{onOpenMobileSidebar && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMobileSidebar}
|
||||
aria-label={t("navigation.openNavigation")}
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-accent md:hidden"
|
||||
>
|
||||
<IconMenu2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{title ?? (
|
||||
<h1 className="text-lg font-semibold tracking-tight truncate">
|
||||
{resolveTitle(location.pathname, t)}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{actions}
|
||||
<AgentToggleButton />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
AgentSidebar,
|
||||
focusAgentChat,
|
||||
isAgentChatHomeHandoffActive,
|
||||
navigateWithAgentChatViewTransition,
|
||||
useAgentChatHomeHandoff,
|
||||
useAgentChatHomeHandoffLinks,
|
||||
} from "@agent-native/core/client/agent-chat";
|
||||
import { useT } from "@agent-native/core/client/i18n";
|
||||
import { HeaderActionsProvider } from "@agent-native/toolkit/app-shell";
|
||||
import { IconMenu2 } from "@tabler/icons-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { APP_TITLE } from "@/lib/app-config";
|
||||
import { TAB_ID } from "@/lib/tab-id";
|
||||
|
||||
import { Header } from "./Header";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SIDEBAR_COLLAPSE_KEY = "chat.sidebar.collapsed";
|
||||
|
||||
/**
|
||||
* Routes whose page renders its own toolbar. Layout still wraps these with the
|
||||
* left Sidebar and agent surfaces but skips the global Header so they don't
|
||||
* double-stack chrome.
|
||||
*/
|
||||
function routeOwnsToolbar(pathname: string): boolean {
|
||||
return (
|
||||
pathname === "/" ||
|
||||
pathname.startsWith("/chat/") ||
|
||||
pathname === "/database" ||
|
||||
pathname.startsWith("/extensions")
|
||||
);
|
||||
}
|
||||
|
||||
export function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const t = useT();
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
||||
const isChatRoute =
|
||||
location.pathname === "/" || location.pathname.startsWith("/chat/");
|
||||
const chatHomeHandoffActive = useAgentChatHomeHandoff({
|
||||
storageKey: "chat",
|
||||
activePath: location.pathname,
|
||||
enabled: !isChatRoute,
|
||||
});
|
||||
const chatHomeHandoffPending = isAgentChatHomeHandoffActive("chat");
|
||||
useAgentChatHomeHandoffLinks({
|
||||
storageKey: "chat",
|
||||
isChatPath: (pathname) => pathname === "/" || pathname.startsWith("/chat/"),
|
||||
requireActiveHandoff: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setMobileSidebarOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const closeMobileSidebar = () => setMobileSidebarOpen(false);
|
||||
window.addEventListener("agent-chat:open-thread", closeMobileSidebar);
|
||||
return () => {
|
||||
window.removeEventListener("agent-chat:open-thread", closeMobileSidebar);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(SIDEBAR_COLLAPSE_KEY);
|
||||
if (stored !== null) setSidebarCollapsed(stored === "1");
|
||||
} catch {
|
||||
// Ignore storage access errors; the default collapsed state still works.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
SIDEBAR_COLLAPSE_KEY,
|
||||
sidebarCollapsed ? "1" : "0",
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage access errors.
|
||||
}
|
||||
}, [sidebarCollapsed]);
|
||||
|
||||
const ownsToolbar = routeOwnsToolbar(location.pathname);
|
||||
function openAskAgentFullscreen() {
|
||||
focusAgentChat();
|
||||
navigateWithAgentChatViewTransition(navigate, "/");
|
||||
}
|
||||
|
||||
const contentFrame = (
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{isChatRoute ? (
|
||||
<div className="flex h-12 shrink-0 items-center gap-3 border-b border-border bg-card px-3 md:hidden">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
aria-label={t("navigation.openNavigation")}
|
||||
>
|
||||
<IconMenu2 className="size-4" />
|
||||
</Button>
|
||||
<span className="truncate text-sm font-semibold">{APP_TITLE}</span>
|
||||
</div>
|
||||
) : ownsToolbar ? (
|
||||
<div className="flex h-12 shrink-0 items-center border-b border-border px-4 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
aria-label={t("navigation.openNavigation")}
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<IconMenu2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Header onOpenMobileSidebar={() => setMobileSidebarOpen(true)} />
|
||||
)}
|
||||
<main className="agent-native-app-main min-w-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<HeaderActionsProvider>
|
||||
<div className="agent-layout-shell flex h-screen w-full overflow-hidden bg-background text-foreground">
|
||||
<div className="agent-layout-left-drawer hidden md:block">
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onCollapsedChange={setSidebarCollapsed}
|
||||
/>
|
||||
</div>
|
||||
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
|
||||
<SheetContent side="left" className="p-0 w-[260px]">
|
||||
<SheetTitle className="sr-only">
|
||||
{t("navigation.navigation")}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
{t("navigation.navigationDescription")}
|
||||
</SheetDescription>
|
||||
<Sidebar collapsed={false} collapsible={false} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{isChatRoute ? (
|
||||
<div className="agent-layout-main-surface flex min-w-0 flex-1 overflow-hidden">
|
||||
{contentFrame}
|
||||
</div>
|
||||
) : (
|
||||
<AgentSidebar
|
||||
position="right"
|
||||
chatViewTransition
|
||||
chatViewTransitionHandoff={chatHomeHandoffPending}
|
||||
storageKey="chat"
|
||||
browserTabId={TAB_ID}
|
||||
openOnChatRunning={chatHomeHandoffActive}
|
||||
onFullscreenRequest={openAskAgentFullscreen}
|
||||
emptyStateText={t("chat.inspectEmptyState")}
|
||||
agentPageHref="/agent"
|
||||
suggestions={[
|
||||
t("chat.inspectSuggestionCapabilities"),
|
||||
t("chat.inspectSuggestionHello"),
|
||||
t("chat.inspectSuggestionAction"),
|
||||
]}
|
||||
>
|
||||
{contentFrame}
|
||||
</AgentSidebar>
|
||||
)}
|
||||
</div>
|
||||
</HeaderActionsProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
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 {
|
||||
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: IconMessageCircle,
|
||||
labelKey: "navigation.chat",
|
||||
href: "/",
|
||||
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<ChatHistoryItem[]>(
|
||||
() =>
|
||||
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 (
|
||||
<div
|
||||
className="an-chat-history-rail__collapse"
|
||||
data-state={open ? "open" : "closed"}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className="ms-4">
|
||||
<ChatHistoryRail
|
||||
items={chatItems}
|
||||
activeId={displayedActiveThreadId}
|
||||
onSelect={(threadId) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
collapsed = false,
|
||||
collapsible = true,
|
||||
onCollapsedChange,
|
||||
}: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const t = useT();
|
||||
const isChatRoute =
|
||||
location.pathname === "/" || 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 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCollapsedChange?.(!collapsed)}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center rounded-md text-sidebar-foreground/65 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
collapsed ? "size-8" : "size-7",
|
||||
)}
|
||||
aria-label={
|
||||
collapsed
|
||||
? t("navigation.expandSidebar")
|
||||
: t("navigation.collapseSidebar")
|
||||
}
|
||||
>
|
||||
<ToggleIcon className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{collapsed
|
||||
? t("navigation.expandSidebar")
|
||||
: t("navigation.collapseSidebar")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
const searchButton = (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCommandMenu}
|
||||
aria-label={t("root.commandSearch")}
|
||||
className="flex size-8 items-center justify-center rounded-md text-sidebar-foreground/65 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<IconSearch className="size-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t("root.commandSearch")}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
const translateButton = (
|
||||
<LanguagePicker variant="ghost-icon" label={t("settings.languageLabel")} />
|
||||
);
|
||||
const feedbackButton = (
|
||||
<FeedbackButton
|
||||
variant={collapsed ? "icon" : "sidebar"}
|
||||
side="right"
|
||||
className={collapsed ? "h-8 w-8" : "min-w-0"}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-collapsed={collapsed ? "true" : "false"}
|
||||
className={cn(
|
||||
"flex h-full min-w-0 shrink-0 flex-col overflow-hidden border-e border-sidebar-border bg-sidebar text-sidebar-foreground transition-[width] duration-200 ease-out",
|
||||
collapsed ? "w-12" : "w-60",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center border-b border-sidebar-border",
|
||||
collapsed ? "h-12 justify-center px-0" : "h-14 px-3",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
to="/"
|
||||
className={cn(
|
||||
"flex min-w-0 items-center rounded outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
collapsed ? "size-7 justify-center" : "flex-1 gap-3",
|
||||
)}
|
||||
aria-label={collapsed ? APP_TITLE : undefined}
|
||||
>
|
||||
<img
|
||||
src={appPath("/agent-native-icon-light.svg")}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="block h-4 w-auto shrink-0 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src={appPath("/agent-native-icon-dark.svg")}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="hidden h-4 w-auto shrink-0 dark:block"
|
||||
/>
|
||||
<div className={cn("min-w-0", collapsed && "sr-only")}>
|
||||
<p className="truncate text-sm font-semibold text-sidebar-accent-foreground">
|
||||
{APP_TITLE}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
"flex-1 overflow-y-auto",
|
||||
collapsed ? "px-0 py-2" : "px-2 py-3",
|
||||
)}
|
||||
>
|
||||
<div className={cn("grid", collapsed ? "gap-0" : "gap-1")}>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive =
|
||||
item.href === "/"
|
||||
? isChatRoute
|
||||
: location.pathname.startsWith(item.href);
|
||||
const link = (
|
||||
<Link
|
||||
to={item.href}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
item.href === "/" &&
|
||||
!isChatRoute &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
event.preventDefault();
|
||||
navigateWithAgentChatViewTransition(navigate, "/");
|
||||
}
|
||||
}}
|
||||
className={navClass({ isActive })}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
aria-label={collapsed ? t(item.labelKey) : undefined}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className={collapsed ? "sr-only" : "truncate"}>
|
||||
{t(item.labelKey)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<div key={item.href}>
|
||||
{collapsed ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t(item.labelKey)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
link
|
||||
)}
|
||||
{!collapsed && item.view === "chat" ? (
|
||||
<ChatThreadsSection open={isChatRoute} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className={cn("mt-auto shrink-0", collapsed && "py-2")}>
|
||||
<nav
|
||||
className={cn(
|
||||
"grid",
|
||||
collapsed ? "gap-0 px-1 py-1" : "gap-1 px-2 py-1",
|
||||
)}
|
||||
>
|
||||
{bottomNavItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname.startsWith(item.href);
|
||||
const link = (
|
||||
<Link
|
||||
to={item.href}
|
||||
className={navClass({ isActive })}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
aria-label={collapsed ? t(item.labelKey) : undefined}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className={collapsed ? "sr-only" : "truncate"}>
|
||||
{t(item.labelKey)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
return collapsed ? (
|
||||
<Tooltip key={item.href}>
|
||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||
<TooltipContent side="right">{t(item.labelKey)}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div key={item.href}>{link}</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className={cn(collapsed ? "px-1 py-1" : "px-3 py-2")}>
|
||||
<OrgSwitcher
|
||||
reserveSpace
|
||||
className={
|
||||
collapsed
|
||||
? "h-8 justify-center px-0 [&>span]:sr-only [&>svg:last-child]:hidden"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarFooterActions
|
||||
collapsed={collapsed}
|
||||
feedback={feedbackButton}
|
||||
translate={translateButton}
|
||||
search={searchButton}
|
||||
collapse={collapseButton}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@agent-native/toolkit/ui/button";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@agent-native/toolkit/ui/card";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@agent-native/toolkit/ui/dropdown-menu";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@agent-native/toolkit/ui/input";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@agent-native/toolkit/ui/label";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@agent-native/toolkit/ui/sheet";
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ToolkitProvider, type ToolkitComponents } from "@agent-native/toolkit";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { designSystem } from "@/design-system";
|
||||
|
||||
const components: ToolkitComponents = {
|
||||
Button: Button as ToolkitComponents["Button"],
|
||||
};
|
||||
|
||||
export function AppToolkitProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ToolkitProvider components={components} designSystem={designSystem}>
|
||||
{children}
|
||||
</ToolkitProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@agent-native/toolkit/ui/tooltip";
|
||||
Reference in New Issue
Block a user