Build Verse: a poem-quote app on the agent-native chat template
CI / build (push) Successful in 4m26s

Adds a Postgres-backed quotes domain (poems/quotes/favorites) exposed as
five agent-native actions (list-quotes, get-random-quote, create-quote,
toggle-favorite, list-favorites) callable from both chat and the React UI
via useActionQuery/useActionMutation. Moves the primary UI from chat to a
mobile-first Quotes/Favorites experience (chat moves to /chat), adds
manual OpenTelemetry instrumentation exporting traces/metrics/logs over
OTLP, and wires a local Docker Postgres for shared state.
This commit is contained in:
exe.dev user
2026-07-25 17:42:04 +00:00
parent ffea2aeff7
commit 0a18c774a5
44 changed files with 14702 additions and 156 deletions
+3 -1
View File
@@ -10,7 +10,9 @@ import { useLocation } from "react-router";
import { APP_TITLE } from "@/lib/app-config";
const pageTitleKeys: Record<string, string> = {
"/": "navigation.chat",
"/": "navigation.quotes",
"/favorites": "navigation.favorites",
"/chat": "navigation.chat",
"/observability": "navigation.observability",
"/agent": "settings.agentTitle",
"/settings": "navigation.settings",
+4 -4
View File
@@ -38,7 +38,7 @@ const SIDEBAR_COLLAPSE_KEY = "chat.sidebar.collapsed";
*/
function routeOwnsToolbar(pathname: string): boolean {
return (
pathname === "/" ||
pathname === "/chat" ||
pathname.startsWith("/chat/") ||
pathname === "/database" ||
pathname.startsWith("/extensions")
@@ -52,7 +52,7 @@ export function Layout({ children }: LayoutProps) {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const isChatRoute =
location.pathname === "/" || location.pathname.startsWith("/chat/");
location.pathname === "/chat" || location.pathname.startsWith("/chat/");
const chatHomeHandoffActive = useAgentChatHomeHandoff({
storageKey: "chat",
activePath: location.pathname,
@@ -61,7 +61,7 @@ export function Layout({ children }: LayoutProps) {
const chatHomeHandoffPending = isAgentChatHomeHandoffActive("chat");
useAgentChatHomeHandoffLinks({
storageKey: "chat",
isChatPath: (pathname) => pathname === "/" || pathname.startsWith("/chat/"),
isChatPath: (pathname) => pathname === "/chat" || pathname.startsWith("/chat/"),
requireActiveHandoff: true,
});
@@ -100,7 +100,7 @@ export function Layout({ children }: LayoutProps) {
const ownsToolbar = routeOwnsToolbar(location.pathname);
function openAskAgentFullscreen() {
focusAgentChat();
navigateWithAgentChatViewTransition(navigate, "/");
navigateWithAgentChatViewTransition(navigate, "/chat");
}
const contentFrame = (
+22 -6
View File
@@ -14,6 +14,8 @@ import {
type ChatHistoryItem,
} from "@agent-native/toolkit/chat-history";
import {
IconBooks,
IconHeart,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand,
IconMessageCircle,
@@ -33,10 +35,22 @@ 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: "/",
href: "/chat",
view: "chat",
},
];
@@ -275,7 +289,7 @@ export function Sidebar({
const navigate = useNavigate();
const t = useT();
const isChatRoute =
location.pathname === "/" || location.pathname.startsWith("/chat/");
location.pathname === "/chat" || location.pathname.startsWith("/chat/");
const ToggleIcon = collapsed
? IconLayoutSidebarLeftExpand
: IconLayoutSidebarLeftCollapse;
@@ -397,15 +411,17 @@ export function Sidebar({
{navItems.map((item) => {
const Icon = item.icon;
const isActive =
item.href === "/"
item.href === "/chat"
? isChatRoute
: location.pathname.startsWith(item.href);
: item.href === "/"
? location.pathname === "/"
: location.pathname.startsWith(item.href);
const link = (
<Link
to={item.href}
onClick={(event) => {
if (
item.href === "/" &&
item.href === "/chat" &&
!isChatRoute &&
!event.metaKey &&
!event.ctrlKey &&
@@ -413,7 +429,7 @@ export function Sidebar({
!event.altKey
) {
event.preventDefault();
navigateWithAgentChatViewTransition(navigate, "/");
navigateWithAgentChatViewTransition(navigate, "/chat");
}
}}
className={navClass({ isActive })}
+95
View File
@@ -0,0 +1,95 @@
import { useActionMutation } from "@agent-native/core/client/hooks";
import { IconHeart, IconHeartFilled } from "@tabler/icons-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { useVisitorId } from "@/lib/visitor-id";
export type QuoteCardData = {
id: number;
text: string;
tags: string[];
poem: {
title: string;
author: string;
source: string | null;
year: number | null;
};
favoriteCount: number;
isFavorited: boolean;
};
interface QuoteCardProps {
quote: QuoteCardData;
onTagClick?: (tag: string) => void;
className?: string;
}
export function QuoteCard({ quote, onTagClick, className }: QuoteCardProps) {
const visitorId = useVisitorId();
const [optimistic, setOptimistic] = useState<{ favorited: boolean; count: number } | null>(
null,
);
const { mutate, isPending } = useActionMutation("toggle-favorite", {
onSuccess: (result) => {
setOptimistic({ favorited: result.favorited, count: result.favoriteCount });
},
onError: () => setOptimistic(null),
});
const favorited = optimistic?.favorited ?? quote.isFavorited;
const count = optimistic?.count ?? quote.favoriteCount;
return (
<Card className={cn("flex h-full flex-col justify-between", className)}>
<CardContent className="pt-6">
<blockquote className="text-balance text-lg leading-relaxed font-medium text-foreground">
{quote.text}
</blockquote>
<p className="mt-4 text-sm text-muted-foreground">
{quote.poem.author}, <span className="italic">{quote.poem.title}</span>
{quote.poem.year ? ` (${quote.poem.year})` : ""}
</p>
{quote.tags.length > 0 && (
<div className="mt-4 flex flex-wrap gap-1.5">
{quote.tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className={cn(onTagClick && "cursor-pointer hover:bg-secondary/70")}
onClick={() => onTagClick?.(tag)}
>
{tag}
</Badge>
))}
</div>
)}
</CardContent>
<CardFooter className="flex items-center justify-between border-t pt-4">
<span className="text-xs text-muted-foreground">
{count} {count === 1 ? "favorite" : "favorites"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending || !visitorId}
onClick={() => mutate({ quoteId: quote.id, visitorId })}
aria-pressed={favorited}
aria-label={favorited ? "Remove from favorites" : "Add to favorites"}
className={cn("gap-1.5", favorited && "text-rose-500 hover:text-rose-500")}
>
{favorited ? (
<IconHeartFilled className="size-4" />
) : (
<IconHeart className="size-4" />
)}
{favorited ? "Favorited" : "Favorite"}
</Button>
</CardFooter>
</Card>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { useActionMutation } from "@agent-native/core/client/hooks";
import { IconPlus } from "@tabler/icons-react";
import { useId, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
const emptyForm = { text: "", author: "", poemTitle: "", source: "", year: "", tags: "" };
export function SubmitQuoteDialog() {
const [open, setOpen] = useState(false);
const [form, setForm] = useState(emptyForm);
const formId = useId();
const { mutate, isPending } = useActionMutation("create-quote", {
onSuccess: () => {
toast.success("Quote added");
setForm(emptyForm);
setOpen(false);
},
onError: () => toast.error("Couldn't save that quote — try again"),
});
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (!form.text.trim() || !form.author.trim() || !form.poemTitle.trim()) return;
mutate({
text: form.text.trim(),
author: form.author.trim(),
poemTitle: form.poemTitle.trim(),
source: form.source.trim() || undefined,
year: form.year ? Number(form.year) : undefined,
tags: form.tags
.split(",")
.map((t) => t.trim().toLowerCase())
.filter(Boolean),
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" className="gap-1.5">
<IconPlus className="size-4" />
Submit a quote
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Submit a quote</DialogTitle>
<DialogDescription>
Share a line or passage from a poem you love.
</DialogDescription>
</DialogHeader>
<form id={formId} onSubmit={handleSubmit} className="grid gap-4">
<div className="grid gap-1.5">
<Label htmlFor={`${formId}-text`}>Quote</Label>
<Textarea
id={`${formId}-text`}
required
rows={4}
value={form.text}
onChange={(e) => setForm((f) => ({ ...f, text: e.target.value }))}
placeholder="Two roads diverged in a wood..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label htmlFor={`${formId}-author`}>Poet</Label>
<Input
id={`${formId}-author`}
required
value={form.author}
onChange={(e) => setForm((f) => ({ ...f, author: e.target.value }))}
placeholder="Robert Frost"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor={`${formId}-title`}>Poem title</Label>
<Input
id={`${formId}-title`}
required
value={form.poemTitle}
onChange={(e) => setForm((f) => ({ ...f, poemTitle: e.target.value }))}
placeholder="The Road Not Taken"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-1.5">
<Label htmlFor={`${formId}-source`}>Source (optional)</Label>
<Input
id={`${formId}-source`}
value={form.source}
onChange={(e) => setForm((f) => ({ ...f, source: e.target.value }))}
placeholder="Collection name"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor={`${formId}-year`}>Year (optional)</Label>
<Input
id={`${formId}-year`}
inputMode="numeric"
value={form.year}
onChange={(e) => setForm((f) => ({ ...f, year: e.target.value.replace(/\D/g, "") }))}
placeholder="1916"
/>
</div>
</div>
<div className="grid gap-1.5">
<Label htmlFor={`${formId}-tags`}>Tags (optional, comma separated)</Label>
<Input
id={`${formId}-tags`}
value={form.tags}
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
placeholder="hope, nature, journey"
/>
</div>
</form>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" form={formId} disabled={isPending}>
{isPending ? "Saving..." : "Save quote"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
interface TagFilterBarProps {
tags: string[];
activeTag: string | null;
onSelect: (tag: string | null) => void;
}
export function TagFilterBar({ tags, activeTag, onSelect }: TagFilterBarProps) {
if (tags.length === 0) return null;
return (
<div className="flex gap-1.5 overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<Badge
variant={activeTag === null ? "default" : "outline"}
className="shrink-0 cursor-pointer"
onClick={() => onSelect(null)}
>
All
</Badge>
{tags.map((tag) => (
<Badge
key={tag}
variant={activeTag === tag ? "default" : "outline"}
className={cn("shrink-0 cursor-pointer")}
onClick={() => onSelect(activeTag === tag ? null : tag)}
>
{tag}
</Badge>
))}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { IconX as X } from "@tabler/icons-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+164
View File
@@ -0,0 +1,164 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import {
IconCheck as Check,
IconChevronDown as ChevronDown,
IconChevronUp as ChevronUp,
} from "@tabler/icons-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }
+2
View File
@@ -45,10 +45,12 @@ const messages = {
database: "Database",
expandSidebar: "Expand Sidebar",
extensions: "Extensions",
favorites: "Favorites",
navigation: "Navigation",
navigationDescription: "Main navigation",
observability: "Observability",
openNavigation: "Open navigation",
quotes: "Quotes",
settings: "Settings",
team: "Team",
},
+2 -2
View File
@@ -1,5 +1,5 @@
const rawAppName = "app";
const rawAppTitle = "App";
const rawAppName = "verse-quote-app";
const rawAppTitle = "Verse";
const APP_NAME_PLACEHOLDER = "{" + "{APP_NAME}}";
const APP_TITLE_PLACEHOLDER = "{" + "{APP_TITLE}}";
+38
View File
@@ -0,0 +1,38 @@
import { useState } from "react";
const STORAGE_KEY = "verse-quote-app.visitor-id";
function generateId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `v-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* A stable anonymous id for this browser, used to scope favorites without
* requiring a login. Not a real user identity — just enough to remember
* "your" favorites on this device.
*/
export function getVisitorId(): string {
if (typeof window === "undefined") return "";
try {
const existing = window.localStorage.getItem(STORAGE_KEY);
if (existing) return existing;
const created = generateId();
window.localStorage.setItem(STORAGE_KEY, created);
return created;
} catch {
return generateId();
}
}
/**
* React hook wrapper around getVisitorId(). Routes that use this render only
* an SSR loading shell (see DEVELOPING.md), so reading localStorage during
* the initial client render here does not cause a hydration mismatch.
*/
export function useVisitorId(): string {
const [visitorId] = useState(() => getVisitorId());
return visitorId;
}
+131 -80
View File
@@ -1,94 +1,145 @@
import {
AgentChatSurface,
markAgentChatHomeHandoff,
} from "@agent-native/core/client/agent-chat";
import { useT } from "@agent-native/core/client/i18n";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import { useActionQuery } from "@agent-native/core/client/hooks";
import { IconArrowsShuffle, IconFeather, IconSearch } from "@tabler/icons-react";
import { useMemo, useState } from "react";
import { APP_TITLE } from "@/lib/app-config";
import { TAB_ID } from "@/lib/tab-id";
const SEO_TITLE = `${APP_TITLE} - Open Source AI app starter with actions`;
const SEO_DESCRIPTION =
"Open Source starter for agent-native apps with durable chat, shared actions, UI state, tools, and a backend your agent can extend.";
import { QuoteCard } from "@/components/quotes/QuoteCard";
import { SubmitQuoteDialog } from "@/components/quotes/SubmitQuoteDialog";
import { TagFilterBar } from "@/components/quotes/TagFilterBar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { useVisitorId } from "@/lib/visitor-id";
export function meta() {
return [
{ title: SEO_TITLE },
{ title: "Verse — quotes drawn from poems" },
{
name: "description",
content: SEO_DESCRIPTION,
content: "Discover, search, and save quotes drawn from classic and community-submitted poems.",
},
{ property: "og:title", content: SEO_TITLE },
{ property: "og:description", content: SEO_DESCRIPTION },
{ name: "twitter:card", content: "summary" },
{ name: "twitter:title", content: SEO_TITLE },
{ name: "twitter:description", content: SEO_DESCRIPTION },
];
}
function chatThreadPath(threadId: string | null) {
return threadId ? `/chat/${encodeURIComponent(threadId)}` : "/";
}
export default function ChatRoute() {
const { threadId } = useParams();
const navigate = useNavigate();
const t = useT();
const threadUrlSync = threadId
? {
routeThreadId: threadId,
getPath: chatThreadPath,
navigate,
}
: undefined;
useEffect(() => {
function handleChatRunning(event: Event) {
const detail = (event as CustomEvent).detail;
if (detail?.isRunning === true) markAgentChatHomeHandoff("chat");
}
window.addEventListener("agentNative.chatRunning", handleChatRunning);
return () =>
window.removeEventListener("agentNative.chatRunning", handleChatRunning);
}, []);
export function HydrateFallback() {
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<AgentChatSurface
mode="page"
chatViewTransition
className="h-full"
defaultMode="chat"
storageKey="chat"
threadUrlSync={threadUrlSync}
browserTabId={TAB_ID}
showHeader={false}
showTabBar={false}
dynamicSuggestions={false}
suggestions={[
t("chat.suggestionCapabilities"),
t("chat.suggestionCustomize"),
t("chat.suggestionActions"),
]}
emptyStateText={t("chat.emptyState")}
emptyStateDisplay="hidden"
centerComposerWhenEmpty
composerLayoutVariant="hero"
composerPlaceholder={t("chat.composerPlaceholder")}
composerSlot={
<div className="mx-auto mb-5 max-w-xl px-4 text-center">
<h1 className="text-2xl font-semibold tracking-normal text-foreground sm:text-3xl">
{t("chat.heroTitle")}
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{t("chat.heroDescription")}
</p>
</div>
}
/>
<div className="flex items-center justify-center h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground" />
</div>
);
}
function DailyQuoteHero() {
const visitorId = useVisitorId();
const { data: quote, isLoading, refetch, isFetching } = useActionQuery("get-random-quote", {
visitorId,
});
return (
<div className="rounded-xl border bg-card p-6 sm:p-8">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<IconFeather className="size-4" />
Quote of the moment
</div>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
disabled={isFetching}
onClick={() => refetch()}
>
<IconArrowsShuffle className="size-4" />
Shuffle
</Button>
</div>
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-4 w-1/3" />
</div>
) : quote ? (
<QuoteCard quote={quote} className="border-0 p-0 shadow-none" />
) : (
<p className="text-sm text-muted-foreground">
No quotes yet be the first to submit one below.
</p>
)}
</div>
);
}
export default function QuotesHomeRoute() {
const visitorId = useVisitorId();
const [search, setSearch] = useState("");
const [activeTag, setActiveTag] = useState<string | null>(null);
const { data: quotes, isLoading } = useActionQuery("list-quotes", { visitorId });
const allTags = useMemo(() => {
const set = new Set<string>();
for (const q of quotes ?? []) for (const tag of q.tags) set.add(tag);
return Array.from(set).sort();
}, [quotes]);
const filtered = useMemo(() => {
const term = search.trim().toLowerCase();
return (quotes ?? []).filter((q) => {
if (activeTag && !q.tags.includes(activeTag)) return false;
if (!term) return true;
const haystack = `${q.text} ${q.poem.title} ${q.poem.author}`.toLowerCase();
return haystack.includes(term);
});
}, [quotes, search, activeTag]);
return (
<div className="mx-auto flex max-w-4xl flex-col gap-6 p-4 sm:p-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Verse</h1>
<p className="text-sm text-muted-foreground">
Quotes drawn from poems browse, search, and save your favorites.
</p>
</div>
<DailyQuoteHero />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1 sm:max-w-xs">
<IconSearch className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search quotes, poets, poems..."
className="pl-8"
/>
</div>
<SubmitQuoteDialog />
</div>
<TagFilterBar tags={allTags} activeTag={activeTag} onSelect={setActiveTag} />
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-48 w-full" />
))}
</div>
) : filtered.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No quotes match. Try a different search or tag.
</p>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{filtered.map((quote) => (
<QuoteCard
key={quote.id}
quote={quote}
onTagClick={(tag) => setActiveTag(tag)}
/>
))}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -1 +1 @@
export { default, meta } from "./_index";
export { default, meta } from "./chat._index";
+94
View File
@@ -0,0 +1,94 @@
import {
AgentChatSurface,
markAgentChatHomeHandoff,
} from "@agent-native/core/client/agent-chat";
import { useT } from "@agent-native/core/client/i18n";
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router";
import { APP_TITLE } from "@/lib/app-config";
import { TAB_ID } from "@/lib/tab-id";
const SEO_TITLE = `Chat - ${APP_TITLE}`;
const SEO_DESCRIPTION =
"Ask the agent to look up, add, or organize poem quotes in Verse.";
export function meta() {
return [
{ title: SEO_TITLE },
{
name: "description",
content: SEO_DESCRIPTION,
},
{ property: "og:title", content: SEO_TITLE },
{ property: "og:description", content: SEO_DESCRIPTION },
{ name: "twitter:card", content: "summary" },
{ name: "twitter:title", content: SEO_TITLE },
{ name: "twitter:description", content: SEO_DESCRIPTION },
];
}
function chatThreadPath(threadId: string | null) {
return threadId ? `/chat/${encodeURIComponent(threadId)}` : "/chat";
}
export default function ChatRoute() {
const { threadId } = useParams();
const navigate = useNavigate();
const t = useT();
const threadUrlSync = threadId
? {
routeThreadId: threadId,
getPath: chatThreadPath,
navigate,
}
: undefined;
useEffect(() => {
function handleChatRunning(event: Event) {
const detail = (event as CustomEvent).detail;
if (detail?.isRunning === true) markAgentChatHomeHandoff("chat");
}
window.addEventListener("agentNative.chatRunning", handleChatRunning);
return () =>
window.removeEventListener("agentNative.chatRunning", handleChatRunning);
}, []);
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<AgentChatSurface
mode="page"
chatViewTransition
className="h-full"
defaultMode="chat"
storageKey="chat"
threadUrlSync={threadUrlSync}
browserTabId={TAB_ID}
showHeader={false}
showTabBar={false}
dynamicSuggestions={false}
suggestions={[
t("chat.suggestionCapabilities"),
t("chat.suggestionCustomize"),
t("chat.suggestionActions"),
]}
emptyStateText={t("chat.emptyState")}
emptyStateDisplay="hidden"
centerComposerWhenEmpty
composerLayoutVariant="hero"
composerPlaceholder={t("chat.composerPlaceholder")}
composerSlot={
<div className="mx-auto mb-5 max-w-xl px-4 text-center">
<h1 className="text-2xl font-semibold tracking-normal text-foreground sm:text-3xl">
{t("chat.heroTitle")}
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{t("chat.heroDescription")}
</p>
</div>
}
/>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useActionQuery } from "@agent-native/core/client/hooks";
import { IconHeart } from "@tabler/icons-react";
import { QuoteCard } from "@/components/quotes/QuoteCard";
import { Skeleton } from "@/components/ui/skeleton";
import { useVisitorId } from "@/lib/visitor-id";
export function meta() {
return [{ title: "Favorites — Verse" }];
}
export function HydrateFallback() {
return (
<div className="flex items-center justify-center h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground" />
</div>
);
}
export default function FavoritesRoute() {
const visitorId = useVisitorId();
const { data: quotes, isLoading } = useActionQuery(
"list-favorites",
{ visitorId },
{ enabled: Boolean(visitorId) },
);
return (
<div className="mx-auto flex max-w-4xl flex-col gap-6 p-4 sm:p-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Favorites</h1>
<p className="text-sm text-muted-foreground">
Quotes you've saved on this device.
</p>
</div>
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-48 w-full" />
))}
</div>
) : !quotes || quotes.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-16 text-center text-muted-foreground">
<IconHeart className="size-8" />
<p className="text-sm">
No favorites yet tap the heart on any quote to save it here.
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{quotes.map((quote) => (
<QuoteCard key={quote.id} quote={quote} />
))}
</div>
)}
</div>
);
}