"use client";
import * as React from "react";
import {
ArrowPathIcon,
ArrowUpIcon,
DocumentDuplicateIcon,
GlobeAltIcon,
MagnifyingGlassIcon,
SparklesIcon,
} from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { CitedText, SourceCard, SourceList, type Source } from "@/components/pandacoderz-ui/citation";
import { PromptInput, PromptInputAction, PromptInputActionGroup, PromptInputActions, PromptInputTextarea } from "@/components/pandacoderz-ui/prompt-input";
import { Suggestion, SuggestionList, Suggestions } from "@/components/pandacoderz-ui/suggestions";
import { TextShimmer } from "@/components/pandacoderz-ui/text-shimmer";
import { Thread, ThreadContent, ThreadScrollToBottom } from "@/components/pandacoderz-ui/thread";
import { sleep, uid } from "@/lib/mock-stream";
import { useStreamText } from "@/lib/use-stream-text";
type Answer = { sources: Source[]; text: string; followUps: string[] };
const canned: { match: RegExp; answer: Answer }[] = [
{
match: /astro|island|hydrat/i,
answer: {
sources: [
{ id: "a1", title: "Islands architecture", url: "https://docs.astro.build/en/concepts/islands/", snippet: "Astro ships zero JavaScript by default and hydrates only the components you mark as interactive.", date: "2025" },
{ id: "a2", title: "Template directives reference", url: "https://docs.astro.build/en/reference/directives-reference/", snippet: "client:load, client:idle, client:visible and client:media control when a component hydrates." },
{ id: "a3", title: "Why Astro?", url: "https://docs.astro.build/en/concepts/why-astro/", snippet: "Astro is a web framework for content-driven websites, designed for performance." },
],
text: `An island is an interactive UI component on an otherwise static HTML page. Astro renders the page to HTML at build time and ships JavaScript only for the islands you opt into, which is why pages start fast [1].\n\nYou control when each island hydrates with a client directive: client:load runs immediately, client:idle waits for the main thread to settle, and client:visible defers until the component scrolls into view [2]. For content-heavy sites that keeps the initial bundle tiny while still allowing rich interactivity where it matters [3].`,
followUps: ["When should I use client:only?", "How do islands share state?", "Compare Astro with Next.js"],
},
},
{
match: /stream|latency|token/i,
answer: {
sources: [
{ id: "b1", title: "Streaming Messages", url: "https://docs.anthropic.com/en/api/messages-streaming", snippet: "Server-sent events deliver content_block_delta events as the model generates text." },
{ id: "b2", title: "Perceived performance", url: "https://web.dev/articles/user-centric-performance-metrics", snippet: "Users judge speed by when content first appears, not when the last byte arrives." },
],
text: `Yes, stream. The total cost is the same but the first token arrives in a few hundred milliseconds instead of several seconds [1]. Users judge responsiveness by when text starts appearing, so streaming makes the same request feel far faster [2].\n\nOn the client, append each delta with a functional state update and render inside a stick-to-bottom container so the newest text stays in view.`,
followUps: ["Show a fetch streaming example", "How do I handle stop and retry?", "What about tool calls while streaming?"],
},
},
{
match: /.*/,
answer: {
sources: [
{ id: "c1", title: "shadcn/ui registry", url: "https://ui.shadcn.com/docs/registry", snippet: "A registry lets you distribute components and blocks that install with the shadcn CLI." },
{ id: "c2", title: "Building composable components", url: "https://www.radix-ui.com/primitives/docs/overview/introduction", snippet: "Unstyled, accessible primitives you compose into your own design system." },
],
text: `This library is a set of composable React components for AI interfaces, distributed as a shadcn registry so the code is copied into your project rather than installed as a dependency [1]. Each component is a small family of parts sharing context, following the same composition model as Radix primitives [2].\n\nTry asking about Astro islands or streaming to see other scripted answers.`,
followUps: ["What blocks are available?", "How do I theme the components?", "Can I use this with Next.js?"],
},
},
];
const suggestions = ["How do Astro islands work?", "Should I stream model responses?", "What is this library?"];
type Phase = "idle" | "searching" | "reading" | "answering" | "done";
export type AnswerWithSourcesProps = { className?: string; initialQuery?: string };
export default function AnswerWithSources({ className, initialQuery }: AnswerWithSourcesProps) {
const [draft, setDraft] = React.useState("");
const [query, setQuery] = React.useState<string | null>(null);
const [phase, setPhase] = React.useState<Phase>("idle");
const [answer, setAnswer] = React.useState<Answer | null>(null);
const [activeSource, setActiveSource] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
const { text, isStreaming, start, stop, reset } = useStreamText(12);
const abortRef = React.useRef<AbortController | null>(null);
const ask = React.useCallback(
async (q: string) => {
const trimmed = q.trim();
if (!trimmed) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setDraft("");
setQuery(trimmed);
setAnswer(null);
reset();
const found = canned.find((c) => c.match.test(trimmed))?.answer ?? canned.at(-1)!.answer;
try {
setPhase("searching");
await sleep(700, controller.signal);
setAnswer(found);
setPhase("reading");
await sleep(600, controller.signal);
setPhase("answering");
await start(found.text, () => setPhase("done"));
} catch {
/* aborted */
}
},
[reset, start],
);
React.useEffect(() => {
if (initialQuery) void ask(initialQuery);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const stopAll = () => {
abortRef.current?.abort();
stop();
setPhase("done");
};
return (
<div data-slot="answer-with-sources" className={cn("flex h-full min-h-0 w-full flex-col overflow-hidden rounded-3xl border bg-background shadow-xs", className)}>
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-4 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-md bg-brand text-primary-foreground"><GlobeAltIcon size={14} className="flex" /></span>
Answers
<span className="ml-auto text-xs font-normal text-muted-foreground">Sources are illustrative</span>
</header>
<div className="relative min-h-0 flex-1">
{query === null ? (
<div className="flex h-full flex-col items-center justify-center gap-6 px-6 text-center">
<div className="flex size-12 items-center justify-center rounded-2xl bg-brand-soft text-brand"><MagnifyingGlassIcon size={22} className="flex" /></div>
<div className="space-y-1">
<h2 className="text-lg font-medium tracking-tight">Ask anything, get a cited answer</h2>
<p className="text-sm text-muted-foreground">Every claim links back to the source it came from.</p>
</div>
<Suggestions onSelect={ask}>
<SuggestionList>{suggestions.map((s) => <Suggestion key={s}>{s}</Suggestion>)}</SuggestionList>
</Suggestions>
</div>
) : (
<Thread className="h-full">
<ThreadContent className="mx-auto max-w-3xl gap-5">
<h2 className="text-xl font-medium tracking-tight">{query}</h2>
<section className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<GlobeAltIcon size={14} className="flex" />
{phase === "searching" ? <TextShimmer invertLight>Searching the web…</TextShimmer> : `${answer?.sources.length ?? 0} sources`}
</div>
{answer ? (
<SourceList className="sm:grid-cols-3">
{answer.sources.map((s, i) => (
<SourceCard key={s.id} index={i + 1} source={s} compact active={activeSource === s.id} onMouseEnter={() => setActiveSource(s.id)} onMouseLeave={() => setActiveSource(null)} />
))}
</SourceList>
) : (
<div className="grid gap-2 sm:grid-cols-3">
{[0, 1, 2].map((i) => <div key={i} className="h-16 animate-pulse rounded-xl border bg-muted/50" />)}
</div>
)}
</section>
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<SparklesIcon size={14} className="flex" />
{phase === "reading" ? <TextShimmer invertLight>Reading sources…</TextShimmer> : phase === "answering" ? <TextShimmer invertLight>Writing…</TextShimmer> : "Answer"}
</div>
{answer && (phase === "answering" || phase === "done") ? (
<div className="flex flex-col gap-3">
{text.split("\n\n").map((para, i) => (
<CitedText key={i} text={para} sources={answer.sources} activeId={activeSource} onHover={setActiveSource} className="text-[15px] leading-7" />
))}
</div>
) : null}
{phase === "done" ? (
<div className="flex items-center gap-1">
<Button variant="ghost" size="xs" className="rounded-full text-muted-foreground" onClick={async () => { try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch {} }}>
<DocumentDuplicateIcon size={12} className="flex" /> {copied ? "Copied" : "Copy"}
</Button>
<Button variant="ghost" size="xs" className="rounded-full text-muted-foreground" onClick={() => ask(query)}>
<ArrowPathIcon size={12} className="flex" /> Rewrite
</Button>
</div>
) : null}
</section>
{phase === "done" && answer ? (
<section className="flex flex-col gap-2 border-t pt-4">
<span className="text-xs font-medium text-muted-foreground">Related</span>
<Suggestions onSelect={ask}>
<SuggestionList orientation="vertical">
{answer.followUps.map((f) => <Suggestion key={f} value={f} variant="ghost" className="justify-start px-2">→ {f}</Suggestion>)}
</SuggestionList>
</Suggestions>
</section>
) : null}
</ThreadContent>
<ThreadScrollToBottom />
</Thread>
)}
</div>
<div className="shrink-0 px-4 pb-4 pt-2">
<div className="mx-auto max-w-3xl">
<PromptInput onSubmit={ask}>
<PromptInputTextarea value={draft} onChange={(e) => setDraft(e.target.value)} placeholder={query ? "Ask a follow-up" : "Ask anything"} />
<PromptInputActions>
<PromptInputActionGroup>
<span className="px-2 text-xs text-muted-foreground">Web search on</span>
</PromptInputActionGroup>
<PromptInputActionGroup>
{isStreaming || phase === "searching" || phase === "reading" ? (
<PromptInputAction asChild tooltip="Stop"><Button size="icon-sm" variant="secondary" className="rounded-full" aria-label="Stop" onClick={stopAll}><span className="block size-2.5 rounded-[2px] bg-current" /></Button></PromptInputAction>
) : (
<PromptInputAction asChild tooltip={{ content: "Search", shortcut: "↵" }}><Button size="icon-sm" className="rounded-full" aria-label="Search" disabled={!draft.trim()} onClick={() => ask(draft)}><ArrowUpIcon size={16} className="flex" /></Button></PromptInputAction>
)}
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>
</div>
</div>
</div>
);
}/**
* Helpers for scripted streaming in demos. Blocks use these to fake a model
* response token by token without touching the network. Swap them for a real
* fetch in production; the UI code does not change.
*/
export function sleep(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const id = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(id);
reject(signal.reason);
},
{ once: true },
);
});
}
/** Split text into word-ish chunks so streaming looks natural. */
export function chunk(text: string): string[] {
return text.match(/\S+\s*|\s+/g) ?? [text];
}
export function jitter(base: number) {
return base + Math.random() * base;
}
/** Yield a string piece by piece with a small random delay between pieces. */
export async function* streamText(
text: string,
signal?: AbortSignal,
delayMs = 14,
): AsyncGenerator<string> {
for (const piece of chunk(text)) {
if (signal?.aborted) return;
yield piece;
await sleep(jitter(delayMs), signal);
}
}
let counter = 0;
export const uid = (prefix = "id") => `${prefix}-${Date.now().toString(36)}-${++counter}`;"use client";
import * as React from "react";
import { streamText } from "./mock-stream";
/**
* Drive a piece of text onto the screen as if it were streaming from a model.
* `start(text)` clears and streams; `stop()` freezes what has arrived.
*/
export function useStreamText(delayMs = 14) {
const [text, setText] = React.useState("");
const [isStreaming, setIsStreaming] = React.useState(false);
const abortRef = React.useRef<AbortController | null>(null);
const stop = React.useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setIsStreaming(false);
}, []);
const start = React.useCallback(
async (full: string, onDone?: () => void) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setText("");
setIsStreaming(true);
try {
for await (const piece of streamText(full, controller.signal, delayMs)) {
setText((prev) => prev + piece);
}
if (!controller.signal.aborted) onDone?.();
} catch {
/* aborted */
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
setIsStreaming(false);
}
}
},
[delayMs],
);
const reset = React.useCallback(() => {
stop();
setText("");
}, [stop]);
React.useEffect(() => () => abortRef.current?.abort(), []);
return { text, isStreaming, start, stop, reset, setText };
}What’s inside
- Prompt Input as the search bar, with a stop button while the answer streams.
- Citation renders numbered chips inside the answer and source cards above it. Hovering either highlights both.
- Suggestions power the empty state and the follow-up questions.
- Text Shimmer narrates the searching, reading, and writing phases.
Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/answer-with-sources.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/answer-with-sources.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/answer-with-sources.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/answer-with-sources.jsonInstall the dependencies:
npm install radix-ui @heroicons-animated/react motionAdd the shadcn primitives it builds on:
npx shadcn@latest add buttonCopy the source into your project:
"use client";
import * as React from "react";
import {
ArrowPathIcon,
ArrowUpIcon,
DocumentDuplicateIcon,
GlobeAltIcon,
MagnifyingGlassIcon,
SparklesIcon,
} from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { CitedText, SourceCard, SourceList, type Source } from "@/components/pandacoderz-ui/citation";
import { PromptInput, PromptInputAction, PromptInputActionGroup, PromptInputActions, PromptInputTextarea } from "@/components/pandacoderz-ui/prompt-input";
import { Suggestion, SuggestionList, Suggestions } from "@/components/pandacoderz-ui/suggestions";
import { TextShimmer } from "@/components/pandacoderz-ui/text-shimmer";
import { Thread, ThreadContent, ThreadScrollToBottom } from "@/components/pandacoderz-ui/thread";
import { sleep, uid } from "@/lib/mock-stream";
import { useStreamText } from "@/lib/use-stream-text";
type Answer = { sources: Source[]; text: string; followUps: string[] };
const canned: { match: RegExp; answer: Answer }[] = [
{
match: /astro|island|hydrat/i,
answer: {
sources: [
{ id: "a1", title: "Islands architecture", url: "https://docs.astro.build/en/concepts/islands/", snippet: "Astro ships zero JavaScript by default and hydrates only the components you mark as interactive.", date: "2025" },
{ id: "a2", title: "Template directives reference", url: "https://docs.astro.build/en/reference/directives-reference/", snippet: "client:load, client:idle, client:visible and client:media control when a component hydrates." },
{ id: "a3", title: "Why Astro?", url: "https://docs.astro.build/en/concepts/why-astro/", snippet: "Astro is a web framework for content-driven websites, designed for performance." },
],
text: `An island is an interactive UI component on an otherwise static HTML page. Astro renders the page to HTML at build time and ships JavaScript only for the islands you opt into, which is why pages start fast [1].\n\nYou control when each island hydrates with a client directive: client:load runs immediately, client:idle waits for the main thread to settle, and client:visible defers until the component scrolls into view [2]. For content-heavy sites that keeps the initial bundle tiny while still allowing rich interactivity where it matters [3].`,
followUps: ["When should I use client:only?", "How do islands share state?", "Compare Astro with Next.js"],
},
},
{
match: /stream|latency|token/i,
answer: {
sources: [
{ id: "b1", title: "Streaming Messages", url: "https://docs.anthropic.com/en/api/messages-streaming", snippet: "Server-sent events deliver content_block_delta events as the model generates text." },
{ id: "b2", title: "Perceived performance", url: "https://web.dev/articles/user-centric-performance-metrics", snippet: "Users judge speed by when content first appears, not when the last byte arrives." },
],
text: `Yes, stream. The total cost is the same but the first token arrives in a few hundred milliseconds instead of several seconds [1]. Users judge responsiveness by when text starts appearing, so streaming makes the same request feel far faster [2].\n\nOn the client, append each delta with a functional state update and render inside a stick-to-bottom container so the newest text stays in view.`,
followUps: ["Show a fetch streaming example", "How do I handle stop and retry?", "What about tool calls while streaming?"],
},
},
{
match: /.*/,
answer: {
sources: [
{ id: "c1", title: "shadcn/ui registry", url: "https://ui.shadcn.com/docs/registry", snippet: "A registry lets you distribute components and blocks that install with the shadcn CLI." },
{ id: "c2", title: "Building composable components", url: "https://www.radix-ui.com/primitives/docs/overview/introduction", snippet: "Unstyled, accessible primitives you compose into your own design system." },
],
text: `This library is a set of composable React components for AI interfaces, distributed as a shadcn registry so the code is copied into your project rather than installed as a dependency [1]. Each component is a small family of parts sharing context, following the same composition model as Radix primitives [2].\n\nTry asking about Astro islands or streaming to see other scripted answers.`,
followUps: ["What blocks are available?", "How do I theme the components?", "Can I use this with Next.js?"],
},
},
];
const suggestions = ["How do Astro islands work?", "Should I stream model responses?", "What is this library?"];
type Phase = "idle" | "searching" | "reading" | "answering" | "done";
export type AnswerWithSourcesProps = { className?: string; initialQuery?: string };
export default function AnswerWithSources({ className, initialQuery }: AnswerWithSourcesProps) {
const [draft, setDraft] = React.useState("");
const [query, setQuery] = React.useState<string | null>(null);
const [phase, setPhase] = React.useState<Phase>("idle");
const [answer, setAnswer] = React.useState<Answer | null>(null);
const [activeSource, setActiveSource] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
const { text, isStreaming, start, stop, reset } = useStreamText(12);
const abortRef = React.useRef<AbortController | null>(null);
const ask = React.useCallback(
async (q: string) => {
const trimmed = q.trim();
if (!trimmed) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setDraft("");
setQuery(trimmed);
setAnswer(null);
reset();
const found = canned.find((c) => c.match.test(trimmed))?.answer ?? canned.at(-1)!.answer;
try {
setPhase("searching");
await sleep(700, controller.signal);
setAnswer(found);
setPhase("reading");
await sleep(600, controller.signal);
setPhase("answering");
await start(found.text, () => setPhase("done"));
} catch {
/* aborted */
}
},
[reset, start],
);
React.useEffect(() => {
if (initialQuery) void ask(initialQuery);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const stopAll = () => {
abortRef.current?.abort();
stop();
setPhase("done");
};
return (
<div data-slot="answer-with-sources" className={cn("flex h-full min-h-0 w-full flex-col overflow-hidden rounded-3xl border bg-background shadow-xs", className)}>
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-4 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-md bg-brand text-primary-foreground"><GlobeAltIcon size={14} className="flex" /></span>
Answers
<span className="ml-auto text-xs font-normal text-muted-foreground">Sources are illustrative</span>
</header>
<div className="relative min-h-0 flex-1">
{query === null ? (
<div className="flex h-full flex-col items-center justify-center gap-6 px-6 text-center">
<div className="flex size-12 items-center justify-center rounded-2xl bg-brand-soft text-brand"><MagnifyingGlassIcon size={22} className="flex" /></div>
<div className="space-y-1">
<h2 className="text-lg font-medium tracking-tight">Ask anything, get a cited answer</h2>
<p className="text-sm text-muted-foreground">Every claim links back to the source it came from.</p>
</div>
<Suggestions onSelect={ask}>
<SuggestionList>{suggestions.map((s) => <Suggestion key={s}>{s}</Suggestion>)}</SuggestionList>
</Suggestions>
</div>
) : (
<Thread className="h-full">
<ThreadContent className="mx-auto max-w-3xl gap-5">
<h2 className="text-xl font-medium tracking-tight">{query}</h2>
<section className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<GlobeAltIcon size={14} className="flex" />
{phase === "searching" ? <TextShimmer invertLight>Searching the web…</TextShimmer> : `${answer?.sources.length ?? 0} sources`}
</div>
{answer ? (
<SourceList className="sm:grid-cols-3">
{answer.sources.map((s, i) => (
<SourceCard key={s.id} index={i + 1} source={s} compact active={activeSource === s.id} onMouseEnter={() => setActiveSource(s.id)} onMouseLeave={() => setActiveSource(null)} />
))}
</SourceList>
) : (
<div className="grid gap-2 sm:grid-cols-3">
{[0, 1, 2].map((i) => <div key={i} className="h-16 animate-pulse rounded-xl border bg-muted/50" />)}
</div>
)}
</section>
<section className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<SparklesIcon size={14} className="flex" />
{phase === "reading" ? <TextShimmer invertLight>Reading sources…</TextShimmer> : phase === "answering" ? <TextShimmer invertLight>Writing…</TextShimmer> : "Answer"}
</div>
{answer && (phase === "answering" || phase === "done") ? (
<div className="flex flex-col gap-3">
{text.split("\n\n").map((para, i) => (
<CitedText key={i} text={para} sources={answer.sources} activeId={activeSource} onHover={setActiveSource} className="text-[15px] leading-7" />
))}
</div>
) : null}
{phase === "done" ? (
<div className="flex items-center gap-1">
<Button variant="ghost" size="xs" className="rounded-full text-muted-foreground" onClick={async () => { try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch {} }}>
<DocumentDuplicateIcon size={12} className="flex" /> {copied ? "Copied" : "Copy"}
</Button>
<Button variant="ghost" size="xs" className="rounded-full text-muted-foreground" onClick={() => ask(query)}>
<ArrowPathIcon size={12} className="flex" /> Rewrite
</Button>
</div>
) : null}
</section>
{phase === "done" && answer ? (
<section className="flex flex-col gap-2 border-t pt-4">
<span className="text-xs font-medium text-muted-foreground">Related</span>
<Suggestions onSelect={ask}>
<SuggestionList orientation="vertical">
{answer.followUps.map((f) => <Suggestion key={f} value={f} variant="ghost" className="justify-start px-2">→ {f}</Suggestion>)}
</SuggestionList>
</Suggestions>
</section>
) : null}
</ThreadContent>
<ThreadScrollToBottom />
</Thread>
)}
</div>
<div className="shrink-0 px-4 pb-4 pt-2">
<div className="mx-auto max-w-3xl">
<PromptInput onSubmit={ask}>
<PromptInputTextarea value={draft} onChange={(e) => setDraft(e.target.value)} placeholder={query ? "Ask a follow-up" : "Ask anything"} />
<PromptInputActions>
<PromptInputActionGroup>
<span className="px-2 text-xs text-muted-foreground">Web search on</span>
</PromptInputActionGroup>
<PromptInputActionGroup>
{isStreaming || phase === "searching" || phase === "reading" ? (
<PromptInputAction asChild tooltip="Stop"><Button size="icon-sm" variant="secondary" className="rounded-full" aria-label="Stop" onClick={stopAll}><span className="block size-2.5 rounded-[2px] bg-current" /></Button></PromptInputAction>
) : (
<PromptInputAction asChild tooltip={{ content: "Search", shortcut: "↵" }}><Button size="icon-sm" className="rounded-full" aria-label="Search" disabled={!draft.trim()} onClick={() => ask(draft)}><ArrowUpIcon size={16} className="flex" /></Button></PromptInputAction>
)}
</PromptInputActionGroup>
</PromptInputActions>
</PromptInput>
</div>
</div>
</div>
);
}/**
* Helpers for scripted streaming in demos. Blocks use these to fake a model
* response token by token without touching the network. Swap them for a real
* fetch in production; the UI code does not change.
*/
export function sleep(ms: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const id = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(id);
reject(signal.reason);
},
{ once: true },
);
});
}
/** Split text into word-ish chunks so streaming looks natural. */
export function chunk(text: string): string[] {
return text.match(/\S+\s*|\s+/g) ?? [text];
}
export function jitter(base: number) {
return base + Math.random() * base;
}
/** Yield a string piece by piece with a small random delay between pieces. */
export async function* streamText(
text: string,
signal?: AbortSignal,
delayMs = 14,
): AsyncGenerator<string> {
for (const piece of chunk(text)) {
if (signal?.aborted) return;
yield piece;
await sleep(jitter(delayMs), signal);
}
}
let counter = 0;
export const uid = (prefix = "id") => `${prefix}-${Date.now().toString(36)}-${++counter}`;"use client";
import * as React from "react";
import { streamText } from "./mock-stream";
/**
* Drive a piece of text onto the screen as if it were streaming from a model.
* `start(text)` clears and streams; `stop()` freezes what has arrived.
*/
export function useStreamText(delayMs = 14) {
const [text, setText] = React.useState("");
const [isStreaming, setIsStreaming] = React.useState(false);
const abortRef = React.useRef<AbortController | null>(null);
const stop = React.useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setIsStreaming(false);
}, []);
const start = React.useCallback(
async (full: string, onDone?: () => void) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setText("");
setIsStreaming(true);
try {
for await (const piece of streamText(full, controller.signal, delayMs)) {
setText((prev) => prev + piece);
}
if (!controller.signal.aborted) onDone?.();
} catch {
/* aborted */
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
setIsStreaming(false);
}
}
},
[delayMs],
);
const reset = React.useCallback(() => {
stop();
setText("");
}, [stop]);
React.useEffect(() => () => abortRef.current?.abort(), []);
return { text, isStreaming, start, stop, reset, setText };
}The registry item pulls in every component it depends on.
Usage
import AnswerWithSources from "@/components/blocks/answer-with-sources/answer-with-sources";
export default function Page() {
return (
<div className="h-dvh p-4">
<AnswerWithSources />
</div>
);
}Give the parent a bounded height. The block fills it and scrolls internally.
Going live
The ask function drives three phases: fetch sources, then stream the answer. Point it at your search endpoint for the sources and your model for the text, and keep [n] markers in the model output so CitedText can link them.