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
+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>
);
}