web: message search results get a sticky highlight (#892) (#896)
Clicking a search result already scrolled to and briefly flashed the message (.msg.flash, 1.2s, shared with the reply-quote jump). Add a separate persistent .msg.search-active highlight that stays on the jumped-to message until another result is picked or the search box is cleared, and mirror the active state on the .msg-result row.
Co-authored-by: Claude <noreply@anthropic.com>
3 files changed,
+31
−1
changelog/2026-07-14-search-result-highlight.md+4 −0web/static/shell.js+22 −1web/static/style.css+5 −0
diff --git a/changelog/2026-07-14-search-result-highlight.md b/changelog/2026-07-14-search-result-highlight.md| new file mode 100644 |
| index 0000000..02c6757 |
| --- /dev/null |
| +++ b/changelog/2026-07-14-search-result-highlight.md |
| @@ -0,0 +1,4 @@ |
| +🤖 Tapping a message search result now highlights that message in the chat — |
| +not just a brief flash, but a highlight that stays put until you pick a |
| +different result or clear your search, so it's easy to tell which message you |
| +landed on. |
diff --git a/web/static/shell.js b/web/static/shell.js| index ea100e2..df79caa 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -5337,6 +5337,17 @@ function searchableThreads() { |
| let searchSeq = 0; |
| let searchTimer = null; |
| let searchBackfill = null; // { controller } while the deep walk runs |
| +let searchActiveTarget = null; // .msg element carrying the sticky .search-active highlight |
| +let searchActiveHitKey = null; // "tid:sid" of the currently jumped-to result, for row styling |
| + |
| +// Sticky highlight is separate from .flash (which always fades on its own): |
| +// it stays on the jumped-to message until another result is picked or the |
| +// search ends, at which point this clears it. |
| +function clearSearchHighlight() { |
| + if (searchActiveTarget) searchActiveTarget.classList.remove("search-active"); |
| + searchActiveTarget = null; |
| + searchActiveHitKey = null; |
| +} |
| function scheduleMessageSearch(query) { |
| clearTimeout(searchTimer); |
| @@ -5349,6 +5360,7 @@ function scheduleMessageSearch(query) { |
| searchSeq++; |
| box.classList.add("hidden"); |
| box.textContent = ""; |
| + clearSearchHighlight(); |
| return; |
| } |
| searchTimer = setTimeout(() => runMessageSearch(query), 250); |
| @@ -5414,7 +5426,13 @@ function renderSearchResults(query) { |
| snip.className = "msg-result-snippet"; |
| snip.textContent = snippetFor(h.text, query); |
| row.append(top, snip); |
| - row.onclick = () => jumpToMessage(h.tid, h.id); |
| + const key = `${h.tid}:${h.id}`; |
| + if (key === searchActiveHitKey) row.classList.add("active"); |
| + row.onclick = () => { |
| + searchActiveHitKey = key; |
| + for (const r of s.list.children) r.classList.toggle("active", r === row); |
| + jumpToMessage(h.tid, h.id); |
| + }; |
| s.list.appendChild(row); |
| } |
| renderSearchFooter(query); |
| @@ -5514,6 +5532,9 @@ async function jumpToMessage(tid, sid) { |
| // an offscreen control). The bubble itself takes it; a deleted/never-found |
| // target falls back to the composer. |
| const target = feed.querySelector(`.msg[data-id="${sid}"]`); |
| + if (searchActiveTarget && searchActiveTarget !== target) searchActiveTarget.classList.remove("search-active"); |
| + searchActiveTarget = target; |
| + if (target) target.classList.add("search-active"); |
| const focusEl = target || document.querySelector(".chatapp-root .composer .composer-input"); |
| if (focusEl) { |
| if (target) target.tabIndex = -1; |
diff --git a/web/static/style.css b/web/static/style.css| index 0e3bc3f..07f46fc 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -1231,6 +1231,9 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| 0%, 100% { background: transparent; } |
| 25% { background: color-mix(in srgb, var(--accent) 16%, transparent); } |
| } |
| +/* sticky highlight for the current search result, until another is picked or |
| + search ends (unlike .flash, this doesn't fade on its own) */ |
| +.msg.search-active { background: color-mix(in srgb, var(--accent) 16%, transparent); border-radius: var(--radius); } |
| /* ---- reply-thread view (apps/chat.js): tap a quote or a "N Replies" tally and |
| the thread lifts over the transcript, iMessage-style — the root pinned on |
| @@ -2180,6 +2183,8 @@ input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-off |
| } |
| .msg-result:hover { background: var(--bubble-them); } |
| .msg-result:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } |
| +/* the result currently jumped to (its message carries .msg.search-active) */ |
| +.msg-result.active { background: color-mix(in srgb, var(--accent) 16%, transparent); } |
| .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; } |