Skip to content
KONIGI Patterns

AI Assistants / Memory and context / Conversation history

‹ 2 of 5 ›

Conversation history

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.

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.

Conversation history anatomy A sidebar of past conversations grouped by recency, with generated titles including one useless title taken from an opening pleasantry, a full-text search field, a pinned item, and a temporary conversation that never enters the list. A filing system nobody agreed to maintain search every message, not just titles Today Lease break clauses Quick question Previous 7 days Q3 forecast variance Pricing page rewrite Temporary not in this list, not used to personalise, kept up to 72 hours delete removes it from here, from storage, or from training. Say which. 1 2 3 4 1 SEARCH THE BODIES People remember a word from the middle, which is the word no title contains. 2 TITLED TOO EARLY Generated from the first message, so an opener is filed under the opener forever. 3 A CONVERSATION WITH NO RECORD Vague promises are worse than none. Give the retention window as a number. 4 THREE KINDS OF DELETE People assume the strongest one. The wording has to distinguish them. This sidebar is a privacy exposure in any shared screen, which is a design problem not a setting.
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.

shadcn
npx shadcn@latest add command dropdown-menu button
npm
cmdk lucide-react
Tokens
--card--card-foreground--muted-foreground--border--accent--accent-foreground--status-warn

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.

Examples

No screenshots yet for this pattern. Browse Products to see other examples.