PolyLLM 1.0 — "AI operating system" upgrade (2026-09-11)
Massive product/UX/UI/mobile-first upgrade of www.polyllm.io. Goal: a premium, app-like multi-model workspace ("One interface. Every model.") that feels native on iPhone/Android and excellent on desktop, without breaking any existing backend behaviour (auth, encrypted keys, chat history, Arena, presets, usage, exports, folders, attachments).
This document is the shared contract between the parallel workstreams. Read it fully before touching code.
Ground rules (all workstreams)
- Do not break existing functionality. Reuse services in
src/lib/*; extend, don't rewrite. Provider quirks stay insrc/lib/ai/providers/*; components only seePolyModelcapabilities (seeCLAUDE.md). - Ownership. Edit only the files/dirs listed for your workstream (plus new files you create there). If you need a change
in someone else's file, add a small adapter in your own area and leave a
// TODO(integration: <area>)comment, or put the request indocs/upgrade-notes/<your-area>.md. Exception:src/lib/client/types.ts— append-only. - Schema. Tables for the upgrade live in
src/db/schema-workspace.ts(already migrated:drizzle/0001_workspace_upgrade.sql). If you need more columns/tables: append toschema-workspace.ts(or createsrc/db/schema-<area>.ts, re-export it insrc/db/index.ts), then runpnpm db:generate --name <area>andpnpm db:migratelocally. Never edit existing migration files. Never drop/rename columns. - No dev server in this phase. Multiple
next devin the same tree conflict on.next. Validate withpnpm typecheck,pnpm lint,pnpm test(vitest) and, for server logic, small vitest units intests/unit. Visual QA happens in the integration phase against a single dev server. - No fake functionality. Every button works or is explicitly labelled "Coming soon" (use
<ComingSoon />badge fromcomponents/ui/misc— add it if missing). No placeholder handlers. - Mobile first. Design for 375 / 390 / 393 / 430 px widths first, then tablet, then desktop. Rules:
- Phones: bottom sheets instead of centered modals (
ResponsiveDialog,BottomSheet,ActionSheetincomponents/ui/sheet.tsx), long-press for message/row actions (useLongPress), swipe carousels (.snap-row+useSnapCarousel), 44 px touch targets (.taphelper or explicit min-h), 16 px inputs,100dvh/.h-app, safe areas (pb-safe,pt-safe,--sab). - Never
window.prompt/confirm/alert— usePromptDialog/ConfirmDialog(components/common). - No desktop tables overflowing on phones: use stacked rows/cards below
md, tables frommdup. - No horizontal overflow, no clipped text, no fixed element blocking content. Respect
prefers-reduced-motion. - Hover-only controls must also be reachable on touch (
.hover-revealshows them on coarse pointers).
- Phones: bottom sheets instead of centered modals (
- Design language (
src/app/globals.csstokens): neutral warm graphite palette, iris accent, fewer borders, spatial grouping (.panel, hairlines) over boxed cards, restrained 150–300 ms motion,text-balanceheadlines, tabular numbers for metrics. UseSegmented/ChipRow(components/ui/segmented.tsx) for filters. Icons: lucide only. - Performance. No new heavy dependencies without a note in your area doc (allowed if < ~40 KB gz and clearly justified —
e.g.
katexfor LaTeX). Virtualize long lists (@tanstack/react-virtualis installed). Memoize streaming renders. - Accessibility. Labels/aria on icon buttons, focus-visible, keyboard nav for lists/palettes, semantic elements.
- Commit nothing. The integrator commits. Leave the tree compiling (
pnpm typecheck && pnpm lintgreen) when you finish. - Final report. End with: files created/changed, new API routes (method, path, body → response), anything left as
"Coming soon", and QA notes (what to check visually) in
docs/upgrade-notes/<area>.md.
Shared primitives (already implemented — use them)
| What | Where | Notes |
|---|---|---|
| Tokens, utilities | src/app/globals.css |
.h-app, .pb-nav, .pb-safe, .tap, .hover-reveal, .snap-row, .panel, .glass-strong, .mask-x, .marquee, coarse:/fine: variants |
| Hooks | src/lib/client/hooks.ts |
useIsMobile, useIsTablet, useIsCoarsePointer, useMounted, useKeyboardInset, useLongPress, useSwipe, useLocalStorage, useDebounced, useCopy, useSnapCarousel, useStandalone |
| Sheets | src/components/ui/sheet.tsx |
BottomSheet (handle, snap `content |
| Segmented / chips | src/components/ui/segmented.tsx |
Segmented, ChipRow |
| Dialog replacements | src/components/common/prompt-dialog.tsx, confirm-dialog.tsx |
|
| Shell | src/components/app/shell.tsx, bottom-nav.tsx, sidebar.tsx, store.tsx |
Mobile bottom nav (Chat/Arena/Models/Usage/Account), drawer with edge-swipe, keyboard inset. Store adds labels, setLabel, activeProjectId, searchOpen, AUTO_MODEL_KEY. |
| Token/cost estimator | src/lib/client/tokens.ts |
estimateTextTokens, estimateAttachmentTokens, estimateContext, estimateCost, formatEstimate, COST_CONFIRM_THRESHOLD_USD |
| Smart router | src/lib/client/router.ts |
analyzePrompt, routeModels, explainRoute, ROUTER_MODES (pure, transparent) |
| Brand | src/components/brand/logo.tsx, public/icon.svg, public/og.png, scripts/brand-assets.sh, src/app/manifest.ts |
"Prism P" mark; Wordmark |
| Schema | src/db/schema-workspace.ts |
projects, project_files, prompts, user_model_labels, custom_endpoints, arena_votes, shared_arena_sessions; conversations.project_id |
| Cross-area stubs | components/projects/sidebar-section.tsx, components/library/file-library-picker.tsx |
Baselines; the Projects workstream owns/finishes them. Chat imports FileLibraryPicker from that path. |
Workstreams & ownership
A — Chat & composer (components/chat/* except model-selector.tsx, model-config.tsx, model-badges.tsx; components/markdown/*; app/app/chat/*; lib/chat/*; app/api/chat/*; app/api/attachments/*)
Mobile composer (+ sheet: upload image/document, camera, paste, from library, tools, web search, structured output, system
prompt, temporary chat), auto-grow 1→6 lines, voice dictation (Web Speech API where available, hidden otherwise), active model
pill above composer (opens the model picker via ModelSelector — keep its public props), attachments (drag/drop, paste,
camera, chips, multiple), context indicator (43K / 200K, warnings at 80/95 %), cost estimate + confirm above
COST_CONFIRM_THRESHOLD_USD with one-tap model alternatives, Smart Router AUTO mode (selectedModelKey === AUTO_MODEL_KEY →
recommendation card before sending: Use / Choose another / Always auto-route), temporary chat (server: ephemeral: true in
chatRequestSchema → stream without persisting; badge "Not stored in history"), message toolbar (desktop hover / mobile
long-press ActionSheet: copy, retry, edit, branch, compare, change model from here, save as prompt, export, rate, more),
expandable message metadata (model, provider, tokens, cost, TTFT, tok/s, reasoning level), deprecated-model warning with
replacement suggestion, humanized provider errors with Retry / Switch model / Details, streaming performance (memoized
blocks, stable markdown, code block Copy/Download/Wrap/Expand, LaTeX via katex + rehype-katex allowed), inline
Compare with… (2–4 models; desktop side-by-side, mobile swipeable panels with sticky tabs; reuse /api/arena +
/api/arena/stream to run, store as Arena session, "Continue with this model"), empty state with quick actions
(Write / Code / Research / Analyze a file / Compare models), project instructions applied when activeProjectId is set
(fetch /api/projects/:id — Projects workstream provides it; degrade gracefully if 404).
B — Models (components/chat/model-selector.tsx, model-config.tsx, model-badges.tsx; app/app/models/*; new app/(marketing)/models/*, app/(marketing)/compare/*; components/models/*; lib/ai/registry/* (additive); app/api/models/* (additive); components/presets/*, app/app/presets/*; lib/models/*)
Model picker redesign (bottom sheet on phones; desktop dialog), fast search with intent parsing ("cheap vision model",
"1M context", "under $1/M", "fastest gemini", "json schema"), sections (Favorites, Recent, Recommended, Fast, Best
reasoning, Cheapest, Largest context, Vision, Coding, Open source, New), compact metadata rows, star favorites, custom labels
(store.setLabel), AUTO entry (Smart Router) with mode selector, model badges (NEW/FAST/CHEAP/REASONING/VISION/CODING/LONG
CONTEXT), lifecycle badges (New/Active/Deprecated/Retiring/Unavailable), model profile sheet (all fields; show "—" for
unknown — never invent release dates or cutoffs), /app/models catalog (sortable, filters, mobile stacked rows, select →
Compare), public /models and /compare/[slug] (gpt-5.5-vs-claude-sonnet-5) pages with SEO metadata reading the registry
server-side (no auth), capability-aware settings grouped Generation / Reasoning / Output / Tools / Advanced with Reset and
Save preset (presets apply across compatible models).
C — Arena (components/arena/*; app/app/arena/*; lib/arena/*; app/api/arena/*; new app/share/arena/*)
Up to 4 models: desktop adaptive 2×2 / side-by-side, phone one panel at a time (.snap-row, sticky model tabs, swipe),
live metrics (status, TTFT, tok/s, in/out tokens, cost), votes (Best / Most accurate / Best writing / Best coding / Best value
/ Fastest + custom criteria) stored in arena_votes, Arena Winner summary with comparative metrics, Blind Arena (Model A/B/C
until vote; reveal), history, personal scoreboard /app/arena/scoreboard (win rate per model, filters Coding/Research/
Writing/Reasoning/Cost efficiency), exports (Markdown/JSON incl. models, parameters, metrics, ratings), public share of an
Arena comparison (shared_arena_sessions, /share/arena/[id], revoke).
D — Projects, prompt library, context library (components/projects/*, components/library/*, components/prompts/*; app/app/projects/*, app/app/library/*, app/app/prompts/*; lib/projects/*, lib/library/*, lib/prompts/*; app/api/projects/*, app/api/library/*, app/api/prompts/*)
Projects CRUD (/api/projects, /api/projects/[id] returning { project, conversations, files, prompts }), project page
(instructions, preferred models, notes, files, conversations), activeProjectId selector, conversations filtered by project
(add projectId to ListFilter/PATCH in lib/conversations/service.ts — small additive change allowed), file library
(/api/library/files GET/POST/DELETE with estimatedTokens, /api/library/files/attach POST {fileIds} → {attachments}
copying into message_attachments), prompt library with {{variables}} (parse, defaults, fill-in sheet before insert into
composer via ?prompt= / router.push('/app/chat?promptId=…') and a usePromptInsert event on window), folders/tags/
favorites, keep existing prompt_presets/model_presets pages working (migrate the /app/prompts page to the new library
while still listing legacy presets). Finish the two stubs listed above.
E — Usage analytics, providers, custom endpoints (components/usage/*; app/app/usage/*; lib/usage/*; app/api/usage/*; app/app/settings/*; components/providers/*; lib/providers/*; app/api/providers/*; new lib/ai/providers/custom/*, app/api/endpoints/*)
Usage dashboard (KPIs: requests, tokens, cost, avg TTFT, tok/s, avg context, error rate; charts: cost/tokens/requests over
time, cost by provider/model, requests by model, latency by model; filters Today/7d/30d/90d/Custom + provider/model/project;
projections "Estimated monthly cost"; savings opportunities computed from real usage_records + pricing; mobile: stacked
KPI tiles, chart cards full-width, tables → rows). Follow dataviz skill guidance (palette from tokens, accessible).
Provider management redesign (status Connected/Not connected/Validation failed, last validated, models available, Test
connection with latency, key rotation, remove), extensible provider list. Custom OpenAI-compatible endpoints (Ollama, LM
Studio, vLLM, llama.cpp, MLX…): custom_endpoints table exists; implement adapter via createOpenAICompatAdapter with
per-endpoint baseURL/headers, discovery (GET {baseUrl}/models) or manual models, models keyed custom/<endpointId>:<modelId>
surfaced in /api/models, chat routing in lib/chat/service.ts resolution (coordinate via a resolveCustomEndpoint()
helper you export; document the one-line hook needed in the chat service in your notes if you can't wire it without
editing A's files — the integrator will wire it). Add "custom" to PROVIDER_IDS only if every switch over providers stays
exhaustive-safe (grep first). Settings pages mobile-first (full-screen sections, bottom sheets).
F — Marketing, onboarding, auth, PWA polish (components/marketing/*; app/(marketing)/* except models/compare; app/(auth)/*; components/app/onboarding.tsx; app/app/onboarding/*; app/sitemap.ts; public/* (additive))
Homepage redesign: hero "One interface. / Every model." + sub-line (Bring your own keys. Compare models. Control every
parameter. Track every token.), CTAs "Start using PolyLLM" / "Try Arena", animated real product UI in the hero (no abstract
art), live model strip (public GET /api/public/models summary — create it; marquee with provider marks, context, price,
status), interactive demo switchable Chat/Arena/Models/Usage with simulated streaming and a phone-shaped mobile demo that
mirrors the real mobile UI, features, security page /security (Browser → PolyLLM encrypted server layer → Provider; key
lifecycle; AES-256-GCM, Argon2id, HttpOnly cookies, CSP, rate limits, audit logs — technically accurate only, verify in code),
FAQ, footer with contact@spboucher.ai · Simon-Pierre Boucher · Hosted on MacLustr (www.maclustr.io), /contact optional.
Onboarding 6 steps (account → providers → first key → validate → favorite models → first prompt; skippable; progress;
swipeable on phone) as a full page /app/onboarding + first-run redirect logic in components/app/onboarding.tsx (keep the
existing Onboarding export working). Auth pages polish (mobile-first). Keep all metadata/OG (already set in app/layout.tsx).
G — Search, command palette, share/export (components/app/command-palette.tsx; new components/search/*; app/api/search/*; lib/search/*; app/share/[id]/*; lib/conversations/service.ts export/share functions (additive); app/api/conversations/[id]/actions/route.ts (additive))
Full-text search API with filter syntax model:claude project:research after:2026-08-01 before: provider: role: over titles
- message content (Postgres
ilike/to_tsvector— add a GIN index via a new migration if you use tsvector), grouped results, snippets with highlights; search UI: desktop inside ⌘K (search mode), phone full-screen sheet (store.searchOpen); universal command palette (New chat, Search, Switch model, Open Arena/Usage/Providers/Project, Upload file, Toggle theme, New temporary chat (/app/chat?temporary=1), Copy conversation URL, keyboard-first). Share: options Entire conversation / Selected messages (messageIds) with privacy warning sheet, revoke, list of active links in Settings → Data; share page redesign (mobile-first, brand). Export: Markdown, TXT, JSON, PDF (print-optimized route/share/print/[id]+window.print()is acceptable — label it "PDF (print)").
Navigation architecture
- Desktop: sidebar (New chat, Search, Chat/Arena/Models/Prompts/Presets/Library/Usage, Projects, Pinned, Folders, Recent, account menu). Collapsible ⌘B.
- Phone: bottom nav Chat / Arena / Models / Usage / Account; drawer (hamburger or edge-swipe) for conversations, projects,
search; page headers use a compact 48 px bar with a hamburger on the left (
setSidebarOpen(true)). - Every app page must reserve space for the bottom nav (the shell adds
padding-bottom; pages useh-full min-h-0 flex-collayouts and scroll inside, never the document).
Integration phase (integrator)
- Wire cross-area hooks (custom endpoint resolution in chat service, project instructions, prompt insert).
pnpm typecheck && pnpm lint && pnpm test,pnpm build.- Single dev server → Playwright QA at 375×812, 390×844, 393×852, 430×932 + desktop 1440 for every screen listed in the brief (login, signup, onboarding, chat, composer+keyboard, attachments, model switcher, Arena + swipe, drawer, search, projects, catalog, usage, providers, account, sheets). Fix overflow/clipping/tap targets.
- Migration on prod via mld hook (
pnpm db:migrate), deploymld stage . polyllm && mld deploy polyllm --node M3U96a.