Conversation history A filing system nobody agreed to maintain. Titles are generated, so one of them is an opening pleasantry; search has to reach message bodies rather than titles alone; and delete has to say whether it means the list, storage, or training. npx shadcn@latest add command dropdown-menu button npm i cmdk lucide-react Tokens this needs: --card, --card-foreground, --muted-foreground, --border, --accent, --accent-foreground, --status-warn The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // ConversationHistory.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; export type Conversation = { id: string; title: string; /** How many turns the title was generated from. One means it was taken * from the opener, which is how "Quick question" gets filed forever. */ titledFromTurns: number; /** Every message, joined. Search runs over this, never over the title. */ body: string; updatedAt: Date; }; /** Three operations that people read as one. The wording has to say which. */ export type DeleteScope = "list" | "storage" | "training"; const DELETE_LABEL: Record = { list: "Remove from this list", storage: "Delete from storage", training: "Delete and exclude from training", }; /** Below this many turns a generated title is a guess, and the row says so. */ const TITLE_NEEDS_TURNS = 3; const DAY = 86_400_000; const bucket = (age: number) => age < DAY ? "Today" : age < 2 * DAY ? "Yesterday" : age < 7 * DAY ? "Previous 7 days" : age < 30 * DAY ? "Previous 30 days" : "Older"; type Props = { conversations: Conversation[]; onOpen: (id: string) => void; onDelete: (id: string, scope: DeleteScope) => void; onStartTemporary: () => void; /** Stated as a number, because a vague promise is worse than none. */ temporaryRetentionHours: number; /** The clock to group against. Pass one to render on a server. */ now?: Date; }; export function ConversationHistory({ conversations, onOpen, onDelete, onStartTemporary, temporaryRetentionHours, now }: Props) { const [query, setQuery] = useState(""); const clock = (now ?? new Date()).getTime(); const groups = new Map(); for (const c of [...conversations].sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())) { const key = bucket(clock - c.updatedAt.getTime()); groups.set(key, [...(groups.get(key) ?? []), c]); } return ( (keywords?.join(" ").toLowerCase().includes(search.toLowerCase()) ? 1 : 0)} > No conversation mentions “{query}”. {[...groups].map(([heading, items]) => ( {items.map((c) => { const early = c.titledFromTurns < TITLE_NEEDS_TURNS; return ( onOpen(c.id)} className="group pr-1"> {c.title} {(Object.keys(DELETE_LABEL) as DeleteScope[]).map((scope) => ( onDelete(c.id, scope)}>{DELETE_LABEL[scope]} ))} ); })} ))} {/* Never enters the list above. The window is a number, to the hour. */} ); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { ConversationHistory, type Conversation, type DeleteScope } from "./ConversationHistory"; /** * Four conversations across two recency groups. "Quick question" was titled * from its opener, so it carries the flag. Search "variance" or "dilapidations" * to see the body filter reach words no title contains. The clock is fixed so * the groups render the same on the server and in the browser. */ const NOW = new Date("2026-09-15T09:00:00Z"); const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3_600_000); const CONVERSATIONS: Conversation[] = [ { id: "lease", title: "Lease break clauses", titledFromTurns: 3, updatedAt: hoursAgo(2), body: "Pull out every clause about early termination and notice periods. The tenant may end the lease at the end of the second year on six months' notice; the deposit is returned less agreed deductions for dilapidations." }, { id: "quick", title: "Quick question", titledFromTurns: 1, updatedAt: hoursAgo(5), body: "Quick question. Does the build cache get invalidated when the lockfile changes, or only when package.json does? It looks like the slow build is re-fetching everything." }, { id: "q3", title: "Q3 forecast variance", titledFromTurns: 4, updatedAt: hoursAgo(3 * 24), body: "The monthly totals on the summary tab should match the sum of the line items on the detail tab. July and August are off by the same 4,200, which points at one line counted twice." }, { id: "pricing", title: "Pricing page rewrite", titledFromTurns: 3, updatedAt: hoursAgo(5 * 24), body: "Draft of the pricing page. Three tiers, annual billing default, the enterprise column with a contact link rather than a price." }, ]; export default function Demo() { const [conversations, setConversations] = useState(CONVERSATIONS); const remove = (id: string, _scope: DeleteScope) => setConversations((cs) => cs.filter((c) => c.id !== id)); return ( {}} onDelete={remove} onStartTemporary={() => {}} temporaryRetentionHours={72} now={NOW} /> ); }