search: list fifty contacts and count the rest
A query matching the whole book still built and rendered every card,
364 ms for "a" at 1,000. The list now stops at
Admin::SearchResults::LIMIT and says how many more matched, so only
the contacts shown are built: 28 ms for the same query.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EQvcDxcd8ucY7iVaS6cJB8
change
commit 3c48abbae3425e98879ce3ce27c61f9c0cdcfb61
author Claude <noreply@anthropic.com>
date
parent e8bf340c
diff --git a/docs/plans/2026-10-17-search-keys.md b/docs/plans/2026-10-17-search-keys.md
index 7611720..0dad927 100644
--- a/docs/plans/2026-10-17-search-keys.md
+++ b/docs/plans/2026-10-17-search-keys.md
@@ -22,9 +22,13 @@ with requests sent in process:
 | blank (10 recent) | 168 ms | 17 ms |
 
 The browser was never the cause, because the results always came from
-the server (`2026-09-30-live-search.md`). A query that matches every
-card still builds and renders every card. That is what an uncapped
-list costs, and a cap would be a separate decision.
+the server (`2026-09-30-live-search.md`).
+
+A query that matches every card still had to build and render every
+card, at 364 ms for "a". So the contacts now stop at 50, followed by a
+line counting the rest and asking for a longer query. This reverses
+the live-search plan's "uncapped, with no see all": a list of hundreds
+was never read, and the next letter typed narrows it anyway.
 
 ## Shape
 
diff --git a/lib/pro_tacts/admin/search_results.rb b/lib/pro_tacts/admin/search_results.rb
index 0e04be6..81d4e70 100644
--- a/lib/pro_tacts/admin/search_results.rb
+++ b/lib/pro_tacts/admin/search_results.rb
@@ -11,21 +11,27 @@ module ProTacts
     # in: the contacts a query matches in the order Search ranks them
     # and then the groups — a group is a record the search reaches as
     # surely as a contact is (docs/DESIGN.md, "When adding something
-    # new"). Uncapped, with no "see all" to send a long list to
-    # (docs/plans/2026-09-30-live-search.md).
+    # new"). The contacts stop at LIMIT, and the rest are counted
+    # rather than listed: the answer to a query that long is a longer
+    # query (docs/plans/2026-10-17-search-keys.md).
     class SearchResults < Phlex::HTML
+      LIMIT = 50 #: Integer
+
       # @rbs @contacts: Array[Store::RecentContact]
       # @rbs @groups: Array[Group]
       # @rbs @recent: bool
+      # @rbs @more: Integer
 
       # `recent` is the list standing in for results before there is a
       # query — the most recently updated contacts, labeled as the
       # dashboard labels them (Web#search_results).
-      #: (contacts: Array[Store::RecentContact], groups: Array[Group], ?recent: bool) -> void
-      def initialize(contacts:, groups:, recent: false)
+      # `more` is how many matched past LIMIT.
+      #: (contacts: Array[Store::RecentContact], groups: Array[Group], ?recent: bool, ?more: Integer) -> void
+      def initialize(contacts:, groups:, recent: false, more: 0)
         @contacts = contacts
         @groups = groups
         @recent = recent
+        @more = more
       end
 
       def view_template
@@ -37,6 +43,7 @@ module ProTacts
         if @contacts.any?
           h2(class: "type-label") { @recent ? "recently updated" : "contacts" }
           ul(class: "card") { @contacts.each { render RecentRow.new(row: it) } }
+          p(class: "type-body-sm") { "#{@more} more. Keep typing to narrow them down." } if @more.positive?
         end
         return if @groups.empty?
 
diff --git a/lib/pro_tacts/web/search.rb b/lib/pro_tacts/web/search.rb
index 76812ab..f1da806 100644
--- a/lib/pro_tacts/web/search.rb
+++ b/lib/pro_tacts/web/search.rb
@@ -36,7 +36,7 @@ module ProTacts
 
     # A blank query answers the most recently updated contacts, the
     # dashboard's own list, so the search page is never empty before a
-    # word is typed. A query builds only the contacts it finds.
+    # word is typed. A query builds only the contacts it shows.
     #: (String query) -> Admin::SearchResults
     def search_results(query)
       if query.empty?
@@ -54,14 +54,15 @@ module ProTacts
           step.set_attribute("contacts", it.length)
         }
       }
+      shown = ids.first(Admin::SearchResults::LIMIT)
       contacts = span("search.contacts_by_recency") { |step|
-        found = store.contacts_by_recency(ids).to_h {
+        found = store.contacts_by_recency(shown).to_h {
           [it.contact.id, it] #: [String, Store::RecentContact]
         }
         step.set_attribute("rows", found.length)
-        ids.map { found.fetch(it) }
+        shown.map { found.fetch(it) }
       }
-      Admin::SearchResults.new(contacts:, groups: Admin::Search.groups(groups, query:))
+      Admin::SearchResults.new(contacts:, groups: Admin::Search.groups(groups, query:), more: ids.length - shown.length)
     end
   end
 end
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 22df518..2a8c35b 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -23,7 +23,8 @@ module ProTacts
     end
 
     class SearchResults
-      def self.new: (contacts: Array[Store::RecentContact], groups: Array[Group], ?recent: bool) -> instance
+      LIMIT: Integer
+      def self.new: (contacts: Array[Store::RecentContact], groups: Array[Group], ?recent: bool, ?more: Integer) -> instance
       def call: () -> String
     end
 
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 762d1d0..6b64f2f 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -540,6 +540,18 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # Past the cap, the rest are counted rather than listed.
+  def test_search_lists_up_to_its_limit_and_counts_the_rest
+    limit = ProTacts::Admin::SearchResults::LIMIT
+    cards = (limit + 2).times.to_h { ["ada#{it}", ADA.sub("Ada Lovelace", "Ada #{it}").sub("UID:ada", "UID:ada#{it}")] }
+    with_contacts(cards) do
+      get "/search/results", q: "ada"
+
+      assert_equal limit, last_response.body.scan("<li").length
+      assert_includes last_response.body, "2 more. Keep typing to narrow them down."
+    end
+  end
+
   def test_search_with_no_matches_says_so
     with_contacts({"ada" => ADA}) do
       get "/search/results", q: "nobody"