Skip to content
KONIGI

AI Assistants / Memory and context / Conversation history

2 of 5

Conversation history

The useful exchange was three days ago and the viewer remembers one word from it.

Updated September 12, 2026

Problem

A conversation from Tuesday contained the one good explanation of a thing, and it’s now the fortieth item in a list of auto-generated titles. The viewer remembers a single distinctive word from it and has no way to search on that word.

Solution

The list is a filing system nobody agreed to maintain, so almost everything about it has to work without effort from the viewer.

The title is the first problem. It’s generated from the opening message, which means a conversation that began with “quick question” is filed forever under “quick question”. Generating the title after a few turns rather than from the first message produces a far better list, since by turn three the actual subject exists. Letting the viewer rename is necessary and used by almost nobody, so it can’t be the primary mechanism.

Search is the part that actually rescues the pattern, and it has to run over message bodies rather than titles. Title search fails precisely in the case the viewer is in: they remember a word from the middle of an exchange, which is exactly the word no title contains. Full-text search across conversations turns a dead archive into a resource, and its absence is the single most common complaint about this pattern.

Grouping by recency is close to universal and close to right, because relevance decays fast. The tail is the hard part. A list that grows forever with no pruning, no archive and no sense of which items mattered becomes a place things are lost rather than kept. Pinning is the cheapest fix. Grouping into projects or folders is the heavier one, and it repays itself for people who work in a few recurring contexts.

Two controls belong here that are easy to treat as settings rather than as part of the list. The first is deletion, and it has to say what it actually does: removing a conversation from the sidebar, removing it from the provider’s storage, and removing it from a training corpus are three different operations and people assume the strongest one. The second is the ability to have a conversation that never enters the list at all. Google’s temporary chats are the clearest shipped form of this, and the documented behaviour is specific: they stay out of recent chats and Gemini Apps Activity, aren’t used for personalisation or model training, and are retained for up to 72 hours to handle the response and any feedback. Retention stated to the hour is the precision this control needs. A vague promise about an ephemeral conversation is worse than no promise at all.

Use when

Conversations have value beyond the session, which is nearly always true once an assistant is used for work.

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

Persistent history makes the assistant useful across days and creates a retention surface that has to be explained, controlled, and defended. Auto-generated titles are the only titles that will ever exist at volume, and they’re mediocre by construction. Projects and folders add real organisation and add a filing decision at the moment someone wants to ask a question. A sidebar of recent conversations is a privacy exposure in any shared or screen-shared context—a design problem rather than a settings one.

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 is the other persistence layer and the one people confuse with this. First-run state has to offer a path into this list. Message turn is the unit these entries are made of. Custom instructions persist across every item here. Scoped context is what a project adds on top of a plain list.

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 captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.