"use client";
import * as React from "react";
import { ArrowsPointingOutIcon, CheckIcon, LanguageIcon, PencilSquareIcon, ScissorsIcon, SparklesIcon, XMarkIcon } from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { DiffHunkView, DiffView } from "@/components/pandacoderz-ui/diff-view";
import { TextShimmer } from "@/components/pandacoderz-ui/text-shimmer";
import { computeDiff } from "@/lib/diff";
import { sleep, streamText } from "@/lib/mock-stream";
type Action = "improve" | "shorten" | "expand" | "grammar" | "translate";
const actions: { id: Action; label: string; icon: React.ComponentType<{ size?: number; className?: string }> }[] = [
{ id: "improve", label: "Improve", icon: SparklesIcon },
{ id: "shorten", label: "Shorten", icon: ScissorsIcon },
{ id: "expand", label: "Expand", icon: ArrowsPointingOutIcon },
{ id: "grammar", label: "Fix grammar", icon: PencilSquareIcon },
{ id: "translate", label: "Translate", icon: LanguageIcon },
];
const seed = [
"Streaming is the single biggest lever for perceived speed in an AI product. The total cost of a request are identical whether you stream or not, but the first token arrives in a few hundred milliseconds instead of several seconds.",
"On the client, the only real requirement is a functional state update so that out-of-order renders never drop a chunk. Pair it with a stick-to-bottom scroll container so the newest text stays in view while the user reads.",
"Tool calls complicate this slightly. Show each call as it starts, with a running indicator, and fill in the result when it lands.",
];
const ghost = " Users forgive a slow answer far more readily when they can watch it being written.";
function rewrite(text: string, action: Action): string {
const sentences = text.split(/(?<=\.)\s+/);
switch (action) {
case "shorten":
return sentences.slice(0, Math.max(1, sentences.length - 1)).join(" ");
case "expand":
return `${text} In practice that means rendering partial markdown as it arrives and only committing the final message once the stream closes.`;
case "grammar":
return text.replace(/cost of a request are/g, "cost of a request is").replace(/so that out-of-order/g, "so out-of-order");
case "translate":
return "El streaming es la palanca más importante para la velocidad percibida en un producto de IA. El costo total de una solicitud es idéntico con o sin streaming, pero el primer token llega en unos cientos de milisegundos en lugar de varios segundos.";
case "improve":
default:
return text
.replace(/is the single biggest lever for perceived speed/g, "does more for perceived speed than any other change")
.replace(/cost of a request are identical/g, "cost of a request is identical")
.replace(/complicate this slightly/g, "add one wrinkle")
.replace(/the only real requirement/g, "the one hard requirement");
}
}
type Suggestion = { index: number; action: Action; text: string; streaming: boolean };
type Toolbar = { index: number; x: number; y: number };
export type EditorCopilotProps = { className?: string; title?: string };
export default function EditorCopilot({ className, title = "Why streaming matters" }: EditorCopilotProps) {
const [paragraphs, setParagraphs] = React.useState(seed);
const [toolbar, setToolbar] = React.useState<Toolbar | null>(null);
const [suggestion, setSuggestion] = React.useState<Suggestion | null>(null);
const [ghostVisible, setGhostVisible] = React.useState(false);
const [history, setHistory] = React.useState<string[]>([]);
const containerRef = React.useRef<HTMLDivElement>(null);
const abortRef = React.useRef<AbortController | null>(null);
// Ghost completion appears after a moment of inactivity.
React.useEffect(() => {
setGhostVisible(false);
if (suggestion || paragraphs[paragraphs.length - 1].endsWith(ghost.trim())) return;
const id = setTimeout(() => setGhostVisible(true), 1800);
return () => clearTimeout(id);
}, [paragraphs, suggestion]);
const acceptGhost = React.useCallback(() => {
if (!ghostVisible) return;
setParagraphs((prev) => prev.map((p, i) => (i === prev.length - 1 ? p + ghost : p)));
setHistory((h) => [...h, "Accepted inline completion"]);
setGhostVisible(false);
}, [ghostVisible]);
const onMouseUp = () => {
const sel = window.getSelection();
const container = containerRef.current;
if (!sel || sel.isCollapsed || !container || !sel.anchorNode || !container.contains(sel.anchorNode)) {
setToolbar(null);
return;
}
const para = (sel.anchorNode instanceof Element ? sel.anchorNode : sel.anchorNode.parentElement)?.closest<HTMLElement>("[data-para]");
if (!para) return setToolbar(null);
const rect = sel.getRangeAt(0).getBoundingClientRect();
const box = container.getBoundingClientRect();
setToolbar({ index: Number(para.dataset.para), x: rect.left - box.left + rect.width / 2, y: rect.top - box.top + container.scrollTop });
};
const runAction = async (action: Action) => {
if (!toolbar) return;
const index = toolbar.index;
setToolbar(null);
window.getSelection()?.removeAllRanges();
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const target = rewrite(paragraphs[index], action);
setSuggestion({ index, action, text: "", streaming: true });
try {
await sleep(500, controller.signal);
let out = "";
for await (const piece of streamText(target, controller.signal, 10)) {
out += piece;
setSuggestion({ index, action, text: out, streaming: true });
}
setSuggestion({ index, action, text: target, streaming: false });
} catch {
/* aborted */
}
};
const accept = () => {
if (!suggestion) return;
setParagraphs((prev) => prev.map((p, i) => (i === suggestion.index ? suggestion.text : p)));
setHistory((h) => [...h, `${actions.find((a) => a.id === suggestion.action)?.label} · paragraph ${suggestion.index + 1}`]);
setSuggestion(null);
};
const reject = () => {
abortRef.current?.abort();
setSuggestion(null);
};
React.useEffect(() => () => abortRef.current?.abort(), []);
const toSentences = (t: string) => t.split(/(?<=\.)\s+/).join("\n");
return (
<div data-slot="editor-copilot" 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 justify-between gap-3 border-b px-4">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-md bg-brand text-primary-foreground"><PencilSquareIcon size={14} className="flex" /></span>
{title}
</div>
<span className="text-xs text-muted-foreground">Select text to see AI actions · <Kbd>Tab</Kbd> accepts completions</span>
</header>
<div className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,16rem)]">
<div
ref={containerRef}
tabIndex={0}
onMouseUp={onMouseUp}
onKeyDown={(e) => {
if (e.key === "Tab" && ghostVisible) {
e.preventDefault();
acceptGhost();
}
if (e.key === "Escape") setToolbar(null);
}}
className="relative min-h-0 overflow-y-auto px-8 py-10 outline-none"
>
{toolbar ? (
<div role="toolbar" aria-label="AI actions" className="absolute z-10 flex -translate-x-1/2 -translate-y-[calc(100%+8px)] items-center gap-0.5 rounded-full border bg-popover p-1 shadow-modal animate-in fade-in-0 zoom-in-95" style={{ left: Math.max(140, Math.min(toolbar.x, (containerRef.current?.clientWidth ?? 600) - 140)), top: toolbar.y }} onMouseDown={(e) => e.preventDefault()}>
{actions.map((a) => (
<button key={a.id} type="button" onClick={() => void runAction(a.id)} className="flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-brand-soft hover:text-brand">
<a.icon size={12} className="flex" /> {a.label}
</button>
))}
</div>
) : null}
<article className="mx-auto max-w-2xl">
<h1 className="mb-6 text-3xl font-semibold tracking-tight">{title}</h1>
{paragraphs.map((p, i) => (
<React.Fragment key={i}>
<p data-para={i} className={cn("mb-5 text-[15px] leading-7 selection:bg-brand/25", suggestion?.index === i && "rounded-lg bg-brand-soft/30 -mx-2 px-2")}>
{p}
{i === paragraphs.length - 1 && ghostVisible ? (
<span className="text-muted-foreground/60">
{ghost}
<button type="button" onClick={acceptGhost} className="ml-2 inline-flex items-center gap-1 rounded-md border bg-background px-1.5 py-0.5 align-middle text-[10px] font-medium text-muted-foreground hover:text-foreground">
<Kbd className="h-4 min-w-4 px-1 text-[9px]">Tab</Kbd> accept
</button>
</span>
) : null}
</p>
{suggestion?.index === i ? (
<div className="mb-6 flex flex-col gap-2 rounded-2xl border bg-card p-3 shadow-xs animate-in fade-in-0 slide-in-from-top-1">
<div className="flex items-center gap-2 text-xs">
<SparklesIcon size={14} className="flex text-brand" />
{suggestion.streaming ? <TextShimmer invertLight>{actions.find((a) => a.id === suggestion.action)?.label}…</TextShimmer> : <span className="font-medium">{actions.find((a) => a.id === suggestion.action)?.label}</span>}
{!suggestion.streaming ? (
<div className="ml-auto flex items-center gap-1">
<Button size="xs" variant="ghost" onClick={reject}><XMarkIcon size={12} className="flex" /> Reject</Button>
<Button size="xs" onClick={accept}><CheckIcon size={12} className="flex" /> Accept</Button>
</div>
) : (
<Button size="xs" variant="ghost" className="ml-auto" onClick={reject}>Cancel</Button>
)}
</div>
{suggestion.streaming ? (
<p className="text-sm leading-6.5 text-muted-foreground">{suggestion.text}<span className="ml-0.5 inline-block h-3.5 w-0.5 translate-y-0.5 animate-pulse bg-brand" /></p>
) : (
<DiffView className="text-xs">
{computeDiff(toSentences(paragraphs[i]), toSentences(suggestion.text), 1).map((h) => <DiffHunkView key={h.id} hunk={h} lineNumbers={false} className="[&_td]:whitespace-pre-wrap [&_td]:font-sans [&_td]:leading-6" />)}
</DiffView>
)}
</div>
) : null}
</React.Fragment>
))}
</article>
</div>
<aside className="hidden min-h-0 flex-col border-l bg-surface lg:flex">
<div className="flex h-10 shrink-0 items-center px-4 text-xs font-medium text-muted-foreground">Edits</div>
{history.length ? (
<ol className="flex flex-col gap-1 px-4 text-xs">
{history.map((h, i) => <li key={i} className="flex items-center gap-2 text-muted-foreground"><CheckIcon size={12} className="flex text-emerald-600 dark:text-emerald-400" />{h}</li>)}
</ol>
) : (
<p className="px-4 text-xs text-muted-foreground">Accepted suggestions show up here. Try selecting the first sentence and pressing “Fix grammar”.</p>
)}
</aside>
</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}`;What’s inside
- Selection toolbar appears above selected text with improve, shorten, expand, fix grammar, and translate.
- Diff View shows the proposed rewrite sentence by sentence before you accept.
- Ghost text offers a continuation after a pause; Tab accepts it.
Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/editor-copilot.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/editor-copilot.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/editor-copilot.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/editor-copilot.jsonInstall the dependencies:
npm install @heroicons-animated/react motionAdd the shadcn primitives it builds on:
npx shadcn@latest add button kbdCopy the source into your project:
"use client";
import * as React from "react";
import { ArrowsPointingOutIcon, CheckIcon, LanguageIcon, PencilSquareIcon, ScissorsIcon, SparklesIcon, XMarkIcon } from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { DiffHunkView, DiffView } from "@/components/pandacoderz-ui/diff-view";
import { TextShimmer } from "@/components/pandacoderz-ui/text-shimmer";
import { computeDiff } from "@/lib/diff";
import { sleep, streamText } from "@/lib/mock-stream";
type Action = "improve" | "shorten" | "expand" | "grammar" | "translate";
const actions: { id: Action; label: string; icon: React.ComponentType<{ size?: number; className?: string }> }[] = [
{ id: "improve", label: "Improve", icon: SparklesIcon },
{ id: "shorten", label: "Shorten", icon: ScissorsIcon },
{ id: "expand", label: "Expand", icon: ArrowsPointingOutIcon },
{ id: "grammar", label: "Fix grammar", icon: PencilSquareIcon },
{ id: "translate", label: "Translate", icon: LanguageIcon },
];
const seed = [
"Streaming is the single biggest lever for perceived speed in an AI product. The total cost of a request are identical whether you stream or not, but the first token arrives in a few hundred milliseconds instead of several seconds.",
"On the client, the only real requirement is a functional state update so that out-of-order renders never drop a chunk. Pair it with a stick-to-bottom scroll container so the newest text stays in view while the user reads.",
"Tool calls complicate this slightly. Show each call as it starts, with a running indicator, and fill in the result when it lands.",
];
const ghost = " Users forgive a slow answer far more readily when they can watch it being written.";
function rewrite(text: string, action: Action): string {
const sentences = text.split(/(?<=\.)\s+/);
switch (action) {
case "shorten":
return sentences.slice(0, Math.max(1, sentences.length - 1)).join(" ");
case "expand":
return `${text} In practice that means rendering partial markdown as it arrives and only committing the final message once the stream closes.`;
case "grammar":
return text.replace(/cost of a request are/g, "cost of a request is").replace(/so that out-of-order/g, "so out-of-order");
case "translate":
return "El streaming es la palanca más importante para la velocidad percibida en un producto de IA. El costo total de una solicitud es idéntico con o sin streaming, pero el primer token llega en unos cientos de milisegundos en lugar de varios segundos.";
case "improve":
default:
return text
.replace(/is the single biggest lever for perceived speed/g, "does more for perceived speed than any other change")
.replace(/cost of a request are identical/g, "cost of a request is identical")
.replace(/complicate this slightly/g, "add one wrinkle")
.replace(/the only real requirement/g, "the one hard requirement");
}
}
type Suggestion = { index: number; action: Action; text: string; streaming: boolean };
type Toolbar = { index: number; x: number; y: number };
export type EditorCopilotProps = { className?: string; title?: string };
export default function EditorCopilot({ className, title = "Why streaming matters" }: EditorCopilotProps) {
const [paragraphs, setParagraphs] = React.useState(seed);
const [toolbar, setToolbar] = React.useState<Toolbar | null>(null);
const [suggestion, setSuggestion] = React.useState<Suggestion | null>(null);
const [ghostVisible, setGhostVisible] = React.useState(false);
const [history, setHistory] = React.useState<string[]>([]);
const containerRef = React.useRef<HTMLDivElement>(null);
const abortRef = React.useRef<AbortController | null>(null);
// Ghost completion appears after a moment of inactivity.
React.useEffect(() => {
setGhostVisible(false);
if (suggestion || paragraphs[paragraphs.length - 1].endsWith(ghost.trim())) return;
const id = setTimeout(() => setGhostVisible(true), 1800);
return () => clearTimeout(id);
}, [paragraphs, suggestion]);
const acceptGhost = React.useCallback(() => {
if (!ghostVisible) return;
setParagraphs((prev) => prev.map((p, i) => (i === prev.length - 1 ? p + ghost : p)));
setHistory((h) => [...h, "Accepted inline completion"]);
setGhostVisible(false);
}, [ghostVisible]);
const onMouseUp = () => {
const sel = window.getSelection();
const container = containerRef.current;
if (!sel || sel.isCollapsed || !container || !sel.anchorNode || !container.contains(sel.anchorNode)) {
setToolbar(null);
return;
}
const para = (sel.anchorNode instanceof Element ? sel.anchorNode : sel.anchorNode.parentElement)?.closest<HTMLElement>("[data-para]");
if (!para) return setToolbar(null);
const rect = sel.getRangeAt(0).getBoundingClientRect();
const box = container.getBoundingClientRect();
setToolbar({ index: Number(para.dataset.para), x: rect.left - box.left + rect.width / 2, y: rect.top - box.top + container.scrollTop });
};
const runAction = async (action: Action) => {
if (!toolbar) return;
const index = toolbar.index;
setToolbar(null);
window.getSelection()?.removeAllRanges();
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const target = rewrite(paragraphs[index], action);
setSuggestion({ index, action, text: "", streaming: true });
try {
await sleep(500, controller.signal);
let out = "";
for await (const piece of streamText(target, controller.signal, 10)) {
out += piece;
setSuggestion({ index, action, text: out, streaming: true });
}
setSuggestion({ index, action, text: target, streaming: false });
} catch {
/* aborted */
}
};
const accept = () => {
if (!suggestion) return;
setParagraphs((prev) => prev.map((p, i) => (i === suggestion.index ? suggestion.text : p)));
setHistory((h) => [...h, `${actions.find((a) => a.id === suggestion.action)?.label} · paragraph ${suggestion.index + 1}`]);
setSuggestion(null);
};
const reject = () => {
abortRef.current?.abort();
setSuggestion(null);
};
React.useEffect(() => () => abortRef.current?.abort(), []);
const toSentences = (t: string) => t.split(/(?<=\.)\s+/).join("\n");
return (
<div data-slot="editor-copilot" 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 justify-between gap-3 border-b px-4">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-md bg-brand text-primary-foreground"><PencilSquareIcon size={14} className="flex" /></span>
{title}
</div>
<span className="text-xs text-muted-foreground">Select text to see AI actions · <Kbd>Tab</Kbd> accepts completions</span>
</header>
<div className="grid min-h-0 flex-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,16rem)]">
<div
ref={containerRef}
tabIndex={0}
onMouseUp={onMouseUp}
onKeyDown={(e) => {
if (e.key === "Tab" && ghostVisible) {
e.preventDefault();
acceptGhost();
}
if (e.key === "Escape") setToolbar(null);
}}
className="relative min-h-0 overflow-y-auto px-8 py-10 outline-none"
>
{toolbar ? (
<div role="toolbar" aria-label="AI actions" className="absolute z-10 flex -translate-x-1/2 -translate-y-[calc(100%+8px)] items-center gap-0.5 rounded-full border bg-popover p-1 shadow-modal animate-in fade-in-0 zoom-in-95" style={{ left: Math.max(140, Math.min(toolbar.x, (containerRef.current?.clientWidth ?? 600) - 140)), top: toolbar.y }} onMouseDown={(e) => e.preventDefault()}>
{actions.map((a) => (
<button key={a.id} type="button" onClick={() => void runAction(a.id)} className="flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-brand-soft hover:text-brand">
<a.icon size={12} className="flex" /> {a.label}
</button>
))}
</div>
) : null}
<article className="mx-auto max-w-2xl">
<h1 className="mb-6 text-3xl font-semibold tracking-tight">{title}</h1>
{paragraphs.map((p, i) => (
<React.Fragment key={i}>
<p data-para={i} className={cn("mb-5 text-[15px] leading-7 selection:bg-brand/25", suggestion?.index === i && "rounded-lg bg-brand-soft/30 -mx-2 px-2")}>
{p}
{i === paragraphs.length - 1 && ghostVisible ? (
<span className="text-muted-foreground/60">
{ghost}
<button type="button" onClick={acceptGhost} className="ml-2 inline-flex items-center gap-1 rounded-md border bg-background px-1.5 py-0.5 align-middle text-[10px] font-medium text-muted-foreground hover:text-foreground">
<Kbd className="h-4 min-w-4 px-1 text-[9px]">Tab</Kbd> accept
</button>
</span>
) : null}
</p>
{suggestion?.index === i ? (
<div className="mb-6 flex flex-col gap-2 rounded-2xl border bg-card p-3 shadow-xs animate-in fade-in-0 slide-in-from-top-1">
<div className="flex items-center gap-2 text-xs">
<SparklesIcon size={14} className="flex text-brand" />
{suggestion.streaming ? <TextShimmer invertLight>{actions.find((a) => a.id === suggestion.action)?.label}…</TextShimmer> : <span className="font-medium">{actions.find((a) => a.id === suggestion.action)?.label}</span>}
{!suggestion.streaming ? (
<div className="ml-auto flex items-center gap-1">
<Button size="xs" variant="ghost" onClick={reject}><XMarkIcon size={12} className="flex" /> Reject</Button>
<Button size="xs" onClick={accept}><CheckIcon size={12} className="flex" /> Accept</Button>
</div>
) : (
<Button size="xs" variant="ghost" className="ml-auto" onClick={reject}>Cancel</Button>
)}
</div>
{suggestion.streaming ? (
<p className="text-sm leading-6.5 text-muted-foreground">{suggestion.text}<span className="ml-0.5 inline-block h-3.5 w-0.5 translate-y-0.5 animate-pulse bg-brand" /></p>
) : (
<DiffView className="text-xs">
{computeDiff(toSentences(paragraphs[i]), toSentences(suggestion.text), 1).map((h) => <DiffHunkView key={h.id} hunk={h} lineNumbers={false} className="[&_td]:whitespace-pre-wrap [&_td]:font-sans [&_td]:leading-6" />)}
</DiffView>
)}
</div>
) : null}
</React.Fragment>
))}
</article>
</div>
<aside className="hidden min-h-0 flex-col border-l bg-surface lg:flex">
<div className="flex h-10 shrink-0 items-center px-4 text-xs font-medium text-muted-foreground">Edits</div>
{history.length ? (
<ol className="flex flex-col gap-1 px-4 text-xs">
{history.map((h, i) => <li key={i} className="flex items-center gap-2 text-muted-foreground"><CheckIcon size={12} className="flex text-emerald-600 dark:text-emerald-400" />{h}</li>)}
</ol>
) : (
<p className="px-4 text-xs text-muted-foreground">Accepted suggestions show up here. Try selecting the first sentence and pressing “Fix grammar”.</p>
)}
</aside>
</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}`;The registry item pulls in every component it depends on.
Usage
import EditorCopilot from "@/components/blocks/editor-copilot/editor-copilot";
export default function Page() {
return (
<div className="h-dvh p-4">
<EditorCopilot />
</div>
);
}Give the parent a bounded height. The block fills it and scrolls internally.
Going live
Swap rewrite for a model call that receives the paragraph and the action, and the ghost completion for a call that receives the trailing context. The selection math works on any block-level elements marked with data-para.