Proposal: Five Modes → Four¶
Status: Done — Phases 0–6 shipped. PlebChat is four modes; Read's rail is a lens over four Collections, Files included. Only the optional Phase 7 (30112 sync) remains · Last updated: 2026-07-15
Summary¶
Dissolve Search mode. Its research agent folds into Chat behind a Search popover in the input capsule; its news surface folds into Read as two Collections. The result is four modes — Chat, Read, Synthesize, Debate — plus a Read sidebar that finally has a job: an identity lens over a Collections list (eBooks, Articles, Files, Feeds).
Search never earned its slot. It is two unrelated things bolted together: an agentic research surface, which is just Chat with tools, and a news/RSS reader, which is just Read with a different content type. Meanwhile Chat is a real agent loop running buildTools: () => [] — zero tools — and Read's rail is nothing but the "browse other people's library" affordance that got parked there when it left the library header.
Two secondary outcomes: Chat gains a clean, extensible provider seam (adding a search API becomes one array entry), which is the foundation for the user-editable agent presets and pi skills that come later; and Files arrives as a new collection — PDFs, deliberately non-highlightable, on their own event kind so they never touch the CFI machinery.
Pre-release stance applies (CLAUDE.md): breaking changes encouraged, no migrations, no compat shims. Old search threads and old /search links die.
Supersedes search-mode.md and news-mode.md.
Decisions (2026-07-15)¶
- A Search popover, not Web/Nostr toggle buttons. One control, because more search APIs are coming (Exa, Brave, Wikipedia…). Provider selection and the effort setting (Balanced / Deep Research) live inside it. The two bare toggles in
SearchInputBardon't scale past three providers. - Per-thread config, global default.
ChatThreadgainssearchProviders/searchEffort, reusing the session-settings machinery that already carries model + thinking level. This is the presets seam: a preset later is a named{providers, effort, model, persona}bundle written onto a thread. A global-only setting would confine presets to app-wide toggles forever. - Presets and skills are future work. Build the seam, not the feature. The pi harness supports much more than we expose; that's a later proposal.
$lib/search/survives, renamed$lib/providers/. It doesn't die with the mode —firecrawl.tsandnip50.tsare transports that$lib/news/already imports. A directory namedsearchthat isn't a mode is a lie; rename it while the import churn is 6 lines.- News moves as-is.
$lib/news/stays put — it's a surface directory, and its only structural coupling wasSearchViewhosting it. Only the host changes. - PDFs get their own kind (30112) and no annotations. This sidesteps the blocker entirely — see pdf-files.md.
- No
/search→/chatredirect. That's a compat shim. Pre-release; the link dies.
Architecture decisions¶
- Chat's components win the merge.
ChatThreadRailvs Search'sThreadSidebardiffers by ~30 additive lines (incognito, housekeeping);ChatMessageListvsMessageListdiffers only by the[sN]cite delegation. Chat's copies are strict supersets, so Search's are deleted and Chat gains the cite handler. - The empty-provider case is the merge's safety guarantee.
buildChatPrompt(caps)returnsCHAT_PERSONAalone when no providers are enabled — byte-identical to today's Chat. Tools and prompt rebuild together at the top of everysend(), as they already do in Search. 'browse'leaves Read's view union. Under the identity lens the main pane isBrowseViewwithcollectionforced to'ebooks'. That's lens state, not view state — conflating them is what made the rail incoherent in the first place.- The lens is honest about encryption. Kinds 30107/30108 are NIP-44-to-self, so another person's feeds and saved articles are cryptographically unreadable. Locked collections render disabled with the reason stated, rather than as empty lists that would imply the person saved nothing. The point is to teach the privacy property, not hide the feature.
- Sources stay tool-recorded.
recorderForlifts into Chat's store unchanged, resolving through chat's incognito-awarethreadForId. Free property:saveThreadno-ops for incognito, so an incognito thread's sources live in memory and die with it — correct, no extra code.
Phases¶
- Phase 0 — Proposals: this doc + pdf-files.md;
read-mode.mdPhase 9 repointed;search-mode.md/news-mode.mdfrozen with superseded banners. - Phase 1 —
$lib/search/→$lib/providers/.git mv+ ~6 import lines (news/fetch.ts,news/stores/{article,discover},routes/settings/+page.svelte). Newregistry.ts:SearchProviderinterface (id,label,hint,icon,isConfigured(),unconfiguredHint,buildTools(recorder),promptLines) +SEARCH_PROVIDERS+resolveProviders. Tool factories move in verbatim fromtools.ts:23-124, prompt bullets fromprompt.ts:41-52.buildSearchTools(recorder, enabled)iterates the registry;buildSearchGuidance(caps)returns tools + effort + citations sections only (no persona — that's Chat's). Effort union →'balanced' | 'deep'; addsettings.search.providers. Stale-effort hazard: a saved'quick'survivesload()'s spread and can arrive from another device via the 30111 payload — do not migrate; make lookups total (EFFORT_GUIDANCE[effort] ?? EFFORT_GUIDANCE.balanced). Search mode still works. Ships with no user-visible change beyond effort labels. Shipped — 95 e2e green. Two deviations:DEFAULT_PROVIDER_IDSdropped (importing it into settings as a value closes the cycle the type-only import avoids;envDefaults()is the one source of the default), andbuildSearchToolslives inregistry.tsrather thantools.ts— the dependency must run registry → tools one way only, and importing it back closed a cycle that broke module init and blanked the whole route. - Phase 2 — Chat absorbs research (DB v8).
ChatThreadgainssources: SearchSource[](required —sources?:with?? []at every read site is a compat shim),searchProviders?,searchEffort?. v8 rebuildschatThreads. LiftrecorderForinto chat's store (viathreadForId);runnerForgets realbuildTools/buildChatPrompt— both called insidesend(), never from a$derived. NewChatSearchSettings.sveltepopover (providers from the registry, disabled +unconfiguredHintwhen unconfigured; effort; Firecrawl credits chip moves here from the capsule row — it's provider-specific and the capsule is crowded).SourcesSidebar/SourceModal→$lib/chat/components/; new$lib/chat/stores/ui.svelte.ts. Search mode still exists and works — that coexistence is what keeps the repo green mid-way. Shipped — 99 e2e green (4 new research tests in Chat), popover verified on desktop light/dark + iPhone 13. Deviation: v8 does NOT dropsearchThreads— Search still runs off that store until Phase 4, so v8 only rebuildschatThreadsand Phase 4 takes its own bump to drop it. DB versions are cheap; keeping the phases independently shippable is not. - Phase 3 — News → Read. Delete
NewsSection.svelte(its tabs become the Collections rail; its Sync button moves into the two collection headers) andnews/stores/ui.svelte.ts. Read's ui store:ReadCollection = 'ebooks'|'articles'|'files'|'feeds',ReadView = 'collection'|'reader'|'ghost'|'pdf'. Newread/components/collections/{ArticlesView,FeedsView}.svelte, lifted fromNewsSection.svelte:25-79./readabsorbs the ArticleReader takeover and?a=/?url=, merged with?book=into onehandledInitialUrlgate and one mirror effect (precedence: article → book → none). Independent of Phase 2. Shipped — 99 e2e green, collections verified at the real surface.
Three things the merge decided that the plan didn't:
- Explore and Feeds became one collection. Search had them as sibling tabs; "what can I read" is one question. FeedsView = URL bar + (open feed's items | subscriptions + discovery). SubscriptionsPanel no longer flips a tab on open — setting webfeed.feed is the navigation.
- A rail click always lands on the collection's root (pick() clears an open feed preview and any browsed shelf). Otherwise clicking the collection you're already in is a dead click. Re-opening costs one click and feeds come from the device cache.
- An article deep link (?a=/?url=) lands in Articles, so closing it leaves you there rather than on a feed you never came from.
No ?lens= yet — ?view=browse simply died, and Phase 5 introduces the replacement with the lens itself.
- [x] Phase 4 — Delete Search mode (the four-modes release). rm -r routes/search + providers/{session.ts,components,stores/threads,stores/ui}; drop search from AppMode/ModeRoute/MODES. DB v9 drops the searchThreads store + its SearchThread type, accessors, and facade (deferred from v8 — see Phase 2). Delete e2e/search.test.ts + search-live.test.ts (their research value already lives in chat.test.ts). Chat's brochure absorbs the research cards; SiteBrochure auto-updates (it iterates MODES). CLAUDE.md: rewrite the five-modes opening, delete the "News is NOT its own mode" gotcha but keep its reasoning (news belongs inside a reading surface), delete the stale cross-mode-imports bullet, retitle the Search-mode gotchas section. Depends on 2 and 3. Shipped — 92 e2e green (7 Search tests deleted with the mode), four modes confirmed at the real surface in the site brochure, the mode selector, and a genuine 404 on /search.
Beyond the plan: search-live.test.ts → chat-live.test.ts rather than dying (it's the only test that drives real endpoints); the user doc docs/search.md split into docs/chat.md + docs/read.md (both halves were documented, and the docs follow the features); whats-new.md records the reshuffle for users. The v9 deleteObjectStore('searchThreads') needs an IDBPDatabase cast — the store is deliberately absent from PlebChatDB, so the typed API can't name what it's dropping.
- [x] Phase 5 — Identity lens. IdentityLens.svelte (a popover whose body is today's entire ReadRail body verbatim — npub form, prefetched follows, the go() guard that only commits once a shelf loaded) + rail/lens.ts LENS_SUPPORT table. ?lens= replaces ?view=browse. Shipped — 93 e2e green, lens verified at the real surface (desktop dark + iPhone 13 popover).
Two notes for whoever touches this next:
- ?lens= joins the single gate, it doesn't get its own. /read's one handledInitialUrl gate is now ?book → ?a → ?url → ?lens, and the one mirror is article → book → lens → none. A second effect would race the first — the same mount-time-mirror trap that ?book= already documents.
- The lens e2e asserts the lock contract, not the shelf. browse.browseUser sets mode = 'shelf' unconditionally after its fetch, so the lens engages against an empty shelf and the rail's locked/enabled states are fully testable with a relay mock that only answers EOSE. Foreign shelf content (books + shared annotations rendering) needs the whitelisted relay and stays covered by read-mode.md's live two-identity gate. Two attempts to mock real events through cyphertap's pool failed and aren't worth a third: routeWebSocket(/.*/) swallows Vite HMR and kills the dev server, and a silent mock hangs login.
- [x] Phase 6 — Files / PDF (DB v10) — see pdf-files.md for the full account. (v9 went to Phase 4's searchThreads drop.) Kind 30112 + pdfs/pdfFiles/pdfProgress stores + a direct pdfjs-dist viewer (canvas + text layer, IntersectionObserver-lazy), read/import.ts routing both drop zones by extension. Shipped — 99 e2e green (6 new, driving real canvas paint and text-layer content off a hand-written 2-page fixture), verified against the production build on desktop light/dark + iPhone 13.
The one thing worth knowing before touching it: pdf.js needs its standard_fonts data served from our origin, or every PDF using non-embedded Helvetica/Times — which is most of them — renders with broken text. A Vite buildStart hook stages it (and cmaps) out of node_modules; a prebuild script would NOT work, because pnpm doesn't run pre/post scripts by default and it would fail in CI only.
- [ ] Phase 7 (later, optional) — wire 30112 into read/nostr/sync.ts; decide then whether PDFs take 30101's plaintext-when-shared stance and become lens-browsable.
Order: 0 → 1 → 2 → 3 → 4, then 5 ∥ 6. Every phase is independently shippable. Each is one reviewable commit to master, gated on pnpm check + pnpm test:unit + the full Playwright suite.
The identity lens and the encryption boundary¶
RailHeader "Read"
no lens: [👁 Viewing as: you]
lens on: [avatar] Alice [× Exit lens]
Collections
📖 eBooks
📰 Articles 🔒 under lens
📄 Files 🔒 under lens
📡 Feeds 🔒 under lens
Only kind 30101 books and kind 30104 shared annotations are plaintext-when-shared, which is exactly what BrowseView already renders. Everything else in Read is NIP-44 to self:
| Collection | Browsable | Why |
|---|---|---|
| eBooks | yes | 30101 + 30104 are plaintext when shared |
| Articles | no | 30108 is encrypted to its owner's key |
| Feeds | no | 30107 is encrypted to its owner's key |
| Files | no | 30112 is encrypted to its owner's key, and sync isn't wired yet |
Locked rows stay visible and disabled, with a Lock icon and the reason as title plus an inline note in the group footer.
Verification¶
Per-phase gate: pnpm check + pnpm test:unit + pnpm test:e2e. Run the /verify skill (production preview + Playwright screenshots) at Phase 4 (shell, mode selector, both brochures) and Phase 6 (canvas rendering and immersive insets pass headless and look wrong on device).
| Test | Fate |
|---|---|
e2e/search.test.ts (340) |
Fold into e2e/chat.test.ts, don't delete. The research roundtrip (:213 — SSE mock → tool card → sources modal → survives reload) and the credits tests (:182, :200) are among the suite's most valuable. Retarget search-* → chat-*; provider/effort assertions (:159) become the popover. Drop :141 (route gone) and :290 (covered in chat.test.ts). Phase 2. |
e2e/search-live.test.ts (57) |
→ e2e/chat-live.test.ts, same retarget. Phase 2. |
e2e/news.test.ts (598) |
Biggest mechanical edit; keep the file and every mock (the routeWebSocket relay store and Firecrawl routes stay valid). ~15 /search… → /read…; /\/search\?a=/ → /\/read\?a=/; news-tab-{saved,feeds} (:405,411,420) → read-collection-{articles,feeds}; delete :198 (logged-out /search brochure — brochure.test.ts covers the four). Phase 3. |
e2e/read.test.ts (440) |
browse-npub-input/browse-go (:44,45,53) move behind read-lens-trigger; ?view=browse → ?lens=. New: assert the lens contract — under a lens, read-collection-articles is disabled and its reason is visible. Phase 5. |
e2e/shell.test.ts |
:3 "lists all five modes" → four; delete :26. Phase 4. |
e2e/brochure.test.ts |
drop mode-link-search from the :13 loop. Phase 4. |
e2e/files.test.ts |
New, Phase 6. |
Traps that have each already cost this repo a day:
- Seed
localStorage['plebchat-settings']explicitly in every test — now includingsearch.providers/search.effort. Saved settings override the dev.envprefill; tests written against a blank env break silently the day.envis filled. - Kill any dev server on 5173. Playwright's
reuseExistingServergrabs it, and even this project's ownvite devfails the feedback/admin suites (noVITE_FEEDBACK_ADMIN_PUBKEY). - pi-ai's
openai-completionstransport isstream: truealways — mock**/chat/completionswith SSEchat.completion.chunkframes. The pattern travels with the search→chat test fold. runner.toolRunsis runtime-only — keep the "survives reload" assertion; it guards rebuilding tool cards fromtoolCall/toolResultblocks.
Rejected¶
- Restoring foliate's
pdf.js— see pdf-files.md. sources?: SearchSource[]— a compat shim in all but name. Drop the store in v8 instead.- A
/search→/chatredirect — same reasoning. The link dies. - Reusing
books/30101 for PDFs with amimeTypediscriminator — drags PDFs throughdeleteBookCascade, cover-restore, and 30101/30102 sync. - A 30113 "PDF progress" kind — new wire surface for a device-local nicety.
- An effort migration for stale
'quick'/'thorough'— forbidden by the pre-release stance and unnecessary; total lookups handle it. - Unifying saved articles into the library grid as books — coherent long-term, but collides with the CFI/Book-shape constraint and is far larger than this change.
Corrections to the record¶
renderMarkdownalready lives at$lib/ai/markdown.tsandToolCallCardat$lib/conversation/ToolCallCard.svelte. CLAUDE.md's claim that they "happen to live in$lib/search/" is stale — that migration already happened. Delete the bullet in Phase 4.- The real cross-mode importers of
$lib/search/are transports, not UI:news/fetch.ts→firecrawl.js;news/stores/{article,discover}→nip50.js;routes/settings/+page.svelte→credits.svelte.js. That's what the Phase 1 rename touches.
Open questions¶
- Agent presets — named, user-editable
{providers, effort, model, persona}bundles. The seam lands in Phase 1–2; the feature needs its own proposal. - pi skills — the harness supports them; we expose none. Related to presets, probably the same proposal.
- 30112 sync (Phase 7) — do PDFs take 30101's plaintext-when-shared stance? That would make Files lens-browsable and change
LENS_SUPPORT.files. - A third mode syncing would finally justify moving
AddressableDraft/dedupeByAddress/tombstoneDraftout of$lib/read/nostr/events.tsinto a neutral home. News already borrows them.