TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1# G — Search, command palette, share & export23Workstream G of the 2026-09-11 upgrade (`docs/UPGRADE-PLAN.md`). Everything below compiles (`pnpm typecheck`),4lints (`eslint` on the files listed) and is covered by vitest (`tests/unit/search-query.test.ts`,5`tests/unit/export-html.test.ts`, `tests/integration/search-share.test.ts` — the last one runs against the local6Postgres when `DATABASE_URL` is reachable).78## Files910| Area | File | What |11| --- | --- | --- |12| Query language | `src/lib/search/query.ts` | Pure `parseQuery()`, `addFilter/removeFilter/stripFilters`, `highlightSegments`, `makeSnippet`, `stripMarkdown`, `prefixTsQuery`, `escapeLike`, `parseDateToken` (unit-tested) |13| 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 |14| API | `src/app/api/search/route.ts` | `GET /api/search` (rewritten, same URL as before, superset response) |15| 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`.** |16| 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` |17| 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) |18| 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` |19| New routes | `src/app/api/conversations/[id]/export/route.ts`, `src/app/api/shares/route.ts` | See API section |20| Palette | `src/components/app/command-palette.tsx` | Universal ⌘K palette (root commands + sub-lists + search mode), `ShortcutsSheet`, mounts `SearchSheet` and `ShareSheetHost` |21| 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 />` |22| Share UI | `src/components/share/share-sheet.tsx` | `ShareSheet`, `useShareSheet()`, `openShareSheet()`, `ShareSheetHost`, `ShareLinksList` |23| 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` |24| Share page | `src/app/share/[id]/page.tsx` | Mobile-first redesign, excerpt badge, view count, `noindex`, per-share OG/Twitter title |25| Types (append-only) | `src/lib/client/types.ts` | Re-exports `SearchResponse`, hit types, `ShareLinkItem`, `ExportFormat`, `ParsedQuery`, `FilterToken`, `FilterKey` |26| Tests | `tests/unit/search-query.test.ts`, `tests/unit/export-html.test.ts`, `tests/integration/search-share.test.ts` | 18 + 11 unit, 6 integration |2728`src/lib/export/*` is a new directory not claimed by any workstream (used only by the conversations service).2930## Query language3132```33free text "exact phrase" model:claude provider:openai project:research project:"Q3 Research"34folder:clients after:2026-08-01 after:2026-08 after:7d after:today after:yesterday after:month35before:2026-09-01 role:user|assistant is:pinned is:archived is:shared36```3738- `model:` substring of `modelKey` **or** of a registry display name (resolved server-side); `provider:` accepts ids and39 aliases (`google`→gemini, `claude`→anthropic, `grok`→xai…); `project:`/`folder:` match id or name substring; a filter40 that matches nothing returns an empty result (not "everything").41- `after:` is inclusive (UTC midnight), `before:` exclusive; `after:2026-08` = whole month; relative `Nd|Nw|Nm|Ny`.42- `role:` applies to messages and hides the Conversations group. `is:archived` is the only way to see archived chats?43 No — archived conversations are searchable by default; `is:archived` restricts to them.44- Unknown `key:value` tokens (URLs, `10:30`) and invalid values (`role:pirate`) are kept as free text.45- Words ≥ 2 chars **or** any filter make a query searchable. `websearch_to_tsquery('simple')` handles phrases and `-neg`;46 a prefix query (`'quot':*`) is OR-ed so the last half-typed word matches; if FTS returns nothing on the first page an47 ILIKE fallback (all words AND-ed) catches fragments inside words (`configur` → "Reconfiguring").4849## API5051### `GET /api/search?q=&limit=12&cursor=&groups=`52Auth: user. Rate limit `LIMITS.search`. `groups` = comma list of `conversations,messages,models,prompts,projects,presets`53(default all; secondary groups only on the first page).5455```ts56interface SearchResponse {57 query: { raw; text; terms: string[]; tokens: FilterToken[]; hasFilters };58 conversations: { id; title; modelKey; provider; projectId; folderId; pinned; archived; shared; messageCount; updatedAt; lastMessageAt }[];59 messages: { id; conversationId; title; role; modelKey; createdAt; snippet }[]; // snippet = plain text, highlight with query.terms60 models: { key; displayName; provider; status }[];61 prompts: { id; name; description; kind: "library" | "legacy" }[]; // library = prompts table (D), legacy = prompt_presets62 projects: { id; name; description; icon; color }[];63 presets: { id; name; description; modelKey }[];64 nextCursor: string | null; // opaque; pass back as ?cursor= (pages conversations + messages only)65 engine: "fts" | "ilike" | "none";66 tookMs: number;67}68```69Prompts are read from the `prompts` table directly (no internal HTTP to `/api/prompts`), so nothing 404s if D is not wired.7071### `POST /api/conversations/[id]/actions` (existing, extended)72- `{ action: "share", messageIds?: string[] }` → `{ id, path: "/share/<id>", partial, messageCount, created }`.73 Without `messageIds` the conversation's single "entire conversation" link is refreshed in place (same URL); with74 `messageIds` a **new** excerpt link is created (active messages only, conversation order).75- `{ action: "unshare", shareId?: string }` → `{ ok }` (all links of the conversation, or one).76- `{ action: "share-status" }` → `{ share: { id, createdAt, viewCount } | null, shares: ShareLinkItem[] }`.77- `{ action: "list-shares" }` → `{ shares: ShareLinkItem[] }` (this conversation).78- `{ action: "export", format: "json" | "markdown" | "txt" | "html" }` → file (kept for the sidebar).7980### `GET /api/conversations/[id]/export?format=json|markdown|txt|html[&download=1][&print=1]`81File download (`Content-Disposition: attachment`) for everything except `html` without `download=1`, which renders82inline; `print=1` embeds a `window.print()` on load (used by "PDF (print)"). `Cache-Control: private, no-store`,83`X-Robots-Tag: noindex`. Rate limit 40/min.8485### `GET /api/shares` → `{ shares: ShareLinkItem[] }` · `DELETE /api/shares?id=<shareId>` → `{ ok }`86```ts87interface ShareLinkItem { id; conversationId; title; createdAt; viewCount; messageCount; partial; path: "/share/<id>" }88```8990### Share snapshot v291`shared_conversations.snapshot` now starts with `{ $meta: true, version: 2, partial, selectedCount, totalCount, generatedAt }`92followed by the messages. Renderers skip elements without `role`; `readShareMeta(snapshot)` reads it. Old snapshots93(no meta) keep working. `getPublicShare(id, { peek: true })` reads without counting a view (used by `generateMetadata`).9495## Contracts for other workstreams9697### A — chat header / composer98- **Share button** → `const share = useShareSheet(); share.open({ conversationId, title, messages })` (from99 `@/components/share/share-sheet`). `messages` (the active `PublicMessage[]` already in memory) avoids a refetch;100 `messageIds` preselects "Selected messages" (use it from the message long-press "Share from here…"). The host is101 already mounted by `CommandPalette` — nothing else to mount.102- **Export** → `<ExportMenu conversationId={id} title={title} />` from `@/components/share/export-menu` (icon button by103 default; pass your own trigger as children). Programmatic: `exportConversation(id, "pdf")` from `@/components/share/export`.104- **Events the palette dispatches on `window`:**105 - `polyllm:open-attach` (CustomEvent, no detail) — "Upload file" command. The composer should open its attachment sheet106 (phone) or the file picker (desktop). When the user is not on a chat page the palette navigates to `/app/chat` first107 and dispatches ~450 ms later.108 - `polyllm:switch-model` (`detail: { modelKey: string }`, may be `AUTO_MODEL_KEY`) — "Switch model" and model search hits.109 The store's `selectedModelKey` is already set; the chat view should apply it to the **current** conversation110 (`changeModel(detail.modelKey)`) if one is open.111- Deep links `/app/chat/<id>#<messageId>`: `chat-view.tsx` already scrolls to `#hash` on mount; when the same conversation112 is open the search UI calls `scrollIntoView` itself. Keep `id={m.id}` on message wrappers.113114### E — Settings → Data115Mount `<ShareLinksList />` from `@/components/share/share-sheet` in a card titled "Active share links" (it renders its116own empty state, copy/revoke actions and a `ConfirmDialog`). It reads `GET /api/shares`.117118### D — prompts / projects119Search links library prompts to `/app/chat?promptId=<id>` (your insertion contract) and legacy presets to120`/app/prompts?edit=<id>`; projects to `/app/projects/<id>`. The palette's "Open project" reads `GET /api/projects`121(tolerates errors) and sets `activeProjectId` before navigating. Filter chips read project names from the same route.122123### Shell (integrator)124"Toggle sidebar" on desktop dispatches a synthetic `⌘B` keydown because the collapsed state lives in `shell.tsx`125(`// TODO(integration: shell)`): exposing `toggleSidebarCollapsed` in the store would make it explicit. On phones the126command opens the drawer (`setSidebarOpen(true)`). `store.searchOpen` on ≥ md is consumed by the palette (opens in127search mode and resets the flag) so `setSearchOpen(true)` works from anywhere on any breakpoint.128129## Palette behaviour (desktop keyboard-first, phone bottom sheet)130- Root: typing filters commands by label/keywords; **≥ 2 characters also shows live search results** under the131 commands; a leading `/` is pure search mode. `⌫` on an empty input returns to the root, `Esc` closes.132- Commands: New chat, New temporary chat (`/app/chat?temporary=1`), Search conversations, Switch model (AUTO + connected133 models, labels, favorites first, current ✓), Upload file, Open project, Go to Chat / Arena / Models / Usage / Providers /134 Projects / Library / Prompts / Model presets / Settings, Toggle theme (light/dark/system ✓), Toggle/Open sidebar,135 Keyboard shortcuts (sheet). On a chat page: Copy conversation URL, Share conversation, Export conversation (sub-list).136- Recent searches (localStorage `polyllm:recent-searches`, 8 max) and syntax examples appear in empty search mode.137138## Migration notes139- `drizzle/0003_search_index.sql` is hand-written (expression indexes cannot be declared from `schema-search.ts` on140 tables defined in `schema.ts`); numbering follows E's `0002_endpoints`. No snapshot file is needed (the indexes are141 not part of the drizzle schema, so `drizzle-kit generate` will neither re-create nor drop them; `drizzle-kit push` is142 not used). Index expressions **must** stay identical to `msgTsv`/`titleTsv` in `src/lib/search/service.ts`.143- Plain `CREATE INDEX IF NOT EXISTS` (the migrator runs in a transaction; `CONCURRENTLY` is not possible). On prod the144 `messages` table is small; expect sub-second creation via the mld hook.145- Verified with `EXPLAIN`: Bitmap Index Scan on `messages_content_fts_idx` for both the websearch and the prefix query.146147## Not done / Coming soon148- Nothing is labelled "Coming soon". Desktop filter chips are shown inside the palette's search mode (compact); the149 pickers are the same sheets as on phones.150- Tags (`tag:`) are not a filter — `conversation_tags` has no UI yet.151152## QA checklist (integration phase, 375/390/393/430 + 1440)1531. Phone: sidebar search icon → full-screen sheet, keyboard opens, 16 px input, no zoom; chips scroll horizontally;154 Model/Project/Date/Role/Status pickers open as half sheets and append tokens; token chips remove on ×.1552. Type `quotas` (or any word from a real chat): grouped results, `<mark>` highlights, tap a message → chat opens and156 scrolls to the message; recent search saved; "Load more" appears when > 10 message hits.1573. Desktop ⌘K: ↑↓↵ across commands and results, `/` search mode, `⌫` back, Esc close; palette sits at 14 % from the top,158 max 72 vh; footer hints. Hybrid: typing "arena" shows the command **and** matching chats.1594. Switch model from the palette on an open chat → chat header model changes (needs A's `polyllm:switch-model` listener).160 Upload file → composer attach opens (needs A's `polyllm:open-attach` listener).1615. Share sheet (chat header / palette): warning visible, Entire vs Selected (checkbox list with role + snippet, All/None),162 Create → link copied + toast, Copy/Open/Share… (native share on phones), Active links list, revoke via ConfirmDialog.163 Sharing the whole conversation twice keeps the same URL; a selection creates a second link.1646. `/share/<id>`: renders on 375 px without horizontal overflow, excerpt badge for partial shares, view count increments165 once per visit, `<meta name="robots" content="noindex">`, OG title = conversation title; revoked link → 404.1667. Export: Markdown/TXT/JSON/HTML download with the right filename; "PDF (print)" opens a new tab with the print dialog167 (Safari/Chrome/iOS Safari); blocked pop-up → HTML download + warning toast.1688. Settings → Data: `ShareLinksList` rows stack correctly on phones, revoke works, empty state text.1699. Old sidebar actions (Copy share link, Export Markdown/JSON) still work through the actions route.170