Assistant sidebar A panel down one edge, worth its width only if it can see the work beside it. It names what it currently sees, routes an answer back into the document, and every pixel it takes comes off the thing being written. npx shadcn@latest add resizable button input npm i lucide-react Tokens this needs: --background, --foreground, --card, --card-foreground, --muted, --muted-foreground, --border, --input, --ring, --chart-1 ──────────────────────────────────────────────────────────────────────── // AssistantSidebar.tsx ──────────────────────────────────────────────────────────────────────── import { useEffect, useState, type ReactNode } from "react"; import { CornerDownLeft, PanelRightOpen } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; /** One turn in the panel. `insert` is the text an answer offers to put into * the document; an answer without one is a remark, and gets no button. */ export type Turn = { role: "user" | "assistant"; text: string; insert?: string }; type Props = { /** What the assistant can currently see, in the viewer's words. Required: * a panel that will not say gets the question answered by experiment. */ scope: string; onChangeScope: () => void; turns: Turn[]; onSend: (text: string) => void; /** The path from an answer back into the document. A button, never a * copy and paste. */ onInsert: (text: string) => void; }; export function AssistantSidebar({ scope, onChangeScope, turns, onSend, onInsert }: Props) { const [draft, setDraft] = useState(""); const send = () => { if (!draft.trim()) return; onSend(draft.trim()); setDraft(""); }; return ( ); } /** The work on the left, the panel down the right edge, one keystroke to open * and the same one to close. The split is the viewer's and is remembered * under `id`. Closed, the panel collapses to a rail rather than vanishing. */ export function AssistantLayout({ id, document, sidebar, open, onToggle, defaultWidth = 38 }: { id: string; document: ReactNode; sidebar: ReactNode; open: boolean; onToggle: () => void; /** Percent of the group. Prose wants roughly 360 to 420px to read. */ defaultWidth?: number; }) { useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "j") { e.preventDefault(); onToggle(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onToggle]); return ( {document} {open ? ( <> {sidebar} ) : ( )} ); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { AssistantLayout, AssistantSidebar, type Turn } from "./AssistantSidebar"; const INTRO = "Our pricing is designed to grow with you. Whether you are a solo founder or a global enterprise, we have a plan that fits your needs and your budget, with transparent pricing and no hidden fees."; const PLANS = [ "Starter: three seats, unlimited projects, community support. Free.", "Team: £12 a seat a month, shared workspaces, priority support, SSO.", "Enterprise: custom seat pricing, audit log, dedicated support, invoicing.", ]; const TURNS: Turn[] = [ { role: "user", text: "Is the intro pulling its weight?" }, { role: "assistant", text: "The intro runs three sentences before a plan is named, and none of them says a price. One line does the job: seats, not tiers, and the first three are free.", insert: "Seats, not tiers. The first three are free.", }, ]; /** The pricing page draft on the left, the panel on the right seeing it. * Insert replaces the intro the answer is about. ⌘J closes the panel to a * rail and opens it again; the split you drag is remembered. */ export default function Demo() { const [intro, setIntro] = useState(INTRO); const [open, setOpen] = useState(true); const [turns, setTurns] = useState(TURNS); return (
setOpen((o) => !o)} document={

Pricing page draft

{intro}

{PLANS.map((p) =>

{p}

)}
} sidebar={ {}} turns={turns} onInsert={(text) => setIntro(text)} onSend={(text) => setTurns([...turns, { role: "user", text }])} /> } />
); }