eleven / ocho Public

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>
James Adam fd7ba201614190ddb60511a54cfeb874f554a05c parent 715a47b
3 files changed, +31 −1
  • changelog/2026-07-14-search-result-highlight.md +4 −0
  • web/static/shell.js +22 −1
  • web/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; }