People need to find and return to earlier conversations.
Updated September 12, 2026
Problem
People need to find earlier conversations, but a long list of generated titles may not contain the words they remember.
Solution
List recent conversations and let people search the full text of their messages. Generate titles that describe the subject, and allow renaming when a title is unclear.
Offer pinning or folders for conversations people return to. Provide archive and delete controls, and explain what deletion removes and what the service retains.
If temporary conversations are available, say whether they appear in history, how long they are kept, and how their data is used. Keep these choices easy to find from the conversation list.
Use when
People need to return to conversations after the current session.
Don’t use when
The assistant is scoped to an object that already has its own history, like a document or a ticket. There the conversation belongs with the object, and a parallel global list splits the record in two.
Trade-offs
Saved history makes past work useful but can expose private topics during screen sharing. Generated titles need little effort from users and can be vague. Folders improve organization but add filing work.
Checklist
When is the title generated, and from how much of the conversation?
Does search cover message bodies or only titles?
What does the list look like at a thousand conversations?
Is there any way to mark something as worth keeping?
Does delete remove it from the sidebar, from storage, or from training data, and does the wording distinguish those?
Can a conversation be started that never enters the list?
If so, exactly how long is it retained and for what?
Is the list visible during screen sharing, and can it be hidden quickly?
Can a conversation be exported or shared without the rest of the history?
Does the list say anything about which conversations the assistant draws on later?
Compare
ChatGPT groups by recency with generated titles, and supports renaming, archiving and project grouping. The sidebar gets treated as a working filing system. Gemini pairs the list with temporary chats and a published retention window, which makes the decision to persist an explicit one taken per conversation. Claude leans on projects, so the primary unit is a body of work with its own knowledge rather than a flat reverse-chronological list. Slack has no separate list at all, because an AI exchange lands in a channel or DM and inherits the search, retention and permission model the workspace already has.
Related
Memory chip shows information saved separately from the conversation record. Custom instructions provide standing guidance across conversations.
First-run state links to earlier work. Message turn structures each conversation. Scoped context defines the material available within a project.
Wireframe — the pattern's anatomy, not any one product's version of it
Implementation
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.
ConversationHistory.tsxGroups by recency, searches bodies rather than titles, flags a title taken from the opener, and makes delete say which of three things it does.
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<DeleteScope, string> = { 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<string, Conversation[]>(); 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 ( <Command className="w-[272px] rounded-lg border bg-card text-card-foreground" // Plain substring over the body. cmdk's fuzzy scorer is for commands, // and it would rank a title match above the word the viewer remembers. filter={(_value, search, keywords) => (keywords?.join(" ").toLowerCase().includes(search.toLowerCase()) ? 1 : 0)} > <CommandInput placeholder="search every message, not just titles" value={query} onValueChange={setQuery} /> <CommandList className="max-h-none"> <CommandEmpty>No conversation mentions “{query}”.</CommandEmpty> {[...groups].map(([heading, items]) => ( <CommandGroup key={heading} heading={heading}> {items.map((c) => { const early = c.titledFromTurns < TITLE_NEEDS_TURNS; return ( <CommandItem key={c.id} value={c.id} keywords={[c.title, c.body]} onSelect={() => onOpen(c.id)} className="group pr-1"> <span className={cn("flex-1 truncate", early && "text-status-warn")} title={early ? `Titled from the opening message. Rename it.` : undefined} > {c.title} </span> <DropdownMenu modal={false}> <DropdownMenuTrigger asChild> <Button variant="ghost" size="sm" className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100" aria-label={`Delete ${c.title}`} onClick={(e) => e.stopPropagation()}> <Trash2 /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> {(Object.keys(DELETE_LABEL) as DeleteScope[]).map((scope) => ( <DropdownMenuItem key={scope} onSelect={() => onDelete(c.id, scope)}>{DELETE_LABEL[scope]}</DropdownMenuItem> ))} </DropdownMenuContent> </DropdownMenu> </CommandItem> ); })} </CommandGroup> ))} </CommandList> {/* Never enters the list above. The window is a number, to the hour. */} <button type="button" onClick={onStartTemporary} className="m-2 rounded-md border border-dashed px-3 py-2 text-left hover:bg-accent hover:text-accent-foreground"> <span className="block text-sm">Temporary</span> <span className="mt-0.5 block text-[11px] leading-relaxed text-muted-foreground"> not in this list, not used to personalise, kept up to {temporaryRetentionHours} hours </span> </button> </Command> );}
demo.tsxHow it is called: four conversations, one titled too early, a 72-hour temporary window, and a fixed clock.
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 ( <ConversationHistory conversations={conversations} onOpen={() => {}} onDelete={remove} onStartTemporary={() => {}} temporaryRetentionHours={72} now={NOW} /> );}
What it renders. Identical markup in both panes, with only the token values changing.