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>
5 files changed,
+130
−45
changelog/2026-07-23-search-finds-conversations.md+8 −0web/pages/index.html+7 −6web/static/searchui.js+76 −10web/static/shell.js+34 −29web/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; } |