eleven / messenger Public

web: message search over the local store (#586) (#780)

* web: message search over the local store (#586)

- search.js: matchText/snippetFor (pure) + SearchIndex — rows decrypt lazily
  into an in-MEMORY index (never written anywhere; the store stays ciphertext
  at rest), incremental by a seen-id SET, not a high-water mark: backfill
  delivers ever-older pages and live frames ever-newer ones, and both must
  index exactly once (the E2E caught the watermark version dropping every
  backfilled row — regression pinned in test/search.test.mjs)
- sidebar: the existing search box now also finds MESSAGES — results render
  inline below the name-filtered chat list (count aria-live, newest-first,
  native-parity limit 50, snippets windowed around the hit); a row jumps to
  the message in its thread (bounded DOM-guided page-back + the reply-jump's
  scroll/flash, chat.js scrollToMessage now exported)
- 'Search earlier messages…' offered while any chat thread's history is
  partial: walks runBackfill thread by thread with the #228 time-machine
  narration ('Searching back to March 2025…'), streaming fresh hits into the
  open results, Stop cancels (watermark persists → later runs resume);
  completed threads never re-offer
- live deletes and left threads evict from the index the moment they happen
- everything local: the query never leaves the device

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

* web: search review fixes — WCAG, O(pages) deep walk, re-entrancy

- results scaffold persists: ONE aria-live status node whose text mutates
  (recreated live regions aren't reliably announced; the walk's narration now
  shares it — no double-speak), list rebuilt per render, single footer slot
- result rows keep their implicit button role (role=listitem was overriding
  it — invisible affordance to AT)
- jumpToMessage moves focus with the navigation (bubble takes tabindex=-1
  focus; composer fallback) — no more focus stranded on a hidden control
- runBackfill's onPage is awaited and hands over the page's rows; the deep
  walk indexes ONLY those (was re-reading the whole thread per page — O(P²))
- startDeepSearch guards against a second concurrent walk; the footer render
  is seq-guarded so a slow mark read can't append a stale button
- first search streams results per thread instead of one render at the end
- search.js documents the deliberate no-eviction memory posture

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

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3d5972b77d7249f2d0f1816915160acd7b0491d2 parent c2236e8
9 files changed, +475 −8
  • changelog/2026-07-12-message-search.md +7 −0
  • package.json +1 −1
  • test/search.test.mjs +114 −0
  • web/pages/index.html +4 −0
  • web/static/apps/chat.js +3 −1
  • web/static/search.js +91 −0
  • web/static/shell.js +225 −3
  • web/static/store.js +7 −3
  • web/static/style.css +23 −0
diff --git a/changelog/2026-07-12-message-search.md b/changelog/2026-07-12-message-search.md
new file mode 100644
index 0000000..da72e73
--- /dev/null
+++ b/changelog/2026-07-12-message-search.md
@@ -0,0 +1,7 @@
+🤖 Search your messages on the web. The search box above your chats now finds
+messages, not just chat names — type a couple of words and matching messages
+appear below, newest first; tap one to jump right to it in its chat. Searching
+happens entirely on your device: your messages stay sealed end-to-end, and
+what you type in the box never leaves your browser. If a chat's older history
+isn't on your device yet, "Search earlier messages" fetches it — watch it dig
+back through the months, and stop it any time.
diff --git a/package.json b/package.json
index 1bb551c..ca5f792 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs"
}
}
diff --git a/test/search.test.mjs b/test/search.test.mjs
new file mode 100644
index 0000000..c53eaac
--- /dev/null
+++ b/test/search.test.mjs
@@ -0,0 +1,114 @@
+// Tests for web/static/search.js (#586) — the pure matchers and the
+// SearchIndex with an injected decrypt, so everything runs in Node.
+import assert from "assert";
+import { matchText, snippetFor, SearchIndex } from "../web/static/search.js";
+
+function test(name, fn) {
+ return Promise.resolve()
+ .then(fn)
+ .then(() => console.log(`PASS ${name}`))
+ .catch((e) => {
+ console.error(`FAIL ${name}`);
+ console.error(e);
+ process.exit(1);
+ });
+}
+
+await test("matchText: case-insensitive AND across terms", () => {
+ assert.ok(matchText("We ship on Friday", "ship friday"));
+ assert.ok(matchText("We ship on Friday", "SHIP"));
+ assert.ok(!matchText("We ship on Friday", "ship monday"));
+ assert.ok(!matchText("", "x"));
+ assert.ok(!matchText("hello", ""));
+ assert.ok(!matchText("hello", " "), "whitespace-only query matches nothing");
+});
+
+await test("matchText: unicode text matches plainly", () => {
+ assert.ok(matchText("Cámara está lista 🎉", "cámara lista"));
+});
+
+await test("snippetFor: short text passes through; long text windows around the hit", () => {
+ assert.strictEqual(snippetFor("short one", "one"), "short one");
+ const long = "aaa ".repeat(40) + "NEEDLE in the middle " + "bbb ".repeat(40);
+ const snip = snippetFor(long, "needle", 60);
+ assert.ok(snip.includes("NEEDLE"), "hit stays in the window");
+ assert.ok(snip.length <= 62, "bounded (+ellipses)");
+ assert.ok(snip.startsWith("…") && snip.endsWith("…"), "cut edges ellipsised");
+});
+
+const fakeDecrypt = async (row) => {
+ // Rows carry their "plaintext" in-blob for the test: "name|text" or "SKIP".
+ if (row.blob === "SKIP") return null;
+ const [name, text] = row.blob.split("|");
+ return { name, text, ts: row.created_at * 1000 };
+};
+
+await test("SearchIndex: incremental ensure — re-ensuring decrypts each row once", async () => {
+ let decrypts = 0;
+ const idx = new SearchIndex(async (r) => {
+ decrypts++;
+ return fakeDecrypt(r);
+ });
+ await idx.ensureThread("t1", [
+ { id: 1, created_at: 10, blob: "Ana|the launch plan" },
+ { id: 2, created_at: 11, blob: "Bo|lunch at noon" },
+ ]);
+ assert.strictEqual(decrypts, 2);
+ await idx.ensureThread("t1", [
+ { id: 1, created_at: 10, blob: "Ana|the launch plan" },
+ { id: 2, created_at: 11, blob: "Bo|lunch at noon" },
+ { id: 3, created_at: 12, blob: "Ana|launch is a go" },
+ ]);
+ assert.strictEqual(decrypts, 3, "already-seen rows skipped");
+ const hits = idx.query("launch");
+ assert.strictEqual(hits.length, 2);
+ assert.strictEqual(hits[0].id, 3, "newest first");
+});
+
+await test("SearchIndex: OLDER rows arriving later (the backfill pattern) still index", async () => {
+ // The recent window indexes first; a backfill walk then delivers strictly
+ // older pages. A high-water mark would silently drop them — the regression
+ // this test pins (found live by the #586 E2E drive).
+ const idx = new SearchIndex(fakeDecrypt);
+ await idx.ensureThread("t1", [{ id: 100, created_at: 100, blob: "Ana|recent chatter" }]);
+ await idx.ensureThread("t1", [
+ { id: 5, created_at: 5, blob: "Bo|the ancient treasure" },
+ { id: 6, created_at: 6, blob: "Ana|more history" },
+ ]);
+ const hits = idx.query("ancient treasure");
+ assert.strictEqual(hits.length, 1, "backfilled row is searchable");
+ assert.strictEqual(hits[0].id, 5);
+});
+
+await test("SearchIndex: tombstone rows evict their target from the index", async () => {
+ const idx = new SearchIndex(fakeDecrypt);
+ await idx.ensureThread("t1", [
+ { id: 1, created_at: 10, blob: "Ana|delete me later" },
+ { id: 2, created_at: 11, blob: "Bo|keep me" },
+ ]);
+ assert.strictEqual(idx.query("delete me").length, 1);
+ await idx.ensureThread("t1", [{ id: 3, created_at: 12, blob: "", tombstone_of: 1 }]);
+ assert.strictEqual(idx.query("delete me").length, 0, "unsent message stops matching");
+ assert.strictEqual(idx.query("keep me").length, 1);
+});
+
+await test("SearchIndex: undecryptable rows skipped; drops work; cross-thread query + limit", async () => {
+ const idx = new SearchIndex(fakeDecrypt);
+ await idx.ensureThread("a", [
+ { id: 5, created_at: 1, blob: "Ana|target in a" },
+ { id: 6, created_at: 2, blob: "SKIP" },
+ ]);
+ await idx.ensureThread("b", [{ id: 9, created_at: 3, blob: "Bo|target in b" }]);
+ let hits = idx.query("target");
+ assert.deepStrictEqual(hits.map((h) => h.tid), ["b", "a"], "newest id first across threads");
+ idx.dropMessage("b", 9);
+ assert.strictEqual(idx.query("target").length, 1, "live delete evicts");
+ idx.dropThread("a");
+ assert.strictEqual(idx.query("target").length, 0, "leaving a thread evicts it");
+ // limit honoured
+ const many = new SearchIndex(fakeDecrypt);
+ await many.ensureThread("m", Array.from({ length: 60 }, (_, i) => ({ id: i + 1, created_at: i, blob: `N|hit ${i}` })));
+ assert.strictEqual(many.query("hit", 50).length, 50);
+});
+
+console.log("\nALL PASSED");
diff --git a/web/pages/index.html b/web/pages/index.html
index e51ca9d..bac8d77 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -136,6 +136,10 @@
</div>
</div>
</div>
+ <!-- Message search results (#586, shell.js): filled while the search box
+ holds a query; each row jumps to the message in its thread. Inline in
+ the sidebar — occupies layout space, never floats. -->
+ <div class="msg-results hidden" id="msg-results" role="region" aria-label="Message search results"></div>
<!-- Inline passkey-backup nudge (backup.js fills it in). It occupies layout
space in the sidebar — never a floating toast. -->
<div class="backup-banner hidden" id="backup-banner"></div>
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 7282042..232c64f 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -1141,7 +1141,9 @@ function agentTag(kind) {
return t;
}
-function scrollToMessage(near, sid) {
+// Exported for the shell's search-result jump (#586): `near` is any node
+// inside the target feed (the feed itself works).
+export function scrollToMessage(near, sid) {
const feed = near.closest(".chatapp-feed");
const target = feed && feed.querySelector(`.msg[data-id="${sid}"]`);
if (!target) return;
diff --git a/web/static/search.js b/web/static/search.js
new file mode 100644
index 0000000..e3717e5
--- /dev/null
+++ b/web/static/search.js
@@ -0,0 +1,91 @@
+// Message search over the local store (#586) — the web twin of the native
+// MessageStore.search (Mac header / iOS thread list).
+//
+// The store holds CIPHERTEXT rows (store.js), so searching means decrypting —
+// done lazily into an in-MEMORY index (never written anywhere): each thread
+// decrypts once per session, then queries are instant scans. At this
+// product's scale a substring scan is plenty — no FTS engine in the browser,
+// matching the issue's call. Decrypt is injected, so the index is Node-testable.
+//
+// Memory: the index holds each ensured thread's decrypted text for the
+// session — roughly the size of the conversation itself (tens of MB only at
+// the far tail of huge, fully-backfilled spaces). Deliberately uncapped: an
+// eviction scheme would silently narrow search coverage; the tab's lifetime
+// bounds it. Revisit if a real space ever makes this hurt.
+
+// Pure: does `text` match the query? Case-insensitive; every whitespace-
+// separated term must appear (AND) — the familiar chat-search behaviour.
+export function matchText(text, query) {
+ if (!text || !query) return false;
+ const hay = text.toLowerCase();
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
+ return terms.length > 0 && terms.every((t) => hay.includes(t));
+}
+
+// Pure: a readable window around the first hit, ellipsised at cut edges.
+export function snippetFor(text, query, len = 90) {
+ const t = (text || "").replace(/\s+/g, " ").trim();
+ if (t.length <= len) return t;
+ const first = (query || "").toLowerCase().split(/\s+/).filter(Boolean)[0] || "";
+ const at = first ? t.toLowerCase().indexOf(first) : 0;
+ const start = Math.max(0, Math.min(at - Math.floor(len / 3), t.length - len));
+ const cut = t.slice(start, start + len);
+ return (start > 0 ? "…" : "") + cut + (start + len < t.length ? "…" : "");
+}
+
+// A session-scoped decrypted-text index. Rows arrive as raw store rows
+// ({id, created_at, blob, tombstone_of}); decryptRow(row, tid) resolves
+// { text, name, ts } or null (undecryptable / typed app event / reaction —
+// not searchable prose). Incremental per thread by a seen-id SET, not a
+// high-water mark: rows arrive out of ID order by design — live arrivals are
+// newer, but a backfill walk (#457) delivers ever-OLDER pages, and both must
+// index exactly once.
+export class SearchIndex {
+ constructor(decryptRow) {
+ this.decryptRow = decryptRow;
+ this.threads = new Map(); // tid → { seen: Set<id>, entries: [{id, ts, text, name}] }
+ }
+
+ async ensureThread(tid, rows) {
+ let t = this.threads.get(tid);
+ if (!t) {
+ t = { seen: new Set(), entries: [] };
+ this.threads.set(tid, t);
+ }
+ for (const row of rows) {
+ if (!row || !row.id || t.seen.has(row.id)) continue;
+ t.seen.add(row.id);
+ if (row.tombstone_of) {
+ // The unsent message must stop matching — drop it from the index too.
+ const gone = row.tombstone_of;
+ t.entries = t.entries.filter((e) => e.id !== gone);
+ continue;
+ }
+ const d = await this.decryptRow(row, tid);
+ if (d && d.text) t.entries.push({ id: row.id, ts: d.ts || row.created_at * 1000 || 0, text: d.text, name: d.name || "" });
+ }
+ return t;
+ }
+
+ // Forget one message (a live delete frame) or a whole thread (left it).
+ dropMessage(tid, id) {
+ const t = this.threads.get(tid);
+ if (t) t.entries = t.entries.filter((e) => e.id !== id);
+ }
+ dropThread(tid) {
+ this.threads.delete(tid);
+ }
+
+ // Newest-first matches across every ensured thread — native parity
+ // (MessageStore.search: instance-wide, limit 50).
+ query(q, limit = 50) {
+ const out = [];
+ for (const [tid, t] of this.threads) {
+ for (const e of t.entries) {
+ if (matchText(e.text, q)) out.push({ tid, ...e });
+ }
+ }
+ out.sort((a, b) => b.id - a.id);
+ return out.slice(0, limit);
+ }
+}
diff --git a/web/static/shell.js b/web/static/shell.js
index 2275444..eff0d5c 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -128,7 +128,8 @@ import { startWelcome } from "/static/apps/welcome.js"; // the joiner welcome co
import { planQuickForm } from "/static/apps/quickform.js"; // "Fill in a form instead" — values → verbs+records
import "/static/apps/admin.js"; // the admin panel (admin-only; opened via /admin or setup's chip)
import "/static/apps/appstudio.js"; // make + manage your own member apps (docs/member-apps.md)
-import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom } from "/static/apps/chat.js";
+import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage } from "/static/apps/chat.js";
+import { SearchIndex, snippetFor } from "/static/search.js"; // message search over the local store (#586)
import { loadMemberApps, setMemberSeeder, setSurfaceRefresh, setMemberAdmin, recentLocalDeploy, pushTheme } from "/static/sandbox.js"; // member-built apps, run sandboxed (docs/member-apps.md)
import { parseInviteFragment, buildInviteFragment } from "/static/invite.js";
import { qrCanvas } from "/static/qrcode.js";
@@ -160,6 +161,9 @@ import {
clearThread as storeClearThread,
cachedThreadIds as storeCachedThreadIds,
wipeStore,
+ allMessages as storeAllMessages,
+ getBackfillMark as storeBackfillMark,
+ runBackfill as storeRunBackfill,
} from "/static/store.js";
const SESSION_KEY = "lchat_session";
@@ -2599,12 +2603,15 @@ function renderApp() {
document.getElementById("new-chat").onclick = showNewChat;
document.getElementById("back-btn").onclick = showList;
- // Filter the chat list as you type (name match only; never sent anywhere).
+ // Filter the chat list as you type (name match only; never sent anywhere) —
+ // and search MESSAGES too (#586): matches from the local store render below
+ // the (filtered) chat list. All local: nothing typed here leaves the device.
const filter = document.getElementById("chat-filter");
if (filter)
filter.oninput = () => {
chatFilter = filter.value.trim().toLowerCase();
renderThreadList();
+ scheduleMessageSearch(filter.value.trim());
};
// The funnel beside search: a small menu of list filters ("Show online
@@ -4410,6 +4417,218 @@ async function chatAppLoadOlder(threadId, beforeSid) {
return msgs.length;
}
+// ---- message search (#586) -------------------------------------------------
+// Search over the local store: rows decrypt lazily into an in-MEMORY index
+// (search.js — never written anywhere), then queries are instant scans.
+// Everything is local; the query never leaves the device. Results render in
+// the sidebar (#msg-results) below the name-filtered chat list; a row jumps
+// to the message in its thread. "Search earlier messages" walks incomplete
+// threads' full history into the store (runBackfill), streaming new hits in,
+// cancellable — the #228 time machine.
+const searchIdx = new SearchIndex(async (row, tid) => {
+ try {
+ const data = await decrypt(threadKeys[tid], row.blob);
+ // Only plain chat prose is searchable — typed app events and reactions
+ // have no text a member would hunt for.
+ if (data.type || !data.text) return null;
+ return { text: data.text, name: data.name || "", ts: data.ts || row.created_at * 1000 };
+ } catch {
+ return null;
+ }
+});
+
+// The threads search covers: every chat-feed thread we hold a key for.
+function searchableThreads() {
+ return threads.filter((t) => chatAppFor(t.id) && threadKeys[t.id]);
+}
+
+let searchSeq = 0;
+let searchTimer = null;
+let searchBackfill = null; // { controller } while the deep walk runs
+
+function scheduleMessageSearch(query) {
+ clearTimeout(searchTimer);
+ const box = document.getElementById("msg-results");
+ if (!box) return;
+ if (!query || query.length < 2) {
+ // Leaving search cancels any deep walk — its watermark persists, so a
+ // later "Search earlier messages" resumes where it stopped.
+ if (searchBackfill) searchBackfill.controller.abort();
+ searchSeq++;
+ box.classList.add("hidden");
+ box.textContent = "";
+ return;
+ }
+ searchTimer = setTimeout(() => runMessageSearch(query), 250);
+}
+
+async function runMessageSearch(query) {
+ const mySeq = ++searchSeq;
+ // 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()) {
+ await searchIdx.ensureThread(t.id, await storeAllMessages(t.id));
+ if (mySeq !== searchSeq) return; // superseded while decrypting
+ renderSearchResults(query);
+ }
+}
+
+// The results scaffold persists across renders: ONE aria-live status node
+// whose text mutates (a recreated live region isn't reliably announced, and
+// two would double-speak), a list rebuilt per render, and a single footer
+// slot (the deeper-search affordance / the walk's Stop control — the latter
+// outside the live region so it isn't re-announced).
+function resultsScaffold() {
+ const box = document.getElementById("msg-results");
+ if (!box) return null;
+ if (!box.firstChild) {
+ const status = document.createElement("div");
+ status.className = "msg-results-count";
+ status.setAttribute("aria-live", "polite");
+ const list = document.createElement("div");
+ const footer = document.createElement("div");
+ box.append(status, list, footer);
+ }
+ const [status, list, footer] = box.children;
+ return { box, status, list, footer };
+}
+
+function renderSearchResults(query) {
+ const s = resultsScaffold();
+ if (!s) return;
+ const hits = searchIdx.query(query);
+ s.box.classList.remove("hidden");
+ let statusText = 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.
+ if (searchBackfill) statusText += ` · ${searchBackfill.progress}`;
+ if (s.status.textContent !== statusText) s.status.textContent = statusText;
+ s.list.textContent = "";
+ for (const h of hits) {
+ const row = document.createElement("button");
+ row.type = "button";
+ row.className = "msg-result";
+ const top = document.createElement("div");
+ top.className = "msg-result-top";
+ const who = document.createElement("span");
+ who.className = "msg-result-who";
+ who.textContent = h.name || "Someone";
+ const where = document.createElement("span");
+ where.className = "msg-result-where";
+ where.textContent = `${threadName[h.tid] || "Chat"} · ${relTime(Math.floor(h.ts / 1000))}`;
+ top.append(who, where);
+ const snip = document.createElement("div");
+ snip.className = "msg-result-snippet";
+ snip.textContent = snippetFor(h.text, query);
+ row.append(top, snip);
+ row.onclick = () => jumpToMessage(h.tid, h.id);
+ s.list.appendChild(row);
+ }
+ renderSearchFooter(query);
+}
+
+// The footer slot: "Search earlier messages…" while any thread's history is
+// partial, or the running walk's Stop control. Guarded by searchSeq so a slow
+// mark read can't append a stale button over a newer render's footer.
+async function renderSearchFooter(query) {
+ const s = resultsScaffold();
+ if (!s) return;
+ if (searchBackfill) {
+ s.footer.replaceChildren(searchBackfill.cancel);
+ return;
+ }
+ const mySeq = searchSeq;
+ const incomplete = [];
+ for (const t of searchableThreads()) {
+ const mark = await storeBackfillMark(t.id);
+ if (!mark || !mark.complete) incomplete.push(t.id);
+ }
+ if (mySeq !== searchSeq || searchBackfill) return; // superseded meanwhile
+ s.footer.textContent = "";
+ if (!incomplete.length) return;
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "msg-result-deeper";
+ btn.textContent = "Search earlier messages…";
+ btn.onclick = () => startDeepSearch(query, incomplete);
+ s.footer.replaceChildren(btn);
+}
+
+async function startDeepSearch(query, threadIds) {
+ if (searchBackfill) return; // one walk at a time
+ const controller = new AbortController();
+ const cancel = document.createElement("button");
+ cancel.type = "button";
+ cancel.className = "msg-result-cancel";
+ cancel.textContent = "Stop searching";
+ cancel.onclick = () => controller.abort();
+ searchBackfill = { controller, cancel, progress: "Searching…" };
+ renderSearchResults(query); // swaps the footer button for Stop
+ try {
+ for (const tid of threadIds) {
+ if (controller.signal.aborted) break;
+ await storeRunBackfill(tid, (before) => fetchMessagePage(tid, before), {
+ signal: controller.signal,
+ onPage: async ({ oldestAt, rows }) => {
+ // Narrate how deep we've dug; index ONLY this page's rows (the
+ // seen-set dedupes) — re-reading the whole thread per page would
+ // make a long walk O(pages²).
+ if (searchBackfill) {
+ const d = oldestAt ? new Date(oldestAt * 1000) : null;
+ searchBackfill.progress = d
+ ? `Searching back to ${d.toLocaleDateString([], { month: "long", year: "numeric" })}…`
+ : "Searching…";
+ }
+ await searchIdx.ensureThread(tid, rows || []);
+ const q = (document.getElementById("chat-filter") || {}).value || "";
+ if (q.trim().length >= 2) renderSearchResults(q.trim());
+ },
+ });
+ }
+ } finally {
+ searchBackfill = null;
+ const q = (document.getElementById("chat-filter") || {}).value || "";
+ if (q.trim().length >= 2) renderSearchResults(q.trim());
+ }
+}
+
+// One history page for the backfill walk (before=0 ⇒ the most recent page).
+async function fetchMessagePage(threadId, before) {
+ const q = before ? `&before=${before}&limit=200` : "&limit=200";
+ const r = await (
+ await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(threadId)}${q}`)
+ ).json();
+ return r.messages || [];
+}
+
+// Open the hit's thread and land on the message: page history back (bounded,
+// DOM-guided — the oldest rendered bubble's data-id is the cursor) until the
+// sid is in the feed, then reuse the reply-jump's scroll + flash.
+async function jumpToMessage(tid, sid) {
+ await openThread(tid);
+ const pane = document.getElementById("app-pane");
+ const feed = pane && pane.querySelector(".chatapp-feed");
+ if (!feed) return;
+ for (let i = 0; i < 12 && !feed.querySelector(`.msg[data-id="${sid}"]`); i++) {
+ const first = feed.querySelector(".msg[data-id]");
+ const oldest = first ? +first.dataset.id : 0;
+ if (!oldest || oldest <= sid) break;
+ if (!(await chatAppLoadOlder(tid, oldest))) break;
+ }
+ scrollToMessage(feed, sid);
+ // Move focus with the navigation (the result button may now be hidden —
+ // mobile swaps the sidebar for the thread — and focus must never strand on
+ // 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}"]`);
+ const focusEl = target || document.querySelector(".chatapp-root .composer textarea");
+ if (focusEl) {
+ if (target) target.tabIndex = -1;
+ focusEl.focus({ preventScroll: true });
+ }
+}
+
// Ephemeral transport (ctx.emitEphemeral): same encryption as emit, but the
// envelope's `ephemeral` flag asks the server to relay-and-forget — no store,
// no push, no message id. Presence traffic only.
@@ -7092,8 +7311,10 @@ function connectWS() {
if (msg.type === "delete") {
// Chat app: a delete (server-verified author, ciphertext hard-deleted
// upstream) removes the message from the fold. The local store drops its
- // ciphertext too — deleting really deletes, here as on the server.
+ // ciphertext too — deleting really deletes, here as on the server —
+ // and the in-memory search index forgets it the same moment.
if (!embed) storeDeleteMessage(msg.thread_id, msg.id).catch(() => {});
+ searchIdx.dropMessage(msg.thread_id, msg.id);
if (chatAppFor(msg.thread_id)) {
foldHostEvent(msg.thread_id, "chat", { type: "chat.delete", msgId: msg.id });
if (msg.thread_id === activeId) renderActiveSurfaces();
@@ -8368,6 +8589,7 @@ async function leaveGroup() {
threads = threads.filter((x) => x.id !== id);
delete threadKeys[id];
storeClearThread(id).catch(() => {}); // a left thread keeps no local rows (#457)
+ searchIdx.dropThread(id); // …and stops matching in search (#586)
if (activeId === id) {
activeId = null;
localStorage.removeItem(ACTIVE_KEY); // the thread we were in is gone
diff --git a/web/static/store.js b/web/static/store.js
index 627080d..27c1485 100644
--- a/web/static/store.js
+++ b/web/static/store.js
@@ -276,8 +276,10 @@ export function backfillCursor(mark, oldestStored) {
// Drive a thread's backfill to completion (or cancellation). Transport is
// injected — fetchPage(before) resolves the raw rows of one page (before=0 ⇒
// the most recent page) — so this stays free of tokens and testable end to
-// end by importing the module. onPage({oldest, oldestAt, count}) narrates
-// progress ("searching back to 2024…"); an AbortSignal cancels between pages.
+// end by importing the module. onPage({oldest, oldestAt, count, rows}) is
+// AWAITED per page — it narrates progress ("searching back to 2024…") and
+// receives the page's rows for incremental indexing; an AbortSignal cancels
+// between pages.
export async function runBackfill(threadId, fetchPage, { onPage, signal } = {}) {
let cursor = backfillCursor(await getBackfillMark(threadId), await oldestStoredId(threadId));
if (cursor === -1) return { complete: true };
@@ -301,7 +303,9 @@ export async function runBackfill(threadId, fetchPage, { onPage, signal } = {})
await putMessages(threadId, rows);
cursor = rows[0].id; // pages come ascending; the first row is the oldest
await setBackfillMark(threadId, { oldest: cursor, complete: false });
- if (onPage) onPage({ oldest: cursor, oldestAt: rows[0].created_at || 0, count: rows.length });
+ // Hand the page's rows over for incremental indexing, and AWAIT: a slow
+ // consumer backpressures the walk rather than racing it.
+ if (onPage) await onPage({ oldest: cursor, oldestAt: rows[0].created_at || 0, count: rows.length, rows });
}
return { complete: false };
}
diff --git a/web/static/style.css b/web/static/style.css
index dd9af08..bb861cd 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -2021,6 +2021,29 @@ input.filter-check:checked::before { transform: scale(1); }
input.filter-check:focus { outline: none; }
input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.chat-search input::placeholder { color: var(--muted); }
+/* Message search results (#586): an inline sidebar section under the search
+ box — rows read like slim thread rows; everything keyboard-reachable. */
+.msg-results { flex: none; overflow-y: auto; max-height: 45%; padding: 0 8px 6px; border-bottom: 1px solid var(--border); }
+.msg-results-count { font-size: 12px; font-weight: 600; color: var(--muted); padding: 6px 8px 4px; }
+.msg-result {
+ display: block; width: 100%; text-align: left; box-sizing: border-box;
+ font: inherit; color: var(--ink); background: none; border: none;
+ padding: 8px 10px; border-radius: 10px; cursor: pointer;
+}
+.msg-result:hover { background: var(--bubble-them); }
+.msg-result:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+.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; }
+.msg-result-snippet { font-size: 13px; color: var(--muted); margin-top: 2px; overflow-wrap: anywhere; }
+/* The deeper-search affordance + its progress line ("Searching back to…"). */
+.msg-result-deeper, .msg-result-cancel {
+ font: inherit; font-size: 13px; font-weight: 600; color: var(--accent);
+ background: none; border: none; cursor: pointer; padding: 8px 10px; border-radius: 10px;
+}
+.msg-result-deeper:hover, .msg-result-cancel:hover { background: var(--bubble-them); }
+.msg-result-deeper:focus-visible, .msg-result-cancel:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+.msg-result-status { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 13px; color: var(--muted); padding: 4px 0 4px 10px; }
/* Keep the app's standard focus ring (global input:focus); just lift the fill. */
.chat-search input:focus { background: var(--panel); }
.thread-list { flex: 1; overflow-y: auto; min-height: 0; padding: 6px; display: flex; flex-direction: column; gap: 2px; }