Build Verse: a poem-quote app on the agent-native chat template
CI / build (push) Successful in 4m26s
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:
+131
-80
@@ -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 @@
|
||||
export { default, meta } from "./_index";
|
||||
export { default, meta } from "./chat._index";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user