Initial commit from agent-native create

This commit is contained in:
agent-native
2026-07-25 17:10:38 +00:00
commit ffea2aeff7
120 changed files with 6804 additions and 0 deletions
+60
View File
@@ -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>
);
}
+188
View File
@@ -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>
);
}
+505
View File
@@ -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>
);
}
+1
View File
@@ -0,0 +1 @@
export * from "@agent-native/toolkit/ui/button";
+1
View File
@@ -0,0 +1 @@
export * from "@agent-native/toolkit/ui/card";
+1
View File
@@ -0,0 +1 @@
export * from "@agent-native/toolkit/ui/dropdown-menu";
+1
View File
@@ -0,0 +1 @@
export * from "@agent-native/toolkit/ui/input";
+1
View File
@@ -0,0 +1 @@
export * from "@agent-native/toolkit/ui/label";
+1
View File
@@ -0,0 +1 @@
export * from "@agent-native/toolkit/ui/sheet";
+17
View File
@@ -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>
);
}
+1
View File
@@ -0,0 +1 @@
export * from "@agent-native/toolkit/ui/tooltip";