Skip to content

AI conversation history

ai-ui specs/ai-ui/conversation-history.kmd

Sidebar of past AI conversations: date-grouped (Today / Yesterday / Last 7 days / Older), search (literal + semantic), pin/archive/delete, auto-titulation, cross-device sync. Standard pattern in ChatGPT/Claude/ Gemini desktop apps.

When this spec applies

Primary triggers

All triggers

Specification body

Spec — AI conversation history

Companion: memory-drawer.kmd (#117) — sibling persistence surface. Cascade-delete contract per identity-data-retention.kmd R5.

Princípios

  1. Date-grouped natural — Today / Yesterday / Last 7 days / Older.
  2. Search dual mode — literal + semantic (via services/ai/memory ou rag).
  3. Actions standard — pin / archive / delete / rename / export.
  4. Auto-titulation — AI-generated title from first messages; user can override.
  5. Multi-tenant scope — sidebar shows only current workspace conversations.

R1 — Anatomia

┌────────────────────────────────────┐
│ [🔍 Search]               [+ New]  │
├────────────────────────────────────┤
│ Pinned                             │
│ ⭐ Stack architecture (...)        │
├────────────────────────────────────┤
│ Today                              │
│ • Kortex deploy review (12:30)    │
│ • Quick math question (10:15)     │
├────────────────────────────────────┤
│ Yesterday                          │
│ • Spec review session (...)       │
├────────────────────────────────────┤
│ Last 7 days                        │
│ • ...                              │
├────────────────────────────────────┤
│ Older                              │
│ • ...                              │
└────────────────────────────────────┘

Slots:

SlotContent
Search barlive filter; semantic toggle
New buttonstart new conversation
GroupsPinned / Today / Yesterday / Last 7 days / Older (collapsible)
Itemtitle + relative timestamp + last assistant snippet (optional)

Item context menu: pin / archive / delete / rename / export markdown.

R2 — Auto-titulation

When conversation has ≥ 2 messages, gateway emits auto-title via small/fast model:

gateway.complete({
  prompt: "Title this conversation in <= 6 words: ${first_user_msg} | ${first_assistant_msg}",
  model: "haiku-fast",  // cheap fast model
})

User can override (long-press → rename). Override persisted; re-generation NOT auto unless user explicitly requests.

R3 — Search modes

ModeTrigger
LiteralDefault; substring match on title + message content
SemanticToggle button; query services/ai/memory embedding similarity

Search debounce: 250ms.

Semantic results ranked by similarity; show score badge optional.

R4 — Pin / archive / delete

  • Pin: max 5 pinned per workspace; sticky top group.
  • Archive: removes from main list; available in "Archived" filter.
  • Delete: soft-delete with 24h grace (per identity-data-retention.kmd R5); during grace, "Undo" toast visible; after grace, hard-delete cascade (messages + memory items referenced + uploads).

R5 — Export

Export as markdown bundle:

# Conversation: Stack architecture review
- Created: 2026-05-14 09:00
- Last activity: 2026-05-14 12:30
- Messages: 24

## Messages

### User · 09:00
...

### Assistant · 09:01
...

Download .md or share via Hub (private/public).

R6 — Sync cross-device

Backed by Koder ID session; conversation list syncs across devices via:

  • Conversation index table (kdb-kv) scoped per (koder_user_id, workspace_id).
  • Real-time updates via WebSocket from services/ai/chat-adapter.
  • Conflict resolution: last-write-wins per item field; new conversation always wins (no merge).

R7 — Surface bindings

SurfaceAPI
FlutterKoderConversationList({onSelect, onArchive, onDelete, onPin, onRename}) em koder_kit/lib/src/ai/conversation_list.dart
Web<koder-conversation-list>
Compose/SwiftUIfuturo
CLI / TUIkoder conv list + koder conv switch <id> + koder conv pin/archive/delete <id>

R8 — Acessibilidade

  • Sidebar: role="navigation" aria-label="Conversation history".
  • Groups: role="group" aria-labelledby="<date-header>".
  • Items: <button> with aria-label "Conversation:, last activity <time>".</li> <li>Search: <code>role="searchbox"</code>.</li> <li>Context menu: keyboard accessible.</li> </ul> <h2 id="r9--i18n">R9 — i18n</h2> <div class="md-table-wrap"><table> <thead> <tr> <th>Key</th> <th>en-US</th> <th>pt-BR</th> </tr> </thead> <tbody> <tr> <td><code>ai.history.group.pinned</code></td> <td>"Pinned"</td> <td>"Fixadas"</td> </tr> <tr> <td><code>ai.history.group.today</code></td> <td>"Today"</td> <td>"Hoje"</td> </tr> <tr> <td><code>ai.history.group.yesterday</code></td> <td>"Yesterday"</td> <td>"Ontem"</td> </tr> <tr> <td><code>ai.history.group.last7days</code></td> <td>"Last 7 days"</td> <td>"Últimos 7 dias"</td> </tr> <tr> <td><code>ai.history.group.older</code></td> <td>"Older"</td> <td>"Mais antigas"</td> </tr> <tr> <td><code>ai.history.action.pin</code></td> <td>"Pin"</td> <td>"Fixar"</td> </tr> <tr> <td><code>ai.history.action.archive</code></td> <td>"Archive"</td> <td>"Arquivar"</td> </tr> <tr> <td><code>ai.history.action.delete</code></td> <td>"Delete"</td> <td>"Excluir"</td> </tr> <tr> <td><code>ai.history.action.rename</code></td> <td>"Rename"</td> <td>"Renomear"</td> </tr> <tr> <td><code>ai.history.action.export</code></td> <td>"Export markdown"</td> <td>"Exportar markdown"</td> </tr> <tr> <td><code>ai.history.action.new</code></td> <td>"New conversation"</td> <td>"Nova conversa"</td> </tr> <tr> <td><code>ai.history.search.placeholder</code></td> <td>"Search conversations..."</td> <td>"Buscar conversas..."</td> </tr> <tr> <td><code>ai.history.search.semantic_toggle</code></td> <td>"Semantic search"</td> <td>"Busca semântica"</td> </tr> <tr> <td><code>ai.history.delete.confirm</code></td> <td>"Delete this conversation?"</td> <td>"Excluir esta conversa?"</td> </tr> <tr> <td><code>ai.history.delete.undo_toast</code></td> <td>"Conversation deleted · Undo"</td> <td>"Conversa excluída · Desfazer"</td> </tr> </tbody> </table></div> <h2 id="r10--multi-tenant">R10 — Multi-tenant</h2> <ul> <li>Conversation index scope per <code>(koder_user_id, workspace_id)</code>.</li> <li>Cross-workspace: each workspace has own sidebar; no cross-workspace search by default.</li> <li>Shared workspace (multi-user): admin-controlled visibility — default own-only.</li> </ul> <h2 id="t-suite">T-suite</h2> <ul> <li><strong>T1</strong> Mount: render groups with conversations populated.</li> <li><strong>T2</strong> Date grouping correct: timestamps map to right group.</li> <li><strong>T3</strong> Pin: max 5 enforced.</li> <li><strong>T4</strong> Archive: removes from main; visible in archive filter.</li> <li><strong>T5</strong> Delete + undo: tap delete → toast 24h undo; tap undo → restored.</li> <li><strong>T6</strong> Hard-delete after grace: advance clock 25h → conversation gone; messages cascade-deleted.</li> <li><strong>T7</strong> Auto-title: after 2 messages → title auto-set.</li> <li><strong>T8</strong> Rename overrides: user renames → persisted; auto-title NOT triggered again.</li> <li><strong>T9</strong> Search literal: type "deploy" → filters list.</li> <li><strong>T10</strong> Search semantic: toggle → query embedding; results ranked.</li> <li><strong>T11</strong> Sync cross-device: edit on device A → visible on device B after WebSocket roundtrip.</li> <li><strong>T12</strong> Multi-tenant: workspace switch → list reloads with new scope.</li> <li><strong>T13</strong> A11y: keyboard nav full; aria announce group transitions.</li> <li><strong>N1</strong> Cross-tenant access: workspace B tries to load conversation A → 404.</li> </ul> <h2 id="cross-link">Cross-link</h2> <ul> <li>Companion: <a href="/en-US/reference/ai-ui-memory-drawer.html" title="> Backend: services/ai/memory/. Patterns referenced: Claude memory > tool (Anthropic), ChatGPT custom memory (OpenAI). Companion impl > tickets em services/ai/ai: #111 (auto-curated memory nudges) +…"><code>memory-drawer.kmd</code></a> (#117 — sibling persistence)</li> <li>Backend: <code>services/ai/chat-adapter/</code>, <code>services/ai/memory/</code></li> <li>Policies: <code>multi-tenant-by-default.kmd</code>, <code>identity-data-retention.kmd</code> R5</li> <li>Refs: ChatGPT sidebar, Claude conversations, Gemini sidebar</li> </ul> </div></section> <section aria-labelledby="specdoc-refs"><h2 id="specdoc-refs">References</h2><ul class="specdoc-refs"><li><code>meta/docs/stack/specs/ai-ui/memory-drawer.kmd</code></li><li><code>meta/docs/stack/specs/ai-ui/chat-message-bubble.kmd</code></li><li><code>meta/docs/stack/policies/identity-data-retention.kmd</code></li><li><code>meta/docs/stack/policies/multi-tenant-by-default.kmd</code></li></ul></section></main><footer class="page-footer"><aside id="koder-feedback" class="koder-feedback" data-state="initial" aria-label="Page feedback"><div class="koder-feedback-prompt"><p>Was this page clear?</p><div class="koder-feedback-actions"><button type="button" class="koder-feedback-btn" data-act="up" aria-label="Yes, this page was clear"><span aria-hidden="true">👍</span> <span>Yes</span></button> <button type="button" class="koder-feedback-btn" data-act="down" aria-label="No, this page could be clearer"><span aria-hidden="true">👎</span> <span>Could be clearer</span></button> <button type="button" class="koder-feedback-dismiss" data-act="dismiss">Dismiss</button></div></div><div class="koder-feedback-form" role="group" aria-label="Optional comment"><label for="koder-feedback-note">Optional comment</label> <textarea id="koder-feedback-note" rows="3" maxlength="1000" placeholder="What was confusing? Anything missing? (optional)"></textarea><div class="koder-feedback-actions"><button type="button" class="koder-feedback-btn-primary" data-act="send">Send feedback</button> <button type="button" class="koder-feedback-dismiss" data-act="dismiss">Dismiss</button></div></div><p class="koder-feedback-thanks">Thanks — feedback noted.</p><script nonce="KVNySDwMBV7an1U0/BOGjg==">(function(){ var w = document.getElementById('koder-feedback'); if (!w) return; var KEY = 'koder.design.feedback.' + window.location.pathname; if (localStorage.getItem(KEY)) { w.dataset.state = 'sent'; return; } var endpoint = 'https://reporter.koder.dev/v1/design-feedback'; function pick(verdict) { w.dataset.verdict = verdict; w.dataset.state = 'open'; var ta = w.querySelector('textarea'); if (ta) ta.focus(); } function send() { var verdict = w.dataset.verdict || 'unknown'; var note = w.querySelector('textarea').value || ''; try { fetch(endpoint, { method: 'POST', keepalive: true, headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ page: window.location.pathname, locale: document.documentElement.lang, verdict: verdict, note: note.slice(0, 1000), ts: Date.now(), }), }).catch(function(){}); } catch (e) {} try { localStorage.setItem(KEY, JSON.stringify({verdict: verdict, ts: Date.now()})); } catch (e) {} w.dataset.state = 'sent'; } function dismiss() { try { localStorage.setItem(KEY, JSON.stringify({verdict: 'dismissed', ts: Date.now()})); } catch (e) {} w.dataset.state = 'sent'; } w.querySelector('[data-act="up"]').addEventListener('click', function(){ pick('up'); }); w.querySelector('[data-act="down"]').addEventListener('click', function(){ pick('down'); }); w.querySelector('[data-act="send"]').addEventListener('click', send); w.querySelector('[data-act="dismiss"]').addEventListener('click', dismiss); })();</script></aside><p>Generated by <code>tools/design-gen</code> from <code>meta/docs/stack/specs/</code>. </p><p><a href="https://flow.koder.dev/Koder/koder">Source on Koder Flow</a> · <a href="/en-US/about/">About Koder Design</a> · <a href="/feed.xml">RSS</a> · <a href="https://brand.koder.dev/">Brand assets</a></p></footer><div id="koder-search-modal" class="koder-search-modal" role="dialog" aria-modal="true" aria-labelledby="koder-search-title" hidden><div class="koder-search-panel"><div class="koder-search-header"><label for="koder-search-input" class="visually-hidden" id="koder-search-title">Search the design system…</label> <input id="koder-search-input" type="search" class="koder-search-input" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="Search the design system…" aria-controls="koder-search-results" aria-describedby="koder-search-status"> <button type="button" class="koder-search-close" data-act="close" aria-label="Close search"><span aria-hidden="true">×</span></button></div><p id="koder-search-status" class="koder-search-status" aria-live="polite">Type to search. Press / from anywhere.</p><ul id="koder-search-results" class="koder-search-results" role="listbox" aria-label="Search the design system…"></ul></div></div><div id="koder-settings-rail" class="koder-settings-rail" role="dialog" aria-modal="true" aria-labelledby="koder-settings-title" hidden data-state="closed"><div class="koder-settings-scrim" aria-hidden="true"></div><aside class="koder-settings-panel"><header class="koder-settings-header"><h2 id="koder-settings-title">Settings</h2><button type="button" class="koder-settings-close" data-act="close" aria-label="Close settings"><span aria-hidden="true">×</span></button></header><div class="koder-settings-body"><section class="koder-settings-group" aria-labelledby="koder-settings-density"><h3 id="koder-settings-density">Density</h3><div class="koder-settings-densities" role="group" data-act="density"><button type="button" data-density="compact" aria-pressed="false">Compact</button> <button type="button" data-density="default" aria-pressed="true">Default</button> <button type="button" data-density="comfortable" aria-pressed="false">Comfortable</button></div><p class="koder-settings-help">Page rhythm. Compact is desktop-only and densifies admin / data-heavy surfaces. Comfortable enlarges hit targets for touch + 10-foot UI. Touch devices always get a comfortable floor.</p></section><section class="koder-settings-group" aria-labelledby="koder-settings-lang"><h3 id="koder-settings-lang">Language</h3><div class="koder-settings-langs" data-act="lang"><a href="/en-US/" data-lang="en-US" aria-current="true">English</a> <a href="/pt-BR/" data-lang="pt-BR">Português (Brasil)</a></div></section><section class="koder-settings-group" aria-labelledby="koder-settings-reporting-title"><h3 id="koder-settings-reporting-title">Error reporting</h3><label class="koder-settings-toggle"><input type="checkbox" id="koder-settings-reporting" role="switch" aria-describedby="koder-settings-reporting-desc"> <span class="koder-settings-toggle-label">Send anonymous error reports</span></label><p id="koder-settings-reporting-desc" class="koder-settings-help">Send a small, anonymous report to reporter.koder.dev when something on this site breaks. Off by default; you can change it any time.<br><a href="/en-US/patterns/errors-reporting.html">Learn how reports are scoped →</a></p></section></div></aside></div><!-- Ticket #040 follow-up (2026-05-21): the ambient pause toggle moved from body-level FAB into TopBar (AmbientPauseTrigger) — script below stays the same since it looks up #koder-ambient-pause by id. --><script nonce="KVNySDwMBV7an1U0/BOGjg==">(function(){ var sb = document.getElementById('koder-sidebar'); var btn = document.getElementById('koder-sidebar-toggle'); var scrim = document.getElementById('koder-sidebar-scrim'); if (!sb || !btn) return; function open() { sb.dataset.state = 'open'; btn.setAttribute('aria-expanded', 'true'); document.documentElement.classList.add('koder-sidebar-open'); } function close() { sb.dataset.state = 'closed'; btn.setAttribute('aria-expanded', 'false'); document.documentElement.classList.remove('koder-sidebar-open'); } btn.addEventListener('click', function(){ if (sb.dataset.state === 'open') close(); else open(); }); if (scrim) scrim.addEventListener('click', close); document.addEventListener('keydown', function(e){ if (e.key === 'Escape' && sb.dataset.state === 'open') close(); }); sb.addEventListener('click', function(e){ var t = e.target; while (t && t !== sb) { if (t.tagName === 'A') { close(); return; } t = t.parentElement; } }); // Sidebar scroll persistence — each navigation is a full page load, // so the sidebar's scrollTop is lost by default. Save on scroll // (throttled via rAF) and restore on next page load so the user keeps // their place in the rail. sessionStorage scopes to the tab so // separate tabs don't fight over state. var STORE_KEY = 'koder-sidebar-scroll'; try { var saved = sessionStorage.getItem(STORE_KEY); if (saved !== null) { var n = parseInt(saved, 10); if (!isNaN(n) && n > 0) sb.scrollTop = n; } } catch (e) {} var ticking = false; sb.addEventListener('scroll', function(){ if (ticking) return; ticking = true; requestAnimationFrame(function(){ try { sessionStorage.setItem(STORE_KEY, String(sb.scrollTop)); } catch (e) {} ticking = false; }); }, { passive: true }); // Persist the latest position right before navigation away — covers // the case where the user clicks a link before the rAF tick fires. window.addEventListener('beforeunload', function(){ try { sessionStorage.setItem(STORE_KEY, String(sb.scrollTop)); } catch (e) {} }); })();</script><script nonce="KVNySDwMBV7an1U0/BOGjg==">(function(){ // Ticket #040 + follow-up (2026-05-21): FAB is global — visible on // every page so the user can flip the ambient state from anywhere // (next page with ambient honors the choice via localStorage on // load). Earlier short-circuit on "no .ambient-anim on this page" // left the click handler unbound; the FAB stayed visible because // .koder-ambient-pause-fab uses display:grid which overrides // [hidden]. Wire always. var html = document.documentElement; var fab = document.getElementById('koder-ambient-pause'); if (fab) fab.hidden = false; function setPaused(v){ if (v) html.dataset.ambientPaused = 'true'; else delete html.dataset.ambientPaused; try { localStorage.setItem('kds.ambient-paused', v ? 'true' : 'false'); } catch(e){} } function toggle(){ setPaused(html.dataset.ambientPaused !== 'true'); } if (fab) fab.addEventListener('click', toggle); document.querySelectorAll('.kds-hero-ambient-pause').forEach(function(b){ b.addEventListener('click', toggle); }); })();</script><script nonce="KVNySDwMBV7an1U0/BOGjg==">(function(){ var btn = document.getElementById('koder-theme-toggle'); if (!btn) return; var html = document.documentElement; btn.addEventListener('click', function(){ var cur = html.dataset.theme === 'light' ? 'light' : 'dark'; var next = cur === 'light' ? 'dark' : 'light'; html.dataset.theme = next; try { localStorage.setItem('koder.theme', next); } catch(e){} }); })();</script><script nonce="KVNySDwMBV7an1U0/BOGjg==">(function(){ var wrap = document.getElementById('koder-version-selector'); var sel = document.getElementById('koder-version-select'); if (!wrap || !sel) return; function inferCurrent(){ var m = location.pathname.match(/^\/(v\d+)\//); return m ? m[1] : ''; } function swapURL(targetUrl){ // targetUrl is '/' (Latest) or '/v<N>/' (snapshot). Preserve the // locale (first non-empty path segment that looks like xx-XX) and // every segment after it. var parts = location.pathname.split('/').filter(Boolean); var rest = parts.slice(); if (rest.length && /^v\d+$/.test(rest[0])) rest.shift(); var joined = rest.length ? rest.join('/') + (location.pathname.endsWith('/') ? '/' : '') : ''; var base = targetUrl.replace(/\/$/, ''); var next = base + '/' + joined; if (!next.startsWith('/')) next = '/' + next; location.assign(next); } fetch('/versions.json', { credentials: 'omit' }).then(function(r){ if (!r.ok) throw new Error('versions.json http ' + r.status); return r.json(); }).then(function(data){ if (!data || !Array.isArray(data.versions) || data.versions.length === 0) return; sel.innerHTML = ''; var current = inferCurrent(); data.versions.forEach(function(v){ var opt = document.createElement('option'); opt.value = v.url || '/'; opt.dataset.slug = v.slug || ''; opt.textContent = v.label + (v.latest ? '' : (v.date ? ' (' + v.date + ')' : '')); if ((v.slug || '') === current) opt.selected = true; sel.appendChild(opt); }); sel.disabled = false; wrap.removeAttribute('data-empty'); sel.addEventListener('change', function(){ var opt = sel.options[sel.selectedIndex]; try { localStorage.setItem('koder.kds.version', opt.dataset.slug || ''); } catch(e){} swapURL(sel.value); }); }).catch(function(){ // /versions.json missing or malformed — keep the placeholder // disabled. Cross-version navigation simply isn't offered. }); })();</script></body></html>