# 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.