eleven / messenger Public

web: consolidated search โ€” Conversations in the dropdown (#1292)

* web: consolidated search โ€” Conversations section in the dropdown

Searching for a person showed only message hits while the dropdown covered
the filtered chat list underneath, so a matching chat couldn't be clicked.
The dropdown now opens with a Conversations section (name-matching threads,
avatar + name, straight to the thread) above the message hits โ€” the same
consolidation the native apps' search already has. Thread-row avatar
painting is extracted (threadRowAvatarEl) and shared through the wireSearch
seam so both surfaces wear the same faces; the keyboard cursor and
aria-activedescendant walk both sections, skipping the presentational
section headers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: fix ReferenceError โ€” the avatar extraction dropped appName the loop still reads

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 624119ef2e17de963d38e11ffda467d7a3e3c007 parent 6951149
5 files changed, +130 −45
  • changelog/2026-07-23-search-finds-conversations.md +8 −0
  • web/pages/index.html +7 −6
  • web/static/searchui.js +76 −10
  • web/static/shell.js +34 −29
  • web/static/style.css +5 −0
diff --git a/changelog/2026-07-23-search-finds-conversations.md b/changelog/2026-07-23-search-finds-conversations.md
new file mode 100644
index 0000000..0bf6f83
--- /dev/null
+++ b/changelog/2026-07-23-search-finds-conversations.md
@@ -0,0 +1,8 @@
+๐Ÿค– Searching on the web now finds your chats, not just your messages. Type a
+person's or group's name and a Conversations section appears at the top of the
+search dropdown โ€” tap a row to jump straight into that chat, with matching
+messages listed below. Before, searching for a person showed only their
+messages, and the dropdown covered the very chat list you were trying to
+reach. The keyboard works through the whole thing: the top result is
+highlighted as you type, arrows move between conversations and messages, and
+Enter opens whichever is lit.
diff --git a/web/pages/index.html b/web/pages/index.html
index 7b807e8..bdb0d3d 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -195,14 +195,15 @@
</div>
</div>
</div>
- <!-- Message search results (#586, searchui.js): filled while the search
- box holds a query; each row jumps to the message in its thread. A
- dropdown under the search bar, floating over the thread list like the
- filter menu beside it (the never-float rule covers toasts/nudges, not
- a user-invoked, transient surface); the zero-height anchor keeps it
+ <!-- Consolidated search results (#586, searchui.js): filled while the
+ search box holds a query โ€” matching conversations first (a row opens
+ the chat), then message hits (a row jumps to the message). A dropdown
+ under the search bar, floating over the thread list like the filter
+ menu beside it (the never-float rule covers toasts/nudges, not a
+ user-invoked, transient surface); the zero-height anchor keeps it
out of the sidebar column's flow. -->
<div class="msg-results-anchor">
- <div class="msg-results hidden" id="msg-results" role="region" aria-label="Message search results"></div>
+ <div class="msg-results hidden" id="msg-results" role="region" aria-label="Search results"></div>
</div>
<!-- Inline passkey-backup nudge (backup.js fills it in). It occupies layout
space in the sidebar โ€” never a floating toast. -->
diff --git a/web/static/searchui.js b/web/static/searchui.js
index 00ccdc2..f8d321e 100644
--- a/web/static/searchui.js
+++ b/web/static/searchui.js
@@ -10,13 +10,15 @@ import { scrollToMessage } from "/static/apps/chat.js";
import { session, threads, threadKeys, myUserId, threadName } from "/static/shellstate.js";
import { relTime } from "/static/format.js";
-// The three shell behaviours search rides: navigation into a result's thread,
-// the chat-thread predicate, and scroll-back paging for the deep walk.
+// The shell behaviours search rides: navigation into a result's thread, the
+// chat-thread predicate, scroll-back paging for the deep walk, and the chat
+// list's avatar painter (so Conversations rows wear the same faces).
let openThreadFn = () => {};
let chatAppFor = () => null;
let chatAppLoadOlder = async () => {};
+let threadAvatarFn = () => document.createElement("span");
export function wireSearch(deps) {
- ({ openThread: openThreadFn, chatAppFor, chatAppLoadOlder } = deps);
+ ({ openThread: openThreadFn, chatAppFor, chatAppLoadOlder, threadAvatar: threadAvatarFn } = deps);
}
// ---- message search (#586) -------------------------------------------------
@@ -103,11 +105,17 @@ document.addEventListener("click", (e) => {
clearSearch(false);
});
+// Every navigable row in the dropdown โ€” Conversations and Messages alike โ€”
+// in visual order (section headers are presentational, never cursor stops).
+function optionRows(s) {
+ return [...s.list.querySelectorAll('[role="option"]')];
+}
+
// The keyboard cursor: focus never leaves the search box โ€” the highlighted
// row (top hit by default, so plain Enter opens it with no arrowing) is a
// virtual cursor the box's aria-activedescendant tracks, combobox-style.
function setCursor(s, row) {
- for (const r of s.list.children) {
+ for (const r of optionRows(s)) {
r.classList.toggle("active", r === row);
r.setAttribute("aria-selected", String(r === row));
}
@@ -136,7 +144,7 @@ document.addEventListener("keydown", (e) => {
}
const s = resultsScaffold();
if (!s) return;
- const rows = [...s.list.children];
+ const rows = optionRows(s);
const cur = rows.findIndex((r) => r.classList.contains("active"));
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
@@ -154,6 +162,9 @@ document.addEventListener("keydown", (e) => {
async function runMessageSearch(query) {
const mySeq = ++searchSeq;
+ // Conversations (and any already-indexed hits) paint before the first
+ // decrypt โ€” a name search answers instantly, whatever the index is up to.
+ renderSearchResults(query);
// Stream: render after each thread's ensure, so a big space's first search
// shows hits as they decrypt instead of a long blank box.
for (const t of searchableThreads()) {
@@ -178,7 +189,7 @@ function resultsScaffold() {
const list = document.createElement("div");
list.id = "msg-results-list"; // the search box's aria-controls target
list.setAttribute("role", "listbox");
- list.setAttribute("aria-label", "Message search results");
+ list.setAttribute("aria-label", "Search results");
const footer = document.createElement("div");
box.append(status, list, footer);
}
@@ -186,18 +197,72 @@ function resultsScaffold() {
return { box, status, list, footer };
}
+// Conversations whose name matches the query โ€” the same predicate the chat
+// list filters by, so the dropdown finds exactly what the (covered) list
+// would show, closed chats included. Most recent first, capped so message
+// hits keep the room.
+function conversationHits(query) {
+ const q = query.toLowerCase();
+ return threads
+ .filter((t) => (threadName[t.id] || "").toLowerCase().includes(q))
+ .sort((a, b) => (b.last_at || b.created_at || 0) - (a.last_at || a.created_at || 0))
+ .slice(0, 8);
+}
+
+// A muted section header inside the listbox โ€” visual structure only, so the
+// keyboard cursor and aria-activedescendant skip straight over it.
+function sectionHeader(text) {
+ const h = document.createElement("div");
+ h.className = "msg-results-count";
+ h.setAttribute("role", "presentation");
+ h.textContent = text;
+ return h;
+}
+
function renderSearchResults(query) {
const s = resultsScaffold();
if (!s) return;
+ const convs = conversationHits(query);
const hits = searchIdx.query(query);
s.box.classList.remove("hidden");
- let statusText = hits.length
+ // One live region, one voice: the summary covers both sections (and the
+ // deep walk's narration below).
+ const msgPart = hits.length
? `${hits.length}${hits.length === 50 ? "+" : ""} message${hits.length === 1 ? "" : "s"}`
: "No messages found";
- // The deep walk's narration shares the single live region โ€” one voice.
+ let statusText = convs.length
+ ? `${convs.length} conversation${convs.length === 1 ? "" : "s"} ยท ${msgPart}`
+ : hits.length
+ ? msgPart
+ : "No matches found";
if (searchBackfill) statusText += ` ยท ${searchBackfill.progress}`;
if (s.status.textContent !== statusText) s.status.textContent = statusText;
s.list.textContent = "";
+ // ---- Conversations: matching chats, straight to the thread ---------------
+ // The consolidated dropdown covers the chat list it floats over โ€” so the
+ // chats a name-search finds must be clickable HERE, not under it.
+ if (convs.length) {
+ s.list.appendChild(sectionHeader("Conversations"));
+ for (const t of convs) {
+ const row = document.createElement("button");
+ row.type = "button";
+ row.className = "msg-result conv-result";
+ row.appendChild(threadAvatarFn(t));
+ const name = document.createElement("span");
+ name.className = "conv-result-name";
+ name.textContent = threadName[t.id] || "Chat";
+ row.appendChild(name);
+ row.dataset.key = `t:${t.id}`;
+ row.id = `conv-hit-${t.id}`; // aria-activedescendant target
+ row.setAttribute("role", "option");
+ row.onclick = () => {
+ clearSearch(false);
+ openThreadFn(t.id);
+ };
+ s.list.appendChild(row);
+ }
+ if (hits.length) s.list.appendChild(sectionHeader("Messages"));
+ }
for (const h of hits) {
const row = document.createElement("button");
row.type = "button";
@@ -229,8 +294,9 @@ function renderSearchResults(query) {
}
// The cursor lands on the top hit unless the member had arrowed to a hit
// that's still in the (streamed re-)rendered list.
- const kept = [...s.list.children].find((r) => r.dataset.key === searchCursorKey);
- setCursor(s, kept || s.list.firstChild);
+ const rows = optionRows(s);
+ const kept = rows.find((r) => r.dataset.key === searchCursorKey);
+ setCursor(s, kept || rows[0] || null);
const filter = document.getElementById("chat-filter");
if (filter) filter.setAttribute("aria-expanded", "true");
renderSearchFooter(query);
diff --git a/web/static/shell.js b/web/static/shell.js
index 7180917..d88d5e0 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -210,7 +210,7 @@ Object.assign(verbs, {
});
onThemeApplied(pushTheme); // live-retint mounted sandbox frames on theme change
-wireSearch({ openThread, chatAppFor, chatAppLoadOlder }); // navigation + thread predicate + scroll-back paging
+wireSearch({ openThread, chatAppFor, chatAppLoadOlder, threadAvatar: threadRowAvatarEl }); // navigation + thread predicate + scroll-back paging + row avatars
// Ephemeral transport (ctx.emitEphemeral): same encryption as emit, but the
// envelope's `ephemeral` flag asks the server to relay-and-forget โ€” no store,
@@ -2979,6 +2979,37 @@ async function openThreadById(id) {
if (threads.some((t) => t.id === id)) openThread(id);
}
+// A thread's avatar node, as the chat list paints it: DM/self rows show the
+// person's profile photo, groups their thread photo, app-threads the app's
+// glyph (or its shipped image โ€” Eleven wears the app icon). Shared with the
+// search dropdown's Conversations rows so the two surfaces can't drift.
+function threadRowAvatarEl(t) {
+ const appName = threadApp[t.id];
+ if (appName) {
+ const app = appByName(appName);
+ const av = document.createElement("span");
+ av.className = "avatar app-avatar";
+ // An app can ship an avatar image (Eleven wears the app icon); the glyph
+ // stays the fallback. In-code URLs only โ€” never user content.
+ if (app && app.avatar) {
+ av.style.backgroundImage = `url(${app.avatar})`;
+ av.style.backgroundSize = "cover";
+ av.setAttribute("role", "img");
+ av.setAttribute("aria-label", (app && app.title) || appName);
+ } else {
+ appGlyphInto(av, app);
+ }
+ return av;
+ }
+ let loader = null;
+ if (t.kind === "group") loader = () => threadAvatarFor(t.id);
+ else {
+ const u = t.kind === "self" ? myUserId : (t.members || []).find((id) => id !== myUserId);
+ loader = () => avatarFor(u);
+ }
+ return avatarEl(threadName[t.id] || "?", loader);
+}
+
function renderThreadList() {
const list = document.getElementById("thread-list");
if (!list) return;
@@ -3112,34 +3143,8 @@ function renderThreadList() {
const row = document.createElement("button");
row.className = "thread-row" + (t.id === activeId ? " active" : "");
row.dataset.id = t.id;
-
- // DM/self rows show the person's profile photo; groups use initials (group
- // photos are thread-scoped โ€” phase 2). An app-thread shows the app's glyph.
- const appName = threadApp[t.id];
- if (appName) {
- const app = appByName(appName);
- const av = document.createElement("span");
- av.className = "avatar app-avatar";
- // An app can ship an avatar image (Eleven wears the app icon); the glyph
- // stays the fallback. In-code URLs only โ€” never user content.
- if (app && app.avatar) {
- av.style.backgroundImage = `url(${app.avatar})`;
- av.style.backgroundSize = "cover";
- av.setAttribute("role", "img");
- av.setAttribute("aria-label", (app && app.title) || appName);
- } else {
- appGlyphInto(av, app);
- }
- row.appendChild(av);
- } else {
- let loader = null;
- if (t.kind === "group") loader = () => threadAvatarFor(t.id);
- else {
- const u = t.kind === "self" ? myUserId : (t.members || []).find((id) => id !== myUserId);
- loader = () => avatarFor(u);
- }
- row.appendChild(avatarEl(threadName[t.id] || "?", loader));
- }
+ row.appendChild(threadRowAvatarEl(t));
+ const appName = threadApp[t.id]; // the badge + blurb below still key off it
const body = document.createElement("span");
body.className = "thread-row-body";
diff --git a/web/static/style.css b/web/static/style.css
index b5f3aeb..aef59d4 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -2285,6 +2285,11 @@ input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-off
.msg-result:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
/* the keyboard-highlighted row โ€” the top hit by default, so Enter just opens it */
.msg-result.active { background: color-mix(in srgb, var(--accent) 16%, transparent); }
+/* Conversations section: matching chats as avatar + name rows. The dropdown
+ floats over the chat list, so the chats a name-search finds must be
+ clickable in here โ€” same consolidation as the native apps' search. */
+.msg-result.conv-result { display: flex; align-items: center; gap: 10px; }
+.conv-result-name { font-size: 14px; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.msg-result-top { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; }
.msg-result-who { font-weight: 600; }
.msg-result-where { color: var(--muted); flex: none; max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }