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>
9 files changed,
+475
−8
changelog/2026-07-12-message-search.md+7 −0package.json+1 −1test/search.test.mjs+114 −0web/pages/index.html+4 −0web/static/apps/chat.js+3 −1web/static/search.js+91 −0web/static/shell.js+225 −3web/static/store.js+7 −3web/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; } |