# 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, `` | | 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:shared ``` - `model:` substring of `modelKey` **or** 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; relative `Nd|Nw|Nm|Ny`. - `role:` applies to messages and hides the Conversations group. `is:archived` is the only way to see archived chats? No — archived conversations are searchable by default; `is:archived` restricts to them. - Unknown `key:value` tokens (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). ```ts 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/", partial, messageCount, created }`. Without `messageIds` the conversation's single "entire conversation" link is refreshed in place (same URL); with `messageIds` a **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=` → `{ ok }` ```ts interface ShareLinkItem { id; conversationId; title; createdAt; viewCount; messageCount; partial; path: "/share/" } ``` ### 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 active `PublicMessage[]` already in memory) avoids a refetch; `messageIds` preselects "Selected messages" (use it from the message long-press "Share from here…"). The host is already mounted by `CommandPalette` — nothing else to mount. - **Export** → `` 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/chat` first and dispatches ~450 ms later. - `polyllm:switch-model` (`detail: { modelKey: string }`, may be `AUTO_MODEL_KEY`) — "Switch model" and model search hits. The store's `selectedModelKey` is already set; the chat view should apply it to the **current** conversation (`changeModel(detail.modelKey)`) if one is open. - Deep links `/app/chat/#`: `chat-view.tsx` already scrolls to `#hash` on mount; when the same conversation is open the search UI calls `scrollIntoView` itself. Keep `id={m.id}` on message wrappers. ### E — Settings → Data Mount `` 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=` (your insertion contract) and legacy presets to `/app/prompts?edit=`; projects to `/app/projects/`. 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, `Esc` closes. - 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.sql` is hand-written (expression indexes cannot be declared from `schema-search.ts` on tables defined in `schema.ts`); numbering follows E's `0002_endpoints`. No snapshot file is needed (the indexes are not part of the drizzle schema, so `drizzle-kit generate` will neither re-create nor drop them; `drizzle-kit push` is not used). Index expressions **must** stay identical to `msgTsv`/`titleTsv` in `src/lib/search/service.ts`. - Plain `CREATE INDEX IF NOT EXISTS` (the migrator runs in a transaction; `CONCURRENTLY` is not possible). On prod the `messages` table is small; expect sub-second creation via the mld hook. - Verified with `EXPLAIN`: Bitmap Index Scan on `messages_content_fts_idx` for 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_tags` has no UI yet. ## QA checklist (integration phase, 375/390/393/430 + 1440) 1. 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 ×. 2. Type `quotas` (or any word from a real chat): grouped results, `` highlights, tap a message → chat opens and scrolls to the message; recent search saved; "Load more" appears when > 10 message hits. 3. 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. 4. Switch model from the palette on an open chat → chat header model changes (needs A's `polyllm:switch-model` listener). Upload file → composer attach opens (needs A's `polyllm:open-attach` listener). 5. 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. 6. `/share/`: renders on 375 px without horizontal overflow, excerpt badge for partial shares, view count increments once per visit, ``, OG title = conversation title; revoked link → 404. 7. 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. 8. Settings → Data: `ShareLinksList` rows stack correctly on phones, revoke works, empty state text. 9. Old sidebar actions (Copy share link, Export Markdown/JSON) still work through the actions route.