Every eigendoc editor (docs, sheets, slides, stickies) has a ⌘F find bar that searches inside
the open document — jump to a card, a cell, a heading. Each app implements one small
DocSearchController contract over its own live state; a shared DocSearchProvider owns the find
session (query, options, matches, active index), the keybinds, and the floating FindReplaceBar.
Matches are plain serialisable data (id + label + context) revealed by id, never closures —
so the same controller also feeds the command palette doc: scope and the ?q= deep link. Docs and
sheets additionally support replace (⌥⌘F). Comment threads on a board are searched server-side
through a separate DocCommentSearch capability backed by comments.db's comments_fts. Core
contract in packages/lib/src/types/doc-search.ts; the bar in
packages/ui/src/components/layout/search/.
This is finding a location inside the document you already have open. Finding which document contains a term (drive-wide body search) is a different system — see PROPOSAL_SEARCH.md.
Each eigendoc app implements DocSearchController once over its live state (the ProseMirror tree,
the workbook, the Y.Doc board). It is the single notion of "a match" shared by the find bar, the
palette doc: scope, and the ?q= landing.
type DocSearchOptions = { matchCase: boolean; wholeWord: boolean; regex: boolean };
type DocSearchMatch = {
id: string; // self-describing — resolvable from the string alone
// (`${from}:${to}`, `${sheetId}:${r}:${c}`, a card id)
label: string; // the matched text or card title
context?: string; // where it is: "To do" / "Sheet1 · B12" / "Slide 3"
};
type DocSearchController = {
search(query: string, opts: DocSearchOptions): DocSearchMatch[]; // PURE — no side effects
highlightAll(matches: DocSearchMatch[]): void; // paint all; [] clears
reveal(matchId: string): void; // scroll-to + flash
// v1.5 — replace (docs + sheets only; slides/stickies leave these unset)
canReplace?: boolean;
replace?(matchId, query, replacement, opts, preserveCase): DocSearchMatch[];
replaceAll?(query, replacement, opts, preserveCase): { replaced: number; matches: DocSearchMatch[] };
};Rules baked into the contract:
searchis pure — it never touches the document. Painting and scrolling are separate calls, so the palette can callsearch()and thenreveal(id)with nohighlightAll()in between.- Match ids are self-describing.
revealresolves an id from the string alone, never via a cached last-search lookup — the palette and an open bar session interleave calls on the same controller. highlightAllis a paint hint. Some surfaces ignore its array: docs paints from its own installedprosemirror-searchquery rather than these ids. The asymmetry is intended.revealtolerates stale ids (validate / clamp / no-op, never throw) and must not move focus while a bar session is open — that would breakEnter/⌘Gstepping. It centres the match so the bar can't cover it.- Replace returns the fresh post-edit match list, which the provider adopts — it never re-runs
search()after an edit (a React context is one render behind then). The query is explicit on every replace method;preserveCaseis a separate arg. Replacement strings are literal on every surface — no$1/$&expansion.
DocSearchProvider (packages/ui/.../layout/search/doc-search-provider.tsx) owns the find session
and renders the floating FindReplaceBar. A surface wraps its editor subtree in the provider and
passes its controller.
- Keybinds:
⌘Fopen (or re-focus + select when already open),⌘G/⇧⌘Gnext / previous match,Escclose,⌥⌘Fopen in replace mode (falls back to plain search when the surface is read-only). - Debounce: 150 ms on the query before re-searching.
- Live under collaboration: the provider re-runs the open session's search whenever the controller identity changes, so the n of m count stays live while a collaborator types. The active match is tracked by index and reveal is best-effort — under remote edits it may drift to a different occurrence.
- Undo/redo routing:
⌘Z/⇧⌘Zinside the bar's inputs route to the surface's own undo (passed asonUndo/onRedo), so a Replace stays undoable without leaving the bar. - Placement: the bar floats top-right of the wrapped subtree; a surface passes
barClassNameoffsets to clear its own chrome.onOpenChangelets a surface with a layeredEscape(slides: present → edit → bar → deselect) defer its default action to the bar-close. - Chrome entry points:
useDocSearchBar()(and the null-safeuseOptionalDocSearchBar()) exposeopen()/openReplace()/canReplaceto toolbar buttons (find-in-document-button.tsx, the⌕cluster) and Edit-menu items — so read-only surfaces hide "Find and replace".
Highlight visuals are single-sourced in packages/ui/src/styles/globals.css, so every surface looks
the same:
| Class | Role |
|---|---|
.eigen-search-match, .eigen-search-match-active |
inline text-run highlight (docs, sheets, slides) |
.eigen-search-ring, .eigen-search-ring-active |
object-outline highlight (stickies cards) |
.eigen-search-flash |
one-shot reveal pulse (the eigen-search-ring-flash keyframe) |
| App | Controller | Strategy | Replace |
|---|---|---|---|
| Docs | apps/docs/src/components/docs/use-doc-search-controller.ts |
ProseMirror walk; paints via prosemirror-search decorations; reveal sets a text selection + scrolls |
✓ |
| Sheets | apps/sheets/src/components/sheets/hooks/use-search-controller.ts |
adapter over packages/sheet collectMatches, iterating all tabs; reveal reuses scroll-and-select |
✓ |
| Slides | apps/slides/src/components/slides/hooks/use-slides-doc-search.ts |
in-memory scan of the deck | — |
| Stickies | apps/stickies/src/components/stickies/hooks/use-stickies-doc-search.ts |
Y.Doc scan of tasks / columns; reveal scrolls to + flashes the card |
— |
Slides and stickies leave the replace members unset and stay search-only; no v1 caller changes shape.
A ?q= term on any eigendoc route opens the bar pre-filled, highlights all matches, and reveals the
first — with focus staying in the document (the bar input is not focused on this path).
useLatchedDocSearchTerm(q) (packages/ui/src/hooks/use-eigen-doc-editor-route.ts) latches the term
once and strips q from the URL. This is the shape a palette drive/mail hit uses to carry its query
into the opened document (mail highlights the message body instead of a find bar).
DocSearchProvider publishes its controller to the palette via usePaletteDocSearch — it becomes
ctx.docSearch, present only while an eigendoc is open. The doc: scope's provider
(packages/lib/src/core/command-palette/providers/doc-search.ts) maps controller.search(q) to
doc-hit results under an In Document section (capped at 6, excluded from Top-Hit candidates so a
matched fragment can't hijack Enter from a typed file name). The palette searches with the bar's
default options (all-false) so its counts stay truthful; the option toggles live on the bar.
Enter on a doc hit calls ctx.docSearchSession.revealFromPalette(q, matchId) — the provider's
published DocSearchSession capability. It adopts the palette's query into the live find session,
paints all matches, and reveals the clicked one (n of m at its index), leaving focus in the
document. (Earlier it revealed in place; the bar-handoff replaced that on 2026-07-06.)
Card comment threads are embedded .eigenchat containers the client never bulk-loads, so they are
searched server-side through a separate capability, DocCommentSearch (ctx.docCommentSearch),
published alongside the controller by docs and stickies.
type DocCommentSearch = {
docKey: string; // `${ownerId}:${mountId}:${pathId}` of the OPEN doc
search(query: string): Promise<DocCommentMatch[]>;
reveal(matchId: string): void; // open the card / thread; tolerates stale ids
} | null;
type DocCommentMatch = { id: string; label: string; context?: string }; // id = the thread's chatName- Index:
comments.dbv3 (apps/api/src/lib/chat/comment-db-config.ts) adds arecentTextcolumn (the newest ~8 KB of each thread, recomputed on every comment write) and acomments_ftsexternal-content FTS5 table over it, with the standard 3 triggers + backfill. - Query:
CommentIndex.searchComments(q)(apps/api/src/lib/chat/comment-index.ts) runscomments_fts MATCH … ORDER BY bm25()with asnippet(), exposed atGET /collab/:ownerId/:mountId/:pathId/comments/search(apps/api/src/routes/collab.ts). - Palette:
providers/doc-comment-search.tswraps the capability in TanStack Query (keyed bydocKey, since a shared doc's owner can differ fromctx.ownerId) and rendersdoc-comment-hitresults under an In Comments section —doc:scope only, never the global blend. The app pairs the document-bound{ docKey, search }with its ownreveal(stickies: chatName → cardId → open the card; docs: open the comments panel + scroll).
Standalone chat history (an .eigenchat file opened on its own) is not covered here — a per-room
messages_fts over full chat history is future work, tracked as Phase 3 in
PROPOSAL_SEARCH.md.
packages/lib/src/doc-search/ holds the DOM-free helpers every controller reuses:
build-search-regex.ts—buildSearchRegex(query, opts)builds the matcher frommatchCase/wholeWord/regex.preserve-case.ts—applyPreserveCase(...)reapplies the source token's case to a literal replacement (a replace-only concernsearch()never reads).
| File | Purpose |
|---|---|
packages/lib/src/types/doc-search.ts |
DocSearchController / DocSearchMatch / DocSearchOptions / DocSearchSession / DocCommentSearch / DocCommentMatch |
packages/lib/src/doc-search/ |
buildSearchRegex, applyPreserveCase (shared DOM-free helpers) |
packages/ui/src/components/layout/search/doc-search-provider.tsx |
find session, keybinds, palette publication (usePaletteDocSearch) |
packages/ui/src/components/layout/search/find-replace-bar.tsx |
the floating bar UI |
packages/ui/src/components/layout/search/find-in-document-button.tsx |
toolbar ⌕ trigger |
packages/ui/src/hooks/use-eigen-doc-editor-route.ts |
useLatchedDocSearchTerm (?q= landing) |
packages/ui/src/styles/globals.css |
.eigen-search-* highlight classes |
packages/lib/src/core/command-palette/providers/doc-search.ts |
doc: scope → doc-hit results |
packages/lib/src/core/command-palette/providers/doc-comment-search.ts |
In Comments → doc-comment-hit results |
packages/lib/src/core/command-palette/hooks/use-palette-doc-search.ts |
app publishes its controller/capability |
apps/{docs,sheets,slides,stickies}/… |
per-app DocSearchController implementations (see table) |
apps/api/src/lib/chat/comment-db-config.ts |
comments.db v3 — recentText + comments_fts |
apps/api/src/lib/chat/comment-index.ts |
CommentIndex.searchComments |
apps/api/src/routes/collab.ts |
GET …/comments/search |