G — Search, command palette, share & export
Workstream G of the 2026-09-11 upgrade (docs/UPGRADE-PLAN.md). Everything below compiles (pnpm typecheck),
lints (eslint on the files listed) and is covered by vitest (tests/unit/search-query.test.ts,
tests/unit/export-html.test.ts, tests/integration/search-share.test.ts — the last one runs against the local
Postgres when DATABASE_URL is reachable).
Files
| Area | File | What |
|---|---|---|
| Query language | src/lib/search/query.ts |
Pure parseQuery(), addFilter/removeFilter/stripFilters, highlightSegments, makeSnippet, stripMarkdown, prefixTsQuery, escapeLike, parseDateToken (unit-tested) |
| Search service | src/lib/search/service.ts |
searchAll(userId, raw, { limit, cursor, groups }) — Postgres FTS (to_tsvector('simple') + websearch_to_tsquery + prefix to_tsquery) with ILIKE fallback, filter resolution, opaque cursor pagination, grouped results |
| API | src/app/api/search/route.ts |
GET /api/search (rewritten, same URL as before, superset response) |
| Migration | drizzle/0003_search_index.sql + drizzle/meta/_journal.json (idx 3) |
GIN expression indexes on messages.content and conversations.title, partial index on active shares. Applied locally with pnpm db:migrate. |
| Conversations service (additive) | src/lib/conversations/service.ts |
exportConversation(format: json|markdown|txt|html, { print, appUrl }), shareConversation(userId, id, { messageIds }), listShares, revokeShareById, readShareMeta, getPublicShare(id, { peek }), EXPORT_FORMATS, types ExportFormat, ShareLinkItem, ShareSnapshotMeta |
| Export renderer | src/lib/export/markdown-html.ts, src/lib/export/html-document.ts |
Dependency-free, escaped Markdown → HTML (GFM subset) and the self-contained print document (inline CSS, brand mark, page-break rules) |
| Actions route (additive) | src/app/api/conversations/[id]/actions/route.ts |
export accepts the 4 formats; share accepts { messageIds? }; new list-shares; unshare accepts { shareId? }; share-status also returns shares |
| New routes | src/app/api/conversations/[id]/export/route.ts, src/app/api/shares/route.ts |
See API section |
| Palette | src/components/app/command-palette.tsx |
Universal ⌘K palette (root commands + sub-lists + search mode), ShortcutsSheet, mounts SearchSheet and ShareSheetHost |
| Search UI | src/components/search/search-sheet.tsx, use-search.ts, hits.tsx, filter-chips.tsx, highlight.tsx |
Phone full-screen sheet, SWR hook with pagination + recent searches, hit rows + navigation, filter chips with picker sheets, <Highlight /> |
| Share UI | src/components/share/share-sheet.tsx |
ShareSheet, useShareSheet(), openShareSheet(), ShareSheetHost, ShareLinksList |
| Export UI | src/components/share/export.ts, src/components/share/export-menu.tsx |
exportConversation(id, format) client helper, EXPORT_OPTIONS, ExportMenu (dropdown / ActionSheet), EXPORT_ICONS |
| Share page | src/app/share/[id]/page.tsx |
Mobile-first redesign, excerpt badge, view count, noindex, per-share OG/Twitter title |
| Types (append-only) | src/lib/client/types.ts |
Re-exports SearchResponse, hit types, ShareLinkItem, ExportFormat, ParsedQuery, FilterToken, FilterKey |
| Tests | tests/unit/search-query.test.ts, tests/unit/export-html.test.ts, tests/integration/search-share.test.ts |
18 + 11 unit, 6 integration |
src/lib/export/* is a new directory not claimed by any workstream (used only by the conversations service).
Query language
free text "exact phrase" model:claude provider:openai project:research project:"Q3 Research"
folder:clients after:2026-08-01 after:2026-08 after:7d after:today after:yesterday after:month
before:2026-09-01 role:user|assistant is:pinned is:archived is:sharedmodel:substring ofmodelKeyor of a registry display name (resolved server-side);provider:accepts ids and aliases (google→gemini,claude→anthropic,grok→xai…);project:/folder:match id or name substring; a filter that matches nothing returns an empty result (not "everything").after:is inclusive (UTC midnight),before:exclusive;after:2026-08= whole month; relativeNd|Nw|Nm|Ny.role:applies to messages and hides the Conversations group.is:archivedis the only way to see archived chats? No — archived conversations are searchable by default;is:archivedrestricts to them.- Unknown
key:valuetokens (URLs,10:30) and invalid values (role:pirate) are kept as free text. - Words ≥ 2 chars or any filter make a query searchable.
websearch_to_tsquery('simple')handles phrases and-neg; a prefix query ('quot':*) is OR-ed so the last half-typed word matches; if FTS returns nothing on the first page an ILIKE fallback (all words AND-ed) catches fragments inside words (configur→ "Reconfiguring").
API
GET /api/search?q=&limit=12&cursor=&groups=
Auth: user. Rate limit LIMITS.search. groups = comma list of conversations,messages,models,prompts,projects,presets
(default all; secondary groups only on the first page).
interface SearchResponse {
query: { raw; text; terms: string[]; tokens: FilterToken[]; hasFilters };
conversations: { id; title; modelKey; provider; projectId; folderId; pinned; archived; shared; messageCount; updatedAt; lastMessageAt }[];
messages: { id; conversationId; title; role; modelKey; createdAt; snippet }[]; // snippet = plain text, highlight with query.terms
models: { key; displayName; provider; status }[];
prompts: { id; name; description; kind: "library" | "legacy" }[]; // library = prompts table (D), legacy = prompt_presets
projects: { id; name; description; icon; color }[];
presets: { id; name; description; modelKey }[];
nextCursor: string | null; // opaque; pass back as ?cursor= (pages conversations + messages only)
engine: "fts" | "ilike" | "none";
tookMs: number;
}Prompts are read from the prompts table directly (no internal HTTP to /api/prompts), so nothing 404s if D is not wired.
POST /api/conversations/[id]/actions (existing, extended)
{ action: "share", messageIds?: string[] }→{ id, path: "/share/<id>", partial, messageCount, created }. WithoutmessageIdsthe conversation's single "entire conversation" link is refreshed in place (same URL); withmessageIdsa new excerpt link is created (active messages only, conversation order).{ action: "unshare", shareId?: string }→{ ok }(all links of the conversation, or one).{ action: "share-status" }→{ share: { id, createdAt, viewCount } | null, shares: ShareLinkItem[] }.{ action: "list-shares" }→{ shares: ShareLinkItem[] }(this conversation).{ action: "export", format: "json" | "markdown" | "txt" | "html" }→ file (kept for the sidebar).
GET /api/conversations/[id]/export?format=json|markdown|txt|html[&download=1][&print=1]
File download (Content-Disposition: attachment) for everything except html without download=1, which renders
inline; print=1 embeds a window.print() on load (used by "PDF (print)"). Cache-Control: private, no-store,
X-Robots-Tag: noindex. Rate limit 40/min.
GET /api/shares → { shares: ShareLinkItem[] } · DELETE /api/shares?id=<shareId> → { ok }
interface ShareLinkItem { id; conversationId; title; createdAt; viewCount; messageCount; partial; path: "/share/<id>" }Share snapshot v2
shared_conversations.snapshot now starts with { $meta: true, version: 2, partial, selectedCount, totalCount, generatedAt }
followed by the messages. Renderers skip elements without role; readShareMeta(snapshot) reads it. Old snapshots
(no meta) keep working. getPublicShare(id, { peek: true }) reads without counting a view (used by generateMetadata).
Contracts for other workstreams
A — chat header / composer
- Share button →
const share = useShareSheet(); share.open({ conversationId, title, messages })(from@/components/share/share-sheet).messages(the activePublicMessage[]already in memory) avoids a refetch;messageIdspreselects "Selected messages" (use it from the message long-press "Share from here…"). The host is already mounted byCommandPalette— nothing else to mount. - Export →
<ExportMenu conversationId={id} title={title} />from@/components/share/export-menu(icon button by default; pass your own trigger as children). Programmatic:exportConversation(id, "pdf")from@/components/share/export. - Events the palette dispatches on
window:polyllm:open-attach(CustomEvent, no detail) — "Upload file" command. The composer should open its attachment sheet (phone) or the file picker (desktop). When the user is not on a chat page the palette navigates to/app/chatfirst and dispatches ~450 ms later.polyllm:switch-model(detail: { modelKey: string }, may beAUTO_MODEL_KEY) — "Switch model" and model search hits. The store'sselectedModelKeyis already set; the chat view should apply it to the current conversation (changeModel(detail.modelKey)) if one is open.
- Deep links
/app/chat/<id>#<messageId>:chat-view.tsxalready scrolls to#hashon mount; when the same conversation is open the search UI callsscrollIntoViewitself. Keepid={m.id}on message wrappers.
E — Settings → Data
Mount <ShareLinksList /> from @/components/share/share-sheet in a card titled "Active share links" (it renders its
own empty state, copy/revoke actions and a ConfirmDialog). It reads GET /api/shares.
D — prompts / projects
Search links library prompts to /app/chat?promptId=<id> (your insertion contract) and legacy presets to
/app/prompts?edit=<id>; projects to /app/projects/<id>. The palette's "Open project" reads GET /api/projects
(tolerates errors) and sets activeProjectId before navigating. Filter chips read project names from the same route.
Shell (integrator)
"Toggle sidebar" on desktop dispatches a synthetic ⌘B keydown because the collapsed state lives in shell.tsx
(// TODO(integration: shell)): exposing toggleSidebarCollapsed in the store would make it explicit. On phones the
command opens the drawer (setSidebarOpen(true)). store.searchOpen on ≥ md is consumed by the palette (opens in
search mode and resets the flag) so setSearchOpen(true) works from anywhere on any breakpoint.
Palette behaviour (desktop keyboard-first, phone bottom sheet)
- Root: typing filters commands by label/keywords; ≥ 2 characters also shows live search results under the
commands; a leading
/is pure search mode.⌫on an empty input returns to the root,Esccloses. - Commands: New chat, New temporary chat (
/app/chat?temporary=1), Search conversations, Switch model (AUTO + connected models, labels, favorites first, current ✓), Upload file, Open project, Go to Chat / Arena / Models / Usage / Providers / Projects / Library / Prompts / Model presets / Settings, Toggle theme (light/dark/system ✓), Toggle/Open sidebar, Keyboard shortcuts (sheet). On a chat page: Copy conversation URL, Share conversation, Export conversation (sub-list). - Recent searches (localStorage
polyllm:recent-searches, 8 max) and syntax examples appear in empty search mode.
Migration notes
drizzle/0003_search_index.sqlis hand-written (expression indexes cannot be declared fromschema-search.tson tables defined inschema.ts); numbering follows E's0002_endpoints. No snapshot file is needed (the indexes are not part of the drizzle schema, sodrizzle-kit generatewill neither re-create nor drop them;drizzle-kit pushis not used). Index expressions must stay identical tomsgTsv/titleTsvinsrc/lib/search/service.ts.- Plain
CREATE INDEX IF NOT EXISTS(the migrator runs in a transaction;CONCURRENTLYis not possible). On prod themessagestable is small; expect sub-second creation via the mld hook. - Verified with
EXPLAIN: Bitmap Index Scan onmessages_content_fts_idxfor both the websearch and the prefix query.
Not done / Coming soon
- Nothing is labelled "Coming soon". Desktop filter chips are shown inside the palette's search mode (compact); the pickers are the same sheets as on phones.
- Tags (
tag:) are not a filter —conversation_tagshas no UI yet.
QA checklist (integration phase, 375/390/393/430 + 1440)
- Phone: sidebar search icon → full-screen sheet, keyboard opens, 16 px input, no zoom; chips scroll horizontally; Model/Project/Date/Role/Status pickers open as half sheets and append tokens; token chips remove on ×.
- Type
quotas(or any word from a real chat): grouped results,<mark>highlights, tap a message → chat opens and scrolls to the message; recent search saved; "Load more" appears when > 10 message hits. - Desktop ⌘K: ↑↓↵ across commands and results,
/search mode,⌫back, Esc close; palette sits at 14 % from the top, max 72 vh; footer hints. Hybrid: typing "arena" shows the command and matching chats. - Switch model from the palette on an open chat → chat header model changes (needs A's
polyllm:switch-modellistener). Upload file → composer attach opens (needs A'spolyllm:open-attachlistener). - Share sheet (chat header / palette): warning visible, Entire vs Selected (checkbox list with role + snippet, All/None), Create → link copied + toast, Copy/Open/Share… (native share on phones), Active links list, revoke via ConfirmDialog. Sharing the whole conversation twice keeps the same URL; a selection creates a second link.
/share/<id>: renders on 375 px without horizontal overflow, excerpt badge for partial shares, view count increments once per visit,<meta name="robots" content="noindex">, OG title = conversation title; revoked link → 404.- Export: Markdown/TXT/JSON/HTML download with the right filename; "PDF (print)" opens a new tab with the print dialog (Safari/Chrome/iOS Safari); blocked pop-up → HTML download + warning toast.
- Settings → Data:
ShareLinksListrows stack correctly on phones, revoke works, empty state text. - Old sidebar actions (Copy share link, Export Markdown/JSON) still work through the actions route.