ADR-027: Every List Names a Chat by Its Context
Status: Accepted
Date: 2026-10-30
Context
Editors found the lists of conversations unusable. On the demo, 49 conversations had 25 distinct titles, and 20 were called "Diese Seite prüfen":
- Every guided tour had the same title.
createConversation()starts a tour about a page with the fixed messageguided.checkPage(ADR-023: The chat guides a process), and a conversation takes its first user message as its title. - Only one list had context. The dashboard widget "Recent chats" showed the tour's skill, its page, its point and the time, but in a narrow column and for five chats. The chat module's list, the panel's tabs, its "more" list, its sidebar and its minimized bar showed the title and the status, because the list endpoint sent nothing else.
- Empty chats were listed as "New chat" (five on the demo).
- The status colours said the wrong thing. A ready conversation was green, as if it had succeeded; a conversation waiting for the editor's answer was blue, like one that is still running.
Decision
One display model, built on the server
ConversationListPresenter turns a conversation into what a list shows
(ConversationListItem). The list endpoint and the dashboard widget use it;
the chat module and the panel render its fields with one shared row
(conversation-list.js). Per conversation: the title, the full title for
the tooltip, the context, the page (only one the user may see in the page
tree, titled in the conversation's language where it has a translation the
user may edit), the skill's title, the point of a tour, when it was created,
when its last message was written, and the status with its tone and label.
When the last message was written is kept on the conversation
(last_message_at): the repository writes it with every save of the
messages, in the same transaction, from the last message's createdAt. A
list therefore never reads a transcript. A row saved before the column
existed is read from its last message row until the upgrade wizard
nrMcpAgent_lastMessageTime has filled it; a wizard rather than a lazy
backfill, because a list request should not write, and a conversation nobody
opens again would otherwise never be filled. The server sends the time as
ISO 8601; the client words it ("Heute, 10:42") in the reader's time zone and
anew at every render, so a list left open past midnight says "Gestern"; a
time slightly ahead of the reader's clock counts as now. The queries of the
lists already order by it (`CASE WHEN last_`, portable across SQLite, MySQL/MariaDB and
PostgreSQL); the wizard walks the table once, batch after batch from the last
uid of the one before.
The title is decided at display time
Stored rows keep their titles; no migration rewrites them. Where a title
comes from is kept in title_source: manual once the editor renamed the
conversation (renameConversation()), tour for the title a new guided
tour is stored with, empty otherwise and for every row from before the column.
Neither is part of the full-row write (Conversation::toRow()), as the
instructions are not: the worker's final save carries the title it loaded,
and a rename during a running turn would be lost. add() writes them with
a new row; a save writes the title it carries only `WHERE title_` column
(ConversationTitle::fit()), never inside a character made of several
code points; the rename field takes no more (maxlength).
- A title the editor gave (
manual) is never replaced. - A guided tour whose title is automatic is shown as
"<skill> · <page>" with the page's current title (
ConversationTitle). Automatic means: markedtour, or, for a row without a mark, exactly one of the titles the extension gives — empty, the tour's start message in the user's language, a shipped one or an installed backend language, the skill's title, or "<skill> · <page>" with the page's current title. Never a prefix: the rename field starts with the derived title, and "<skill> · <page> (Kunde X)" is the editor's. The page is left out when the user may not see it or it is deleted. - Any other title is shown with its whitespace collapsed and, when it is longer than 60 characters, shortened to its first sentence (from 20 characters on) or cut at a word boundary with "…". The full text is the tooltip. Lengths are counted in grapheme clusters, so a cut never splits an emoji or an accented letter. This covers a chat's first message, "Alt-Text: <file>" and a title the editor gave.
- Two alike — the same tour twice on the same page — get the time they
were started after the title ("SEO optimieren · Über uns · 20. Okt.,
08:00"), so a tab tells them apart without its tooltip. Started in the
same minute, they get the second ("11:27:01", "11:27:44"); started in the
same second, a number in the order they were started, then by uid
("11:27:44 (1)", "11:27:44 (2)"), the same on every load. The endpoint
sends that suffix as
titleSuffixas well, and every list that has to shorten a title shortens the part before it: the time is what tells the rows apart.
The context names what the title does not
A tour shown as "<skill> · <page>" gets Guided tour and its point; a tour the editor renamed gets the page, the skill and Guided tour. A plain skill that needs a page (ADR-025: The Skill Says Whether It Needs a Page) gets its page and the skill. Every other conversation names no page: it stores the page the browser showed with its last message, which says where the editor was, not what the chat is about. That rule comes from the widget, unchanged.
Lists
- Every list is ordered by the last message, newest first, pinned
conversations first where pinning exists.
tstampalone moved with a rename or a pin. - A conversation without a message is not listed, except the open one: the endpoint still sends it, because the chat opens a conversation it has just created from that list. Since a user can neither see nor archive it, a start that runs nothing takes up the user's empty conversation of the same start (same page, language and skill, no instructions, title or pin of its own) instead of creating another, and the overview counts only conversations with a message.
- The panel's tabs show the status icon and the title; the context, the
status in words and the time are in the tooltip, and the context and the
time are each tab's description (
aria-describedby, pointing to ahiddenelement, so browse mode does not read it twice) for keyboard, touch and screen readers. The "more" list searches the title, the full title and the context. - The dashboard widget is full width and lists ten conversations. Ten rows
with a context line are taller than
large, the tallest height a widget can have, so the list scrolls in a region of its own (a<section>named after the widget, so a region, focusable so it scrolls by keyboard); the hint above it and "New chat" and "Show all chats" below it stay in place. Before, the dashboard's content area scrolled hint and list together and cut the last row off against "New chat". The dashboard keeps the size of a widget it has placed, so the wizardnrMcpAgent_recentChatsWidgetSizeremoves the widget's stored position where it is smaller, on dashboards arranged before 0.26. The dashboard then places it full width in the first rows free across the whole width, so it usually moves below the widgets that shared its rows; the other widgets keep their positions. In a narrow widget the status and the time move under the title; the arrow stays beside it.
Status
Ready is neutral, waiting for the editor's answer or approval is a warning
with an icon, running is information, failed is an error. Every list says the
status in words; the colour only repeats it. Each tone sets a background and a
text colour that do not depend on the row around it, so a badge reads the same
in the selected row; neutral is core's default badge pair, not the variant
text colour, which TYPO3 14.3 mixes from currentColor.
Consequences
- Existing tours read "<skill> · <page>" in every list without a migration; they follow a renamed page.
- A page the user may not see is no longer a reason for the widget to leave a tour out: the chat module listed it anyway. It is listed without the page.
- The table gains
title_sourceandlast_message_at; run the database analyzer, then the wizardnrMcpAgent_lastMessageTime. - A tour renamed before
title_sourceexisted to exactly a title the extension gives is treated as automatic. Every rename since is marked. - The pages of a list are read with one query per language of the
conversations and one for their translations, with the same checks as
BackendUtility::readPageAccess()— the "show page" clause, the web mounts, the workspace overlay — but without the record path no list shows. Translations are read asBackendUtility::getRecordLocalization()reads them: live rows and new ones of the user's workspace, no version rows, nothing of another workspace, then overlaid. The mount check stays per page; core caches the rootlines it reads. - The list endpoint reads the context columns of
tx_nrmcpagent_conversation(view_context,skill_uid,guided_state,alt_text_hit,title_source,last_message_at) and no message rows, except for rows the wizard has not filled yet.