SPB Git

spb/polyllm

Public

Foundation for 1.0 upgrade: design tokens, mobile shell (bottom nav, drawer, keyboard inset), sheet primitives, brand (Prism P, favicon, OG, PWA manifest), workspace schema, router & token estimators, upgrade plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 11, 2026) parent f5497ba Browse files

41 changed files +6,051 −204

added docs/UPGRADE-PLAN.md +159 −0
@@ -0,0 +1,159 @@
1+# PolyLLM 1.0 — "AI operating system" upgrade (2026-09-11)
2+
3+Massive product/UX/UI/mobile-first upgrade of www.polyllm.io. Goal: a premium, app-like multi-model workspace
4+("One interface. Every model.") that feels native on iPhone/Android and excellent on desktop, without breaking any
5+existing backend behaviour (auth, encrypted keys, chat history, Arena, presets, usage, exports, folders, attachments).
6+
7+This document is the shared contract between the parallel workstreams. Read it fully before touching code.
8+
9+## Ground rules (all workstreams)
10+
11+1. **Do not break existing functionality.** Reuse services in `src/lib/*`; extend, don't rewrite. Provider quirks stay in
12+ `src/lib/ai/providers/*`; components only see `PolyModel` capabilities (see `CLAUDE.md`).
13+2. **Ownership.** Edit only the files/dirs listed for your workstream (plus new files you create there). If you need a change
14+ in someone else's file, add a small **adapter in your own area** and leave a `// TODO(integration: <area>)` comment, or
15+ put the request in `docs/upgrade-notes/<your-area>.md`. Exception: `src/lib/client/types.ts` — append-only.
16+3. **Schema.** Tables for the upgrade live in `src/db/schema-workspace.ts` (already migrated: `drizzle/0001_workspace_upgrade.sql`).
17+ If you need more columns/tables: append to `schema-workspace.ts` (or create `src/db/schema-<area>.ts`, re-export it in
18+ `src/db/index.ts`), then run `pnpm db:generate --name <area>` **and** `pnpm db:migrate` locally. Never edit existing
19+ migration files. Never drop/rename columns.
20+4. **No dev server in this phase.** Multiple `next dev` in the same tree conflict on `.next`. Validate with
21+ `pnpm typecheck`, `pnpm lint`, `pnpm test` (vitest) and, for server logic, small vitest units in `tests/unit`. Visual QA
22+ happens in the integration phase against a single dev server.
23+5. **No fake functionality.** Every button works or is explicitly labelled "Coming soon" (use `<ComingSoon />` badge from
24+ `components/ui/misc` — add it if missing). No placeholder handlers.
25+6. **Mobile first.** Design for 375 / 390 / 393 / 430 px widths first, then tablet, then desktop. Rules:
26+ - Phones: bottom sheets instead of centered modals (`ResponsiveDialog`, `BottomSheet`, `ActionSheet` in `components/ui/sheet.tsx`),
27+ long-press for message/row actions (`useLongPress`), swipe carousels (`.snap-row` + `useSnapCarousel`), 44 px touch
28+ targets (`.tap` helper or explicit min-h), 16 px inputs, `100dvh`/`.h-app`, safe areas (`pb-safe`, `pt-safe`, `--sab`).
29+ - Never `window.prompt/confirm/alert` — use `PromptDialog` / `ConfirmDialog` (`components/common`).
30+ - No desktop tables overflowing on phones: use stacked rows/cards below `md`, tables from `md` up.
31+ - No horizontal overflow, no clipped text, no fixed element blocking content. Respect `prefers-reduced-motion`.
32+ - Hover-only controls must also be reachable on touch (`.hover-reveal` shows them on coarse pointers).
33+7. **Design language** (`src/app/globals.css` tokens): neutral warm graphite palette, iris accent, fewer borders, spatial
34+ grouping (`.panel`, hairlines) over boxed cards, restrained 150–300 ms motion, `text-balance` headlines, tabular numbers
35+ for metrics. Use `Segmented`/`ChipRow` (`components/ui/segmented.tsx`) for filters. Icons: lucide only.
36+8. **Performance.** No new heavy dependencies without a note in your area doc (allowed if < ~40 KB gz and clearly justified —
37+ e.g. `katex` for LaTeX). Virtualize long lists (`@tanstack/react-virtual` is installed). Memoize streaming renders.
38+9. **Accessibility.** Labels/aria on icon buttons, focus-visible, keyboard nav for lists/palettes, semantic elements.
39+10. **Commit nothing.** The integrator commits. Leave the tree compiling (`pnpm typecheck && pnpm lint` green) when you finish.
40+11. **Final report.** End with: files created/changed, new API routes (method, path, body → response), anything left as
41+ "Coming soon", and QA notes (what to check visually) in `docs/upgrade-notes/<area>.md`.
42+
43+## Shared primitives (already implemented — use them)
44+
45+| What | Where | Notes |
46+| --- | --- | --- |
47+| 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 |
48+| Hooks | `src/lib/client/hooks.ts` | `useIsMobile`, `useIsTablet`, `useIsCoarsePointer`, `useMounted`, `useKeyboardInset`, `useLongPress`, `useSwipe`, `useLocalStorage`, `useDebounced`, `useCopy`, `useSnapCarousel`, `useStandalone` |
49+| Sheets | `src/components/ui/sheet.tsx` | `BottomSheet` (handle, snap `content|half|full`, swipe-dismiss), `ResponsiveDialog` (sheet on phone, dialog/panel on desktop), `ActionSheet` (iOS-style list) |
50+| Segmented / chips | `src/components/ui/segmented.tsx` | `Segmented`, `ChipRow` |
51+| Dialog replacements | `src/components/common/prompt-dialog.tsx`, `confirm-dialog.tsx` | |
52+| 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`. |
53+| Token/cost estimator | `src/lib/client/tokens.ts` | `estimateTextTokens`, `estimateAttachmentTokens`, `estimateContext`, `estimateCost`, `formatEstimate`, `COST_CONFIRM_THRESHOLD_USD` |
54+| Smart router | `src/lib/client/router.ts` | `analyzePrompt`, `routeModels`, `explainRoute`, `ROUTER_MODES` (pure, transparent) |
55+| Brand | `src/components/brand/logo.tsx`, `public/icon.svg`, `public/og.png`, `scripts/brand-assets.sh`, `src/app/manifest.ts` | "Prism P" mark; `Wordmark` |
56+| Schema | `src/db/schema-workspace.ts` | `projects`, `project_files`, `prompts`, `user_model_labels`, `custom_endpoints`, `arena_votes`, `shared_arena_sessions`; `conversations.project_id` |
57+| 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. |
58+
59+## Workstreams & ownership
60+
61+### 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/*`)
62+Mobile composer (+ sheet: upload image/document, camera, paste, from library, tools, web search, structured output, system
63+prompt, temporary chat), auto-grow 1→6 lines, voice dictation (Web Speech API where available, hidden otherwise), active model
64+pill above composer (opens the model picker via `ModelSelector` — keep its public props), attachments (drag/drop, paste,
65+camera, chips, multiple), context indicator (`43K / 200K`, warnings at 80/95 %), cost estimate + confirm above
66+`COST_CONFIRM_THRESHOLD_USD` with one-tap model alternatives, Smart Router AUTO mode (`selectedModelKey === AUTO_MODEL_KEY` →
67+recommendation card before sending: Use / Choose another / Always auto-route), temporary chat (server: `ephemeral: true` in
68+`chatRequestSchema` → stream without persisting; badge "Not stored in history"), message toolbar (desktop hover / mobile
69+long-press ActionSheet: copy, retry, edit, branch, compare, change model from here, save as prompt, export, rate, more),
70+expandable message metadata (model, provider, tokens, cost, TTFT, tok/s, reasoning level), deprecated-model warning with
71+replacement suggestion, humanized provider errors with Retry / Switch model / Details, streaming performance (memoized
72+blocks, stable markdown, code block Copy/Download/Wrap/Expand, LaTeX via `katex` + `rehype-katex` allowed), inline
73+**Compare with…** (2–4 models; desktop side-by-side, mobile swipeable panels with sticky tabs; reuse `/api/arena` +
74+`/api/arena/stream` to run, store as Arena session, "Continue with this model"), empty state with quick actions
75+(Write / Code / Research / Analyze a file / Compare models), project instructions applied when `activeProjectId` is set
76+(fetch `/api/projects/:id` — Projects workstream provides it; degrade gracefully if 404).
77+
78+### 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/*`)
79+Model picker redesign (bottom sheet on phones; desktop dialog), fast search with intent parsing ("cheap vision model",
80+"1M context", "under $1/M", "fastest gemini", "json schema"), sections (Favorites, Recent, Recommended, Fast, Best
81+reasoning, Cheapest, Largest context, Vision, Coding, Open source, New), compact metadata rows, star favorites, custom labels
82+(`store.setLabel`), AUTO entry (Smart Router) with mode selector, model badges (NEW/FAST/CHEAP/REASONING/VISION/CODING/LONG
83+CONTEXT), lifecycle badges (New/Active/Deprecated/Retiring/Unavailable), model profile sheet (all fields; show "—" for
84+unknown — never invent release dates or cutoffs), `/app/models` catalog (sortable, filters, mobile stacked rows, select →
85+Compare), public `/models` and `/compare/[slug]` (`gpt-5.5-vs-claude-sonnet-5`) pages with SEO metadata reading the registry
86+server-side (no auth), capability-aware settings grouped Generation / Reasoning / Output / Tools / Advanced with Reset and
87+Save preset (presets apply across compatible models).
88+
89+### C — Arena (`components/arena/*`; `app/app/arena/*`; `lib/arena/*`; `app/api/arena/*`; new `app/share/arena/*`)
90+Up to 4 models: desktop adaptive 2×2 / side-by-side, phone one panel at a time (`.snap-row`, sticky model tabs, swipe),
91+live metrics (status, TTFT, tok/s, in/out tokens, cost), votes (Best / Most accurate / Best writing / Best coding / Best value
92+/ Fastest + custom criteria) stored in `arena_votes`, Arena Winner summary with comparative metrics, Blind Arena (Model A/B/C
93+until vote; reveal), history, personal scoreboard `/app/arena/scoreboard` (win rate per model, filters Coding/Research/
94+Writing/Reasoning/Cost efficiency), exports (Markdown/JSON incl. models, parameters, metrics, ratings), public share of an
95+Arena comparison (`shared_arena_sessions`, `/share/arena/[id]`, revoke).
96+
97+### 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/*`)
98+Projects CRUD (`/api/projects`, `/api/projects/[id]` returning `{ project, conversations, files, prompts }`), project page
99+(instructions, preferred models, notes, files, conversations), `activeProjectId` selector, conversations filtered by project
100+(add `projectId` to `ListFilter`/PATCH in `lib/conversations/service.ts` — small additive change allowed), file library
101+(`/api/library/files` GET/POST/DELETE with `estimatedTokens`, `/api/library/files/attach` POST `{fileIds}` → `{attachments}`
102+copying into `message_attachments`), prompt library with `{{variables}}` (parse, defaults, fill-in sheet before insert into
103+composer via `?prompt=` / `router.push('/app/chat?promptId=…')` and a `usePromptInsert` event on `window`), folders/tags/
104+favorites, keep existing `prompt_presets`/`model_presets` pages working (migrate the `/app/prompts` page to the new library
105+while still listing legacy presets). Finish the two stubs listed above.
106+
107+### 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/*`)
108+Usage dashboard (KPIs: requests, tokens, cost, avg TTFT, tok/s, avg context, error rate; charts: cost/tokens/requests over
109+time, cost by provider/model, requests by model, latency by model; filters Today/7d/30d/90d/Custom + provider/model/project;
110+projections "Estimated monthly cost"; savings opportunities computed from real usage_records + pricing; mobile: stacked
111+KPI tiles, chart cards full-width, tables → rows). Follow `dataviz` skill guidance (palette from tokens, accessible).
112+Provider management redesign (status Connected/Not connected/Validation failed, last validated, models available, Test
113+connection with latency, key rotation, remove), extensible provider list. Custom OpenAI-compatible endpoints (Ollama, LM
114+Studio, vLLM, llama.cpp, MLX…): `custom_endpoints` table exists; implement adapter via `createOpenAICompatAdapter` with
115+per-endpoint baseURL/headers, discovery (`GET {baseUrl}/models`) or manual models, models keyed `custom/<endpointId>:<modelId>`
116+surfaced in `/api/models`, chat routing in `lib/chat/service.ts` resolution (coordinate via a `resolveCustomEndpoint()`
117+helper you export; document the one-line hook needed in the chat service in your notes if you can't wire it without
118+editing A's files — the integrator will wire it). Add `"custom"` to `PROVIDER_IDS` only if every switch over providers stays
119+exhaustive-safe (grep first). Settings pages mobile-first (full-screen sections, bottom sheets).
120+
121+### 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))
122+Homepage redesign: hero "One interface. / Every model." + sub-line (Bring your own keys. Compare models. Control every
123+parameter. Track every token.), CTAs "Start using PolyLLM" / "Try Arena", animated **real product UI** in the hero (no abstract
124+art), live model strip (public `GET /api/public/models` summary — create it; marquee with provider marks, context, price,
125+status), interactive demo switchable Chat/Arena/Models/Usage with simulated streaming and a phone-shaped mobile demo that
126+mirrors the real mobile UI, features, security page `/security` (Browser → PolyLLM encrypted server layer → Provider; key
127+lifecycle; AES-256-GCM, Argon2id, HttpOnly cookies, CSP, rate limits, audit logs — technically accurate only, verify in code),
128+FAQ, footer with **contact@spboucher.ai · Simon-Pierre Boucher · Hosted on MacLustr (www.maclustr.io)**, `/contact` optional.
129+Onboarding 6 steps (account → providers → first key → validate → favorite models → first prompt; skippable; progress;
130+swipeable on phone) as a full page `/app/onboarding` + first-run redirect logic in `components/app/onboarding.tsx` (keep the
131+existing `Onboarding` export working). Auth pages polish (mobile-first). Keep all metadata/OG (already set in `app/layout.tsx`).
132+
133+### 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))
134+Full-text search API with filter syntax `model:claude project:research after:2026-08-01 before: provider: role:` over titles
135++ message content (Postgres `ilike`/`to_tsvector` — add a GIN index via a new migration if you use tsvector), grouped results,
136+snippets with highlights; search UI: desktop inside ⌘K (search mode), phone full-screen sheet (`store.searchOpen`); universal
137+command palette (New chat, Search, Switch model, Open Arena/Usage/Providers/Project, Upload file, Toggle theme, New temporary
138+chat (`/app/chat?temporary=1`), Copy conversation URL, keyboard-first). Share: options Entire conversation / Selected messages
139+(`messageIds`) with privacy warning sheet, revoke, list of active links in Settings → Data; share page redesign (mobile-first,
140+brand). Export: Markdown, TXT, JSON, PDF (print-optimized route `/share/print/[id]` + `window.print()` is acceptable — label it
141+"PDF (print)").
142+
143+## Navigation architecture
144+
145+- Desktop: sidebar (New chat, Search, Chat/Arena/Models/Prompts/Presets/Library/Usage, Projects, Pinned, Folders, Recent,
146+ account menu). Collapsible ⌘B.
147+- Phone: bottom nav Chat / Arena / Models / Usage / Account; drawer (hamburger or edge-swipe) for conversations, projects,
148+ search; page headers use a compact 48 px bar with a hamburger on the left (`setSidebarOpen(true)`).
149+- Every app page must reserve space for the bottom nav (the shell adds `padding-bottom`; pages use `h-full min-h-0 flex-col`
150+ layouts and scroll inside, never the document).
151+
152+## Integration phase (integrator)
153+
154+1. Wire cross-area hooks (custom endpoint resolution in chat service, project instructions, prompt insert).
155+2. `pnpm typecheck && pnpm lint && pnpm test`, `pnpm build`.
156+3. Single dev server → Playwright QA at 375×812, 390×844, 393×852, 430×932 + desktop 1440 for every screen listed in the brief
157+ (login, signup, onboarding, chat, composer+keyboard, attachments, model switcher, Arena + swipe, drawer, search, projects,
158+ catalog, usage, providers, account, sheets). Fix overflow/clipping/tap targets.
159+4. Migration on prod via mld hook (`pnpm db:migrate`), deploy `mld stage . polyllm && mld deploy polyllm --node M3U96a`.
modified drizzle.config.ts +1 −1
@@ -2,7 +2,7 @@ import { defineConfig } from "drizzle-kit";
2 2
3 export default defineConfig({ 3 export default defineConfig({
4 dialect: "postgresql", 4 dialect: "postgresql",
5− schema: "./src/db/schema.ts", 5+ schema: ["./src/db/schema.ts", "./src/db/schema-*.ts"],
6 out: "./drizzle", 6 out: "./drizzle",
7 dbCredentials: { url: process.env.DATABASE_URL ?? "postgres://localhost:5432/polyllm" }, 7 dbCredentials: { url: process.env.DATABASE_URL ?? "postgres://localhost:5432/polyllm" },
8 strict: true, 8 strict: true,
added drizzle/0001_workspace_upgrade.sql +122 −0
@@ -0,0 +1,122 @@
1+CREATE TABLE "arena_votes" (
2+ "id" text PRIMARY KEY NOT NULL,
3+ "user_id" text NOT NULL,
4+ "session_id" text NOT NULL,
5+ "response_id" text NOT NULL,
6+ "model_key" text NOT NULL,
7+ "criterion" text NOT NULL,
8+ "category" text,
9+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
10+);
11+--> statement-breakpoint
12+CREATE TABLE "custom_endpoints" (
13+ "id" text PRIMARY KEY NOT NULL,
14+ "user_id" text NOT NULL,
15+ "name" text NOT NULL,
16+ "base_url" text NOT NULL,
17+ "encrypted_key" text,
18+ "key_hint" text,
19+ "encrypted_headers" text,
20+ "models_path" text DEFAULT '/models' NOT NULL,
21+ "manual_models" jsonb DEFAULT '[]'::jsonb NOT NULL,
22+ "status" text DEFAULT 'unverified' NOT NULL,
23+ "last_validated_at" timestamp with time zone,
24+ "last_validation_error" text,
25+ "last_latency_ms" integer,
26+ "models_available" integer,
27+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
28+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
29+);
30+--> statement-breakpoint
31+CREATE TABLE "project_files" (
32+ "id" text PRIMARY KEY NOT NULL,
33+ "user_id" text NOT NULL,
34+ "project_id" text,
35+ "kind" text NOT NULL,
36+ "name" text NOT NULL,
37+ "mime_type" text NOT NULL,
38+ "size_bytes" integer NOT NULL,
39+ "data_base64" text NOT NULL,
40+ "width" integer,
41+ "height" integer,
42+ "estimated_tokens" integer,
43+ "description" text,
44+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
45+);
46+--> statement-breakpoint
47+CREATE TABLE "projects" (
48+ "id" text PRIMARY KEY NOT NULL,
49+ "user_id" text NOT NULL,
50+ "name" text NOT NULL,
51+ "description" text,
52+ "icon" text,
53+ "color" text,
54+ "instructions" text,
55+ "preferred_model_keys" jsonb DEFAULT '[]'::jsonb NOT NULL,
56+ "default_settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
57+ "notes" text,
58+ "archived" boolean DEFAULT false NOT NULL,
59+ "sort_order" integer DEFAULT 0 NOT NULL,
60+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
61+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
62+);
63+--> statement-breakpoint
64+CREATE TABLE "prompts" (
65+ "id" text PRIMARY KEY NOT NULL,
66+ "user_id" text NOT NULL,
67+ "project_id" text,
68+ "kind" text DEFAULT 'user' NOT NULL,
69+ "name" text NOT NULL,
70+ "description" text,
71+ "content" text NOT NULL,
72+ "variables" jsonb DEFAULT '[]'::jsonb NOT NULL,
73+ "schema" jsonb,
74+ "folder" text,
75+ "tags" jsonb DEFAULT '[]'::jsonb NOT NULL,
76+ "favorite" boolean DEFAULT false NOT NULL,
77+ "default_model_key" text,
78+ "uses" integer DEFAULT 0 NOT NULL,
79+ "last_used_at" timestamp with time zone,
80+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
81+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
82+);
83+--> statement-breakpoint
84+CREATE TABLE "shared_arena_sessions" (
85+ "id" text PRIMARY KEY NOT NULL,
86+ "session_id" text NOT NULL,
87+ "user_id" text NOT NULL,
88+ "snapshot" jsonb NOT NULL,
89+ "is_public" boolean DEFAULT true NOT NULL,
90+ "view_count" integer DEFAULT 0 NOT NULL,
91+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
92+ "revoked_at" timestamp with time zone
93+);
94+--> statement-breakpoint
95+CREATE TABLE "user_model_labels" (
96+ "user_id" text NOT NULL,
97+ "model_key" text NOT NULL,
98+ "label" text NOT NULL,
99+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
100+ CONSTRAINT "user_model_labels_user_id_model_key_pk" PRIMARY KEY("user_id","model_key")
101+);
102+--> statement-breakpoint
103+ALTER TABLE "conversations" ADD COLUMN "project_id" text;--> statement-breakpoint
104+ALTER TABLE "arena_votes" ADD CONSTRAINT "arena_votes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
105+ALTER TABLE "custom_endpoints" ADD CONSTRAINT "custom_endpoints_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
106+ALTER TABLE "project_files" ADD CONSTRAINT "project_files_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
107+ALTER TABLE "project_files" ADD CONSTRAINT "project_files_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
108+ALTER TABLE "projects" ADD CONSTRAINT "projects_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
109+ALTER TABLE "prompts" ADD CONSTRAINT "prompts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
110+ALTER TABLE "prompts" ADD CONSTRAINT "prompts_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
111+ALTER TABLE "shared_arena_sessions" ADD CONSTRAINT "shared_arena_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
112+ALTER TABLE "user_model_labels" ADD CONSTRAINT "user_model_labels_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
113+CREATE UNIQUE INDEX "arena_votes_session_criterion_uq" ON "arena_votes" USING btree ("session_id","criterion");--> statement-breakpoint
114+CREATE INDEX "arena_votes_user_model_idx" ON "arena_votes" USING btree ("user_id","model_key");--> statement-breakpoint
115+CREATE INDEX "custom_endpoints_user_idx" ON "custom_endpoints" USING btree ("user_id");--> statement-breakpoint
116+CREATE INDEX "project_files_user_idx" ON "project_files" USING btree ("user_id");--> statement-breakpoint
117+CREATE INDEX "project_files_project_idx" ON "project_files" USING btree ("project_id");--> statement-breakpoint
118+CREATE INDEX "projects_user_idx" ON "projects" USING btree ("user_id");--> statement-breakpoint
119+CREATE INDEX "prompts_user_idx" ON "prompts" USING btree ("user_id");--> statement-breakpoint
120+CREATE INDEX "prompts_user_folder_idx" ON "prompts" USING btree ("user_id","folder");--> statement-breakpoint
121+CREATE INDEX "shared_arena_session_idx" ON "shared_arena_sessions" USING btree ("session_id");--> statement-breakpoint
122+CREATE INDEX "conversations_user_project_idx" ON "conversations" USING btree ("user_id","project_id");
\ No newline at end of file
added drizzle/meta/0001_snapshot.json +3661 −0
@@ -0,0 +1,3661 @@
1+{
2+ "id": "37eed628-caf4-40a5-a511-f6aad208f087",
3+ "prevId": "6f7e7f5c-d043-4e85-b3e5-b92b0ee9d52b",
4+ "version": "7",
5+ "dialect": "postgresql",
6+ "tables": {
7+ "public.accounts": {
8+ "name": "accounts",
9+ "schema": "",
10+ "columns": {
11+ "id": {
12+ "name": "id",
13+ "type": "text",
14+ "primaryKey": true,
15+ "notNull": true
16+ },
17+ "account_id": {
18+ "name": "account_id",
19+ "type": "text",
20+ "primaryKey": false,
21+ "notNull": true
22+ },
23+ "provider_id": {
24+ "name": "provider_id",
25+ "type": "text",
26+ "primaryKey": false,
27+ "notNull": true
28+ },
29+ "user_id": {
30+ "name": "user_id",
31+ "type": "text",
32+ "primaryKey": false,
33+ "notNull": true
34+ },
35+ "access_token": {
36+ "name": "access_token",
37+ "type": "text",
38+ "primaryKey": false,
39+ "notNull": false
40+ },
41+ "refresh_token": {
42+ "name": "refresh_token",
43+ "type": "text",
44+ "primaryKey": false,
45+ "notNull": false
46+ },
47+ "id_token": {
48+ "name": "id_token",
49+ "type": "text",
50+ "primaryKey": false,
51+ "notNull": false
52+ },
53+ "access_token_expires_at": {
54+ "name": "access_token_expires_at",
55+ "type": "timestamp with time zone",
56+ "primaryKey": false,
57+ "notNull": false
58+ },
59+ "refresh_token_expires_at": {
60+ "name": "refresh_token_expires_at",
61+ "type": "timestamp with time zone",
62+ "primaryKey": false,
63+ "notNull": false
64+ },
65+ "scope": {
66+ "name": "scope",
67+ "type": "text",
68+ "primaryKey": false,
69+ "notNull": false
70+ },
71+ "password": {
72+ "name": "password",
73+ "type": "text",
74+ "primaryKey": false,
75+ "notNull": false
76+ },
77+ "created_at": {
78+ "name": "created_at",
79+ "type": "timestamp with time zone",
80+ "primaryKey": false,
81+ "notNull": true,
82+ "default": "now()"
83+ },
84+ "updated_at": {
85+ "name": "updated_at",
86+ "type": "timestamp with time zone",
87+ "primaryKey": false,
88+ "notNull": true,
89+ "default": "now()"
90+ }
91+ },
92+ "indexes": {
93+ "accounts_user_idx": {
94+ "name": "accounts_user_idx",
95+ "columns": [
96+ {
97+ "expression": "user_id",
98+ "isExpression": false,
99+ "asc": true,
100+ "nulls": "last"
101+ }
102+ ],
103+ "isUnique": false,
104+ "concurrently": false,
105+ "method": "btree",
106+ "with": {}
107+ }
108+ },
109+ "foreignKeys": {
110+ "accounts_user_id_users_id_fk": {
111+ "name": "accounts_user_id_users_id_fk",
112+ "tableFrom": "accounts",
113+ "tableTo": "users",
114+ "columnsFrom": [
115+ "user_id"
116+ ],
117+ "columnsTo": [
118+ "id"
119+ ],
120+ "onDelete": "cascade",
121+ "onUpdate": "no action"
122+ }
123+ },
124+ "compositePrimaryKeys": {},
125+ "uniqueConstraints": {},
126+ "policies": {},
127+ "checkConstraints": {},
128+ "isRLSEnabled": false
129+ },
130+ "public.arena_responses": {
131+ "name": "arena_responses",
132+ "schema": "",
133+ "columns": {
134+ "id": {
135+ "name": "id",
136+ "type": "text",
137+ "primaryKey": true,
138+ "notNull": true
139+ },
140+ "session_id": {
141+ "name": "session_id",
142+ "type": "text",
143+ "primaryKey": false,
144+ "notNull": true
145+ },
146+ "model_key": {
147+ "name": "model_key",
148+ "type": "text",
149+ "primaryKey": false,
150+ "notNull": true
151+ },
152+ "provider": {
153+ "name": "provider",
154+ "type": "text",
155+ "primaryKey": false,
156+ "notNull": true
157+ },
158+ "content": {
159+ "name": "content",
160+ "type": "text",
161+ "primaryKey": false,
162+ "notNull": true,
163+ "default": "''"
164+ },
165+ "reasoning": {
166+ "name": "reasoning",
167+ "type": "text",
168+ "primaryKey": false,
169+ "notNull": false
170+ },
171+ "status": {
172+ "name": "status",
173+ "type": "text",
174+ "primaryKey": false,
175+ "notNull": true,
176+ "default": "'complete'"
177+ },
178+ "error": {
179+ "name": "error",
180+ "type": "jsonb",
181+ "primaryKey": false,
182+ "notNull": false
183+ },
184+ "usage": {
185+ "name": "usage",
186+ "type": "jsonb",
187+ "primaryKey": false,
188+ "notNull": false
189+ },
190+ "latency_ms": {
191+ "name": "latency_ms",
192+ "type": "integer",
193+ "primaryKey": false,
194+ "notNull": false
195+ },
196+ "ttft_ms": {
197+ "name": "ttft_ms",
198+ "type": "integer",
199+ "primaryKey": false,
200+ "notNull": false
201+ },
202+ "cost_usd": {
203+ "name": "cost_usd",
204+ "type": "double precision",
205+ "primaryKey": false,
206+ "notNull": false
207+ },
208+ "ratings": {
209+ "name": "ratings",
210+ "type": "jsonb",
211+ "primaryKey": false,
212+ "notNull": true,
213+ "default": "'{}'::jsonb"
214+ },
215+ "created_at": {
216+ "name": "created_at",
217+ "type": "timestamp with time zone",
218+ "primaryKey": false,
219+ "notNull": true,
220+ "default": "now()"
221+ }
222+ },
223+ "indexes": {
224+ "arena_responses_session_idx": {
225+ "name": "arena_responses_session_idx",
226+ "columns": [
227+ {
228+ "expression": "session_id",
229+ "isExpression": false,
230+ "asc": true,
231+ "nulls": "last"
232+ }
233+ ],
234+ "isUnique": false,
235+ "concurrently": false,
236+ "method": "btree",
237+ "with": {}
238+ }
239+ },
240+ "foreignKeys": {
241+ "arena_responses_session_id_arena_sessions_id_fk": {
242+ "name": "arena_responses_session_id_arena_sessions_id_fk",
243+ "tableFrom": "arena_responses",
244+ "tableTo": "arena_sessions",
245+ "columnsFrom": [
246+ "session_id"
247+ ],
248+ "columnsTo": [
249+ "id"
250+ ],
251+ "onDelete": "cascade",
252+ "onUpdate": "no action"
253+ }
254+ },
255+ "compositePrimaryKeys": {},
256+ "uniqueConstraints": {},
257+ "policies": {},
258+ "checkConstraints": {},
259+ "isRLSEnabled": false
260+ },
261+ "public.arena_sessions": {
262+ "name": "arena_sessions",
263+ "schema": "",
264+ "columns": {
265+ "id": {
266+ "name": "id",
267+ "type": "text",
268+ "primaryKey": true,
269+ "notNull": true
270+ },
271+ "user_id": {
272+ "name": "user_id",
273+ "type": "text",
274+ "primaryKey": false,
275+ "notNull": true
276+ },
277+ "prompt": {
278+ "name": "prompt",
279+ "type": "text",
280+ "primaryKey": false,
281+ "notNull": true
282+ },
283+ "system_prompt": {
284+ "name": "system_prompt",
285+ "type": "text",
286+ "primaryKey": false,
287+ "notNull": false
288+ },
289+ "model_keys": {
290+ "name": "model_keys",
291+ "type": "jsonb",
292+ "primaryKey": false,
293+ "notNull": true
294+ },
295+ "settings": {
296+ "name": "settings",
297+ "type": "jsonb",
298+ "primaryKey": false,
299+ "notNull": true,
300+ "default": "'{}'::jsonb"
301+ },
302+ "created_at": {
303+ "name": "created_at",
304+ "type": "timestamp with time zone",
305+ "primaryKey": false,
306+ "notNull": true,
307+ "default": "now()"
308+ }
309+ },
310+ "indexes": {
311+ "arena_sessions_user_idx": {
312+ "name": "arena_sessions_user_idx",
313+ "columns": [
314+ {
315+ "expression": "user_id",
316+ "isExpression": false,
317+ "asc": true,
318+ "nulls": "last"
319+ },
320+ {
321+ "expression": "created_at",
322+ "isExpression": false,
323+ "asc": true,
324+ "nulls": "last"
325+ }
326+ ],
327+ "isUnique": false,
328+ "concurrently": false,
329+ "method": "btree",
330+ "with": {}
331+ }
332+ },
333+ "foreignKeys": {
334+ "arena_sessions_user_id_users_id_fk": {
335+ "name": "arena_sessions_user_id_users_id_fk",
336+ "tableFrom": "arena_sessions",
337+ "tableTo": "users",
338+ "columnsFrom": [
339+ "user_id"
340+ ],
341+ "columnsTo": [
342+ "id"
343+ ],
344+ "onDelete": "cascade",
345+ "onUpdate": "no action"
346+ }
347+ },
348+ "compositePrimaryKeys": {},
349+ "uniqueConstraints": {},
350+ "policies": {},
351+ "checkConstraints": {},
352+ "isRLSEnabled": false
353+ },
354+ "public.audit_events": {
355+ "name": "audit_events",
356+ "schema": "",
357+ "columns": {
358+ "id": {
359+ "name": "id",
360+ "type": "text",
361+ "primaryKey": true,
362+ "notNull": true
363+ },
364+ "user_id": {
365+ "name": "user_id",
366+ "type": "text",
367+ "primaryKey": false,
368+ "notNull": false
369+ },
370+ "action": {
371+ "name": "action",
372+ "type": "text",
373+ "primaryKey": false,
374+ "notNull": true
375+ },
376+ "ip_address": {
377+ "name": "ip_address",
378+ "type": "text",
379+ "primaryKey": false,
380+ "notNull": false
381+ },
382+ "user_agent": {
383+ "name": "user_agent",
384+ "type": "text",
385+ "primaryKey": false,
386+ "notNull": false
387+ },
388+ "meta": {
389+ "name": "meta",
390+ "type": "jsonb",
391+ "primaryKey": false,
392+ "notNull": true,
393+ "default": "'{}'::jsonb"
394+ },
395+ "created_at": {
396+ "name": "created_at",
397+ "type": "timestamp with time zone",
398+ "primaryKey": false,
399+ "notNull": true,
400+ "default": "now()"
401+ }
402+ },
403+ "indexes": {
404+ "audit_user_created_idx": {
405+ "name": "audit_user_created_idx",
406+ "columns": [
407+ {
408+ "expression": "user_id",
409+ "isExpression": false,
410+ "asc": true,
411+ "nulls": "last"
412+ },
413+ {
414+ "expression": "created_at",
415+ "isExpression": false,
416+ "asc": true,
417+ "nulls": "last"
418+ }
419+ ],
420+ "isUnique": false,
421+ "concurrently": false,
422+ "method": "btree",
423+ "with": {}
424+ }
425+ },
426+ "foreignKeys": {
427+ "audit_events_user_id_users_id_fk": {
428+ "name": "audit_events_user_id_users_id_fk",
429+ "tableFrom": "audit_events",
430+ "tableTo": "users",
431+ "columnsFrom": [
432+ "user_id"
433+ ],
434+ "columnsTo": [
435+ "id"
436+ ],
437+ "onDelete": "set null",
438+ "onUpdate": "no action"
439+ }
440+ },
441+ "compositePrimaryKeys": {},
442+ "uniqueConstraints": {},
443+ "policies": {},
444+ "checkConstraints": {},
445+ "isRLSEnabled": false
446+ },
447+ "public.conversation_tags": {
448+ "name": "conversation_tags",
449+ "schema": "",
450+ "columns": {
451+ "conversation_id": {
452+ "name": "conversation_id",
453+ "type": "text",
454+ "primaryKey": false,
455+ "notNull": true
456+ },
457+ "tag_id": {
458+ "name": "tag_id",
459+ "type": "text",
460+ "primaryKey": false,
461+ "notNull": true
462+ }
463+ },
464+ "indexes": {},
465+ "foreignKeys": {
466+ "conversation_tags_conversation_id_conversations_id_fk": {
467+ "name": "conversation_tags_conversation_id_conversations_id_fk",
468+ "tableFrom": "conversation_tags",
469+ "tableTo": "conversations",
470+ "columnsFrom": [
471+ "conversation_id"
472+ ],
473+ "columnsTo": [
474+ "id"
475+ ],
476+ "onDelete": "cascade",
477+ "onUpdate": "no action"
478+ },
479+ "conversation_tags_tag_id_tags_id_fk": {
480+ "name": "conversation_tags_tag_id_tags_id_fk",
481+ "tableFrom": "conversation_tags",
482+ "tableTo": "tags",
483+ "columnsFrom": [
484+ "tag_id"
485+ ],
486+ "columnsTo": [
487+ "id"
488+ ],
489+ "onDelete": "cascade",
490+ "onUpdate": "no action"
491+ }
492+ },
493+ "compositePrimaryKeys": {
494+ "conversation_tags_conversation_id_tag_id_pk": {
495+ "name": "conversation_tags_conversation_id_tag_id_pk",
496+ "columns": [
497+ "conversation_id",
498+ "tag_id"
499+ ]
500+ }
501+ },
502+ "uniqueConstraints": {},
503+ "policies": {},
504+ "checkConstraints": {},
505+ "isRLSEnabled": false
506+ },
507+ "public.conversations": {
508+ "name": "conversations",
509+ "schema": "",
510+ "columns": {
511+ "id": {
512+ "name": "id",
513+ "type": "text",
514+ "primaryKey": true,
515+ "notNull": true
516+ },
517+ "user_id": {
518+ "name": "user_id",
519+ "type": "text",
520+ "primaryKey": false,
521+ "notNull": true
522+ },
523+ "title": {
524+ "name": "title",
525+ "type": "text",
526+ "primaryKey": false,
527+ "notNull": true,
528+ "default": "'New chat'"
529+ },
530+ "title_source": {
531+ "name": "title_source",
532+ "type": "text",
533+ "primaryKey": false,
534+ "notNull": true,
535+ "default": "'auto'"
536+ },
537+ "folder_id": {
538+ "name": "folder_id",
539+ "type": "text",
540+ "primaryKey": false,
541+ "notNull": false
542+ },
543+ "project_id": {
544+ "name": "project_id",
545+ "type": "text",
546+ "primaryKey": false,
547+ "notNull": false
548+ },
549+ "pinned": {
550+ "name": "pinned",
551+ "type": "boolean",
552+ "primaryKey": false,
553+ "notNull": true,
554+ "default": false
555+ },
556+ "archived": {
557+ "name": "archived",
558+ "type": "boolean",
559+ "primaryKey": false,
560+ "notNull": true,
561+ "default": false
562+ },
563+ "model_key": {
564+ "name": "model_key",
565+ "type": "text",
566+ "primaryKey": false,
567+ "notNull": false
568+ },
569+ "provider": {
570+ "name": "provider",
571+ "type": "text",
572+ "primaryKey": false,
573+ "notNull": false
574+ },
575+ "system_prompt": {
576+ "name": "system_prompt",
577+ "type": "text",
578+ "primaryKey": false,
579+ "notNull": false
580+ },
581+ "settings": {
582+ "name": "settings",
583+ "type": "jsonb",
584+ "primaryKey": false,
585+ "notNull": true,
586+ "default": "'{}'::jsonb"
587+ },
588+ "parent_conversation_id": {
589+ "name": "parent_conversation_id",
590+ "type": "text",
591+ "primaryKey": false,
592+ "notNull": false
593+ },
594+ "branched_from_message_id": {
595+ "name": "branched_from_message_id",
596+ "type": "text",
597+ "primaryKey": false,
598+ "notNull": false
599+ },
600+ "message_count": {
601+ "name": "message_count",
602+ "type": "integer",
603+ "primaryKey": false,
604+ "notNull": true,
605+ "default": 0
606+ },
607+ "total_cost_usd": {
608+ "name": "total_cost_usd",
609+ "type": "double precision",
610+ "primaryKey": false,
611+ "notNull": true,
612+ "default": 0
613+ },
614+ "total_input_tokens": {
615+ "name": "total_input_tokens",
616+ "type": "integer",
617+ "primaryKey": false,
618+ "notNull": true,
619+ "default": 0
620+ },
621+ "total_output_tokens": {
622+ "name": "total_output_tokens",
623+ "type": "integer",
624+ "primaryKey": false,
625+ "notNull": true,
626+ "default": 0
627+ },
628+ "last_message_at": {
629+ "name": "last_message_at",
630+ "type": "timestamp with time zone",
631+ "primaryKey": false,
632+ "notNull": false
633+ },
634+ "created_at": {
635+ "name": "created_at",
636+ "type": "timestamp with time zone",
637+ "primaryKey": false,
638+ "notNull": true,
639+ "default": "now()"
640+ },
641+ "updated_at": {
642+ "name": "updated_at",
643+ "type": "timestamp with time zone",
644+ "primaryKey": false,
645+ "notNull": true,
646+ "default": "now()"
647+ }
648+ },
649+ "indexes": {
650+ "conversations_user_updated_idx": {
651+ "name": "conversations_user_updated_idx",
652+ "columns": [
653+ {
654+ "expression": "user_id",
655+ "isExpression": false,
656+ "asc": true,
657+ "nulls": "last"
658+ },
659+ {
660+ "expression": "updated_at",
661+ "isExpression": false,
662+ "asc": true,
663+ "nulls": "last"
664+ }
665+ ],
666+ "isUnique": false,
667+ "concurrently": false,
668+ "method": "btree",
669+ "with": {}
670+ },
671+ "conversations_user_folder_idx": {
672+ "name": "conversations_user_folder_idx",
673+ "columns": [
674+ {
675+ "expression": "user_id",
676+ "isExpression": false,
677+ "asc": true,
678+ "nulls": "last"
679+ },
680+ {
681+ "expression": "folder_id",
682+ "isExpression": false,
683+ "asc": true,
684+ "nulls": "last"
685+ }
686+ ],
687+ "isUnique": false,
688+ "concurrently": false,
689+ "method": "btree",
690+ "with": {}
691+ },
692+ "conversations_user_model_idx": {
693+ "name": "conversations_user_model_idx",
694+ "columns": [
695+ {
696+ "expression": "user_id",
697+ "isExpression": false,
698+ "asc": true,
699+ "nulls": "last"
700+ },
701+ {
702+ "expression": "model_key",
703+ "isExpression": false,
704+ "asc": true,
705+ "nulls": "last"
706+ }
707+ ],
708+ "isUnique": false,
709+ "concurrently": false,
710+ "method": "btree",
711+ "with": {}
712+ },
713+ "conversations_user_project_idx": {
714+ "name": "conversations_user_project_idx",
715+ "columns": [
716+ {
717+ "expression": "user_id",
718+ "isExpression": false,
719+ "asc": true,
720+ "nulls": "last"
721+ },
722+ {
723+ "expression": "project_id",
724+ "isExpression": false,
725+ "asc": true,
726+ "nulls": "last"
727+ }
728+ ],
729+ "isUnique": false,
730+ "concurrently": false,
731+ "method": "btree",
732+ "with": {}
733+ }
734+ },
735+ "foreignKeys": {
736+ "conversations_user_id_users_id_fk": {
737+ "name": "conversations_user_id_users_id_fk",
738+ "tableFrom": "conversations",
739+ "tableTo": "users",
740+ "columnsFrom": [
741+ "user_id"
742+ ],
743+ "columnsTo": [
744+ "id"
745+ ],
746+ "onDelete": "cascade",
747+ "onUpdate": "no action"
748+ },
749+ "conversations_folder_id_folders_id_fk": {
750+ "name": "conversations_folder_id_folders_id_fk",
751+ "tableFrom": "conversations",
752+ "tableTo": "folders",
753+ "columnsFrom": [
754+ "folder_id"
755+ ],
756+ "columnsTo": [
757+ "id"
758+ ],
759+ "onDelete": "set null",
760+ "onUpdate": "no action"
761+ }
762+ },
763+ "compositePrimaryKeys": {},
764+ "uniqueConstraints": {},
765+ "policies": {},
766+ "checkConstraints": {},
767+ "isRLSEnabled": false
768+ },
769+ "public.folders": {
770+ "name": "folders",
771+ "schema": "",
772+ "columns": {
773+ "id": {
774+ "name": "id",
775+ "type": "text",
776+ "primaryKey": true,
777+ "notNull": true
778+ },
779+ "user_id": {
780+ "name": "user_id",
781+ "type": "text",
782+ "primaryKey": false,
783+ "notNull": true
784+ },
785+ "name": {
786+ "name": "name",
787+ "type": "text",
788+ "primaryKey": false,
789+ "notNull": true
790+ },
791+ "color": {
792+ "name": "color",
793+ "type": "text",
794+ "primaryKey": false,
795+ "notNull": false
796+ },
797+ "sort_order": {
798+ "name": "sort_order",
799+ "type": "integer",
800+ "primaryKey": false,
801+ "notNull": true,
802+ "default": 0
803+ },
804+ "created_at": {
805+ "name": "created_at",
806+ "type": "timestamp with time zone",
807+ "primaryKey": false,
808+ "notNull": true,
809+ "default": "now()"
810+ }
811+ },
812+ "indexes": {
813+ "folders_user_idx": {
814+ "name": "folders_user_idx",
815+ "columns": [
816+ {
817+ "expression": "user_id",
818+ "isExpression": false,
819+ "asc": true,
820+ "nulls": "last"
821+ }
822+ ],
823+ "isUnique": false,
824+ "concurrently": false,
825+ "method": "btree",
826+ "with": {}
827+ }
828+ },
829+ "foreignKeys": {
830+ "folders_user_id_users_id_fk": {
831+ "name": "folders_user_id_users_id_fk",
832+ "tableFrom": "folders",
833+ "tableTo": "users",
834+ "columnsFrom": [
835+ "user_id"
836+ ],
837+ "columnsTo": [
838+ "id"
839+ ],
840+ "onDelete": "cascade",
841+ "onUpdate": "no action"
842+ }
843+ },
844+ "compositePrimaryKeys": {},
845+ "uniqueConstraints": {},
846+ "policies": {},
847+ "checkConstraints": {},
848+ "isRLSEnabled": false
849+ },
850+ "public.message_attachments": {
851+ "name": "message_attachments",
852+ "schema": "",
853+ "columns": {
854+ "id": {
855+ "name": "id",
856+ "type": "text",
857+ "primaryKey": true,
858+ "notNull": true
859+ },
860+ "user_id": {
861+ "name": "user_id",
862+ "type": "text",
863+ "primaryKey": false,
864+ "notNull": true
865+ },
866+ "message_id": {
867+ "name": "message_id",
868+ "type": "text",
869+ "primaryKey": false,
870+ "notNull": false
871+ },
872+ "conversation_id": {
873+ "name": "conversation_id",
874+ "type": "text",
875+ "primaryKey": false,
876+ "notNull": false
877+ },
878+ "kind": {
879+ "name": "kind",
880+ "type": "text",
881+ "primaryKey": false,
882+ "notNull": true
883+ },
884+ "name": {
885+ "name": "name",
886+ "type": "text",
887+ "primaryKey": false,
888+ "notNull": true
889+ },
890+ "mime_type": {
891+ "name": "mime_type",
892+ "type": "text",
893+ "primaryKey": false,
894+ "notNull": true
895+ },
896+ "size_bytes": {
897+ "name": "size_bytes",
898+ "type": "integer",
899+ "primaryKey": false,
900+ "notNull": true
901+ },
902+ "data_base64": {
903+ "name": "data_base64",
904+ "type": "text",
905+ "primaryKey": false,
906+ "notNull": true
907+ },
908+ "width": {
909+ "name": "width",
910+ "type": "integer",
911+ "primaryKey": false,
912+ "notNull": false
913+ },
914+ "height": {
915+ "name": "height",
916+ "type": "integer",
917+ "primaryKey": false,
918+ "notNull": false
919+ },
920+ "created_at": {
921+ "name": "created_at",
922+ "type": "timestamp with time zone",
923+ "primaryKey": false,
924+ "notNull": true,
925+ "default": "now()"
926+ }
927+ },
928+ "indexes": {
929+ "attachments_message_idx": {
930+ "name": "attachments_message_idx",
931+ "columns": [
932+ {
933+ "expression": "message_id",
934+ "isExpression": false,
935+ "asc": true,
936+ "nulls": "last"
937+ }
938+ ],
939+ "isUnique": false,
940+ "concurrently": false,
941+ "method": "btree",
942+ "with": {}
943+ },
944+ "attachments_user_idx": {
945+ "name": "attachments_user_idx",
946+ "columns": [
947+ {
948+ "expression": "user_id",
949+ "isExpression": false,
950+ "asc": true,
951+ "nulls": "last"
952+ }
953+ ],
954+ "isUnique": false,
955+ "concurrently": false,
956+ "method": "btree",
957+ "with": {}
958+ }
959+ },
960+ "foreignKeys": {
961+ "message_attachments_user_id_users_id_fk": {
962+ "name": "message_attachments_user_id_users_id_fk",
963+ "tableFrom": "message_attachments",
964+ "tableTo": "users",
965+ "columnsFrom": [
966+ "user_id"
967+ ],
968+ "columnsTo": [
969+ "id"
970+ ],
971+ "onDelete": "cascade",
972+ "onUpdate": "no action"
973+ },
974+ "message_attachments_message_id_messages_id_fk": {
975+ "name": "message_attachments_message_id_messages_id_fk",
976+ "tableFrom": "message_attachments",
977+ "tableTo": "messages",
978+ "columnsFrom": [
979+ "message_id"
980+ ],
981+ "columnsTo": [
982+ "id"
983+ ],
984+ "onDelete": "cascade",
985+ "onUpdate": "no action"
986+ },
987+ "message_attachments_conversation_id_conversations_id_fk": {
988+ "name": "message_attachments_conversation_id_conversations_id_fk",
989+ "tableFrom": "message_attachments",
990+ "tableTo": "conversations",
991+ "columnsFrom": [
992+ "conversation_id"
993+ ],
994+ "columnsTo": [
995+ "id"
996+ ],
997+ "onDelete": "cascade",
998+ "onUpdate": "no action"
999+ }
1000+ },
1001+ "compositePrimaryKeys": {},
1002+ "uniqueConstraints": {},
1003+ "policies": {},
1004+ "checkConstraints": {},
1005+ "isRLSEnabled": false
1006+ },
1007+ "public.messages": {
1008+ "name": "messages",
1009+ "schema": "",
1010+ "columns": {
1011+ "id": {
1012+ "name": "id",
1013+ "type": "text",
1014+ "primaryKey": true,
1015+ "notNull": true
1016+ },
1017+ "conversation_id": {
1018+ "name": "conversation_id",
1019+ "type": "text",
1020+ "primaryKey": false,
1021+ "notNull": true
1022+ },
1023+ "user_id": {
1024+ "name": "user_id",
1025+ "type": "text",
1026+ "primaryKey": false,
1027+ "notNull": true
1028+ },
1029+ "role": {
1030+ "name": "role",
1031+ "type": "text",
1032+ "primaryKey": false,
1033+ "notNull": true
1034+ },
1035+ "content": {
1036+ "name": "content",
1037+ "type": "text",
1038+ "primaryKey": false,
1039+ "notNull": true,
1040+ "default": "''"
1041+ },
1042+ "parts": {
1043+ "name": "parts",
1044+ "type": "jsonb",
1045+ "primaryKey": false,
1046+ "notNull": true,
1047+ "default": "'[]'::jsonb"
1048+ },
1049+ "model_key": {
1050+ "name": "model_key",
1051+ "type": "text",
1052+ "primaryKey": false,
1053+ "notNull": false
1054+ },
1055+ "provider": {
1056+ "name": "provider",
1057+ "type": "text",
1058+ "primaryKey": false,
1059+ "notNull": false
1060+ },
1061+ "status": {
1062+ "name": "status",
1063+ "type": "text",
1064+ "primaryKey": false,
1065+ "notNull": true,
1066+ "default": "'complete'"
1067+ },
1068+ "finish_reason": {
1069+ "name": "finish_reason",
1070+ "type": "text",
1071+ "primaryKey": false,
1072+ "notNull": false
1073+ },
1074+ "error": {
1075+ "name": "error",
1076+ "type": "jsonb",
1077+ "primaryKey": false,
1078+ "notNull": false
1079+ },
1080+ "usage": {
1081+ "name": "usage",
1082+ "type": "jsonb",
1083+ "primaryKey": false,
1084+ "notNull": false
1085+ },
1086+ "settings": {
1087+ "name": "settings",
1088+ "type": "jsonb",
1089+ "primaryKey": false,
1090+ "notNull": false
1091+ },
1092+ "latency_ms": {
1093+ "name": "latency_ms",
1094+ "type": "integer",
1095+ "primaryKey": false,
1096+ "notNull": false
1097+ },
1098+ "ttft_ms": {
1099+ "name": "ttft_ms",
1100+ "type": "integer",
1101+ "primaryKey": false,
1102+ "notNull": false
1103+ },
1104+ "cost_usd": {
1105+ "name": "cost_usd",
1106+ "type": "double precision",
1107+ "primaryKey": false,
1108+ "notNull": false
1109+ },
1110+ "parent_message_id": {
1111+ "name": "parent_message_id",
1112+ "type": "text",
1113+ "primaryKey": false,
1114+ "notNull": false
1115+ },
1116+ "version": {
1117+ "name": "version",
1118+ "type": "integer",
1119+ "primaryKey": false,
1120+ "notNull": true,
1121+ "default": 1
1122+ },
1123+ "active": {
1124+ "name": "active",
1125+ "type": "boolean",
1126+ "primaryKey": false,
1127+ "notNull": true,
1128+ "default": true
1129+ },
1130+ "created_at": {
1131+ "name": "created_at",
1132+ "type": "timestamp with time zone",
1133+ "primaryKey": false,
1134+ "notNull": true,
1135+ "default": "now()"
1136+ },
1137+ "updated_at": {
1138+ "name": "updated_at",
1139+ "type": "timestamp with time zone",
1140+ "primaryKey": false,
1141+ "notNull": true,
1142+ "default": "now()"
1143+ }
1144+ },
1145+ "indexes": {
1146+ "messages_conversation_created_idx": {
1147+ "name": "messages_conversation_created_idx",
1148+ "columns": [
1149+ {
1150+ "expression": "conversation_id",
1151+ "isExpression": false,
1152+ "asc": true,
1153+ "nulls": "last"
1154+ },
1155+ {
1156+ "expression": "created_at",
1157+ "isExpression": false,
1158+ "asc": true,
1159+ "nulls": "last"
1160+ }
1161+ ],
1162+ "isUnique": false,
1163+ "concurrently": false,
1164+ "method": "btree",
1165+ "with": {}
1166+ },
1167+ "messages_user_idx": {
1168+ "name": "messages_user_idx",
1169+ "columns": [
1170+ {
1171+ "expression": "user_id",
1172+ "isExpression": false,
1173+ "asc": true,
1174+ "nulls": "last"
1175+ }
1176+ ],
1177+ "isUnique": false,
1178+ "concurrently": false,
1179+ "method": "btree",
1180+ "with": {}
1181+ }
1182+ },
1183+ "foreignKeys": {
1184+ "messages_conversation_id_conversations_id_fk": {
1185+ "name": "messages_conversation_id_conversations_id_fk",
1186+ "tableFrom": "messages",
1187+ "tableTo": "conversations",
1188+ "columnsFrom": [
1189+ "conversation_id"
1190+ ],
1191+ "columnsTo": [
1192+ "id"
1193+ ],
1194+ "onDelete": "cascade",
1195+ "onUpdate": "no action"
1196+ },
1197+ "messages_user_id_users_id_fk": {
1198+ "name": "messages_user_id_users_id_fk",
1199+ "tableFrom": "messages",
1200+ "tableTo": "users",
1201+ "columnsFrom": [
1202+ "user_id"
1203+ ],
1204+ "columnsTo": [
1205+ "id"
1206+ ],
1207+ "onDelete": "cascade",
1208+ "onUpdate": "no action"
1209+ }
1210+ },
1211+ "compositePrimaryKeys": {},
1212+ "uniqueConstraints": {},
1213+ "policies": {},
1214+ "checkConstraints": {},
1215+ "isRLSEnabled": false
1216+ },
1217+ "public.model_presets": {
1218+ "name": "model_presets",
1219+ "schema": "",
1220+ "columns": {
1221+ "id": {
1222+ "name": "id",
1223+ "type": "text",
1224+ "primaryKey": true,
1225+ "notNull": true
1226+ },
1227+ "user_id": {
1228+ "name": "user_id",
1229+ "type": "text",
1230+ "primaryKey": false,
1231+ "notNull": true
1232+ },
1233+ "name": {
1234+ "name": "name",
1235+ "type": "text",
1236+ "primaryKey": false,
1237+ "notNull": true
1238+ },
1239+ "description": {
1240+ "name": "description",
1241+ "type": "text",
1242+ "primaryKey": false,
1243+ "notNull": false
1244+ },
1245+ "icon": {
1246+ "name": "icon",
1247+ "type": "text",
1248+ "primaryKey": false,
1249+ "notNull": false
1250+ },
1251+ "model_key": {
1252+ "name": "model_key",
1253+ "type": "text",
1254+ "primaryKey": false,
1255+ "notNull": true
1256+ },
1257+ "system_prompt": {
1258+ "name": "system_prompt",
1259+ "type": "text",
1260+ "primaryKey": false,
1261+ "notNull": false
1262+ },
1263+ "parameters": {
1264+ "name": "parameters",
1265+ "type": "jsonb",
1266+ "primaryKey": false,
1267+ "notNull": true,
1268+ "default": "'{}'::jsonb"
1269+ },
1270+ "tools": {
1271+ "name": "tools",
1272+ "type": "jsonb",
1273+ "primaryKey": false,
1274+ "notNull": true,
1275+ "default": "'{}'::jsonb"
1276+ },
1277+ "file_settings": {
1278+ "name": "file_settings",
1279+ "type": "jsonb",
1280+ "primaryKey": false,
1281+ "notNull": true,
1282+ "default": "'{}'::jsonb"
1283+ },
1284+ "sort_order": {
1285+ "name": "sort_order",
1286+ "type": "integer",
1287+ "primaryKey": false,
1288+ "notNull": true,
1289+ "default": 0
1290+ },
1291+ "created_at": {
1292+ "name": "created_at",
1293+ "type": "timestamp with time zone",
1294+ "primaryKey": false,
1295+ "notNull": true,
1296+ "default": "now()"
1297+ },
1298+ "updated_at": {
1299+ "name": "updated_at",
1300+ "type": "timestamp with time zone",
1301+ "primaryKey": false,
1302+ "notNull": true,
1303+ "default": "now()"
1304+ }
1305+ },
1306+ "indexes": {
1307+ "model_presets_user_idx": {
1308+ "name": "model_presets_user_idx",
1309+ "columns": [
1310+ {
1311+ "expression": "user_id",
1312+ "isExpression": false,
1313+ "asc": true,
1314+ "nulls": "last"
1315+ }
1316+ ],
1317+ "isUnique": false,
1318+ "concurrently": false,
1319+ "method": "btree",
1320+ "with": {}
1321+ }
1322+ },
1323+ "foreignKeys": {
1324+ "model_presets_user_id_users_id_fk": {
1325+ "name": "model_presets_user_id_users_id_fk",
1326+ "tableFrom": "model_presets",
1327+ "tableTo": "users",
1328+ "columnsFrom": [
1329+ "user_id"
1330+ ],
1331+ "columnsTo": [
1332+ "id"
1333+ ],
1334+ "onDelete": "cascade",
1335+ "onUpdate": "no action"
1336+ }
1337+ },
1338+ "compositePrimaryKeys": {},
1339+ "uniqueConstraints": {},
1340+ "policies": {},
1341+ "checkConstraints": {},
1342+ "isRLSEnabled": false
1343+ },
1344+ "public.model_sync_runs": {
1345+ "name": "model_sync_runs",
1346+ "schema": "",
1347+ "columns": {
1348+ "id": {
1349+ "name": "id",
1350+ "type": "text",
1351+ "primaryKey": true,
1352+ "notNull": true
1353+ },
1354+ "provider": {
1355+ "name": "provider",
1356+ "type": "text",
1357+ "primaryKey": false,
1358+ "notNull": true
1359+ },
1360+ "triggered_by": {
1361+ "name": "triggered_by",
1362+ "type": "text",
1363+ "primaryKey": false,
1364+ "notNull": true,
1365+ "default": "'schedule'"
1366+ },
1367+ "started_at": {
1368+ "name": "started_at",
1369+ "type": "timestamp with time zone",
1370+ "primaryKey": false,
1371+ "notNull": true,
1372+ "default": "now()"
1373+ },
1374+ "finished_at": {
1375+ "name": "finished_at",
1376+ "type": "timestamp with time zone",
1377+ "primaryKey": false,
1378+ "notNull": false
1379+ },
1380+ "ok": {
1381+ "name": "ok",
1382+ "type": "boolean",
1383+ "primaryKey": false,
1384+ "notNull": false
1385+ },
1386+ "models_found": {
1387+ "name": "models_found",
1388+ "type": "integer",
1389+ "primaryKey": false,
1390+ "notNull": false
1391+ },
1392+ "models_added": {
1393+ "name": "models_added",
1394+ "type": "integer",
1395+ "primaryKey": false,
1396+ "notNull": false
1397+ },
1398+ "models_removed": {
1399+ "name": "models_removed",
1400+ "type": "integer",
1401+ "primaryKey": false,
1402+ "notNull": false
1403+ },
1404+ "latency_ms": {
1405+ "name": "latency_ms",
1406+ "type": "integer",
1407+ "primaryKey": false,
1408+ "notNull": false
1409+ },
1410+ "error_code": {
1411+ "name": "error_code",
1412+ "type": "text",
1413+ "primaryKey": false,
1414+ "notNull": false
1415+ },
1416+ "error_message": {
1417+ "name": "error_message",
1418+ "type": "text",
1419+ "primaryKey": false,
1420+ "notNull": false
1421+ }
1422+ },
1423+ "indexes": {
1424+ "model_sync_runs_provider_started_idx": {
1425+ "name": "model_sync_runs_provider_started_idx",
1426+ "columns": [
1427+ {
1428+ "expression": "provider",
1429+ "isExpression": false,
1430+ "asc": true,
1431+ "nulls": "last"
1432+ },
1433+ {
1434+ "expression": "started_at",
1435+ "isExpression": false,
1436+ "asc": true,
1437+ "nulls": "last"
1438+ }
1439+ ],
1440+ "isUnique": false,
1441+ "concurrently": false,
1442+ "method": "btree",
1443+ "with": {}
1444+ }
1445+ },
1446+ "foreignKeys": {},
1447+ "compositePrimaryKeys": {},
1448+ "uniqueConstraints": {},
1449+ "policies": {},
1450+ "checkConstraints": {},
1451+ "isRLSEnabled": false
1452+ },
1453+ "public.models": {
1454+ "name": "models",
1455+ "schema": "",
1456+ "columns": {
1457+ "key": {
1458+ "name": "key",
1459+ "type": "text",
1460+ "primaryKey": true,
1461+ "notNull": true
1462+ },
1463+ "provider": {
1464+ "name": "provider",
1465+ "type": "text",
1466+ "primaryKey": false,
1467+ "notNull": true
1468+ },
1469+ "model_id": {
1470+ "name": "model_id",
1471+ "type": "text",
1472+ "primaryKey": false,
1473+ "notNull": true
1474+ },
1475+ "display_name": {
1476+ "name": "display_name",
1477+ "type": "text",
1478+ "primaryKey": false,
1479+ "notNull": true
1480+ },
1481+ "family": {
1482+ "name": "family",
1483+ "type": "text",
1484+ "primaryKey": false,
1485+ "notNull": false
1486+ },
1487+ "capabilities": {
1488+ "name": "capabilities",
1489+ "type": "jsonb",
1490+ "primaryKey": false,
1491+ "notNull": true
1492+ },
1493+ "limits": {
1494+ "name": "limits",
1495+ "type": "jsonb",
1496+ "primaryKey": false,
1497+ "notNull": true,
1498+ "default": "'{}'::jsonb"
1499+ },
1500+ "parameters": {
1501+ "name": "parameters",
1502+ "type": "jsonb",
1503+ "primaryKey": false,
1504+ "notNull": true,
1505+ "default": "'{}'::jsonb"
1506+ },
1507+ "pricing": {
1508+ "name": "pricing",
1509+ "type": "jsonb",
1510+ "primaryKey": false,
1511+ "notNull": false
1512+ },
1513+ "status": {
1514+ "name": "status",
1515+ "type": "text",
1516+ "primaryKey": false,
1517+ "notNull": true,
1518+ "default": "'unknown'"
1519+ },
1520+ "source": {
1521+ "name": "source",
1522+ "type": "text",
1523+ "primaryKey": false,
1524+ "notNull": true,
1525+ "default": "'catalog'"
1526+ },
1527+ "hidden": {
1528+ "name": "hidden",
1529+ "type": "boolean",
1530+ "primaryKey": false,
1531+ "notNull": true,
1532+ "default": false
1533+ },
1534+ "sort_weight": {
1535+ "name": "sort_weight",
1536+ "type": "integer",
1537+ "primaryKey": false,
1538+ "notNull": true,
1539+ "default": 0
1540+ },
1541+ "metadata": {
1542+ "name": "metadata",
1543+ "type": "jsonb",
1544+ "primaryKey": false,
1545+ "notNull": true,
1546+ "default": "'{}'::jsonb"
1547+ },
1548+ "first_seen_at": {
1549+ "name": "first_seen_at",
1550+ "type": "timestamp with time zone",
1551+ "primaryKey": false,
1552+ "notNull": true,
1553+ "default": "now()"
1554+ },
1555+ "last_seen_at": {
1556+ "name": "last_seen_at",
1557+ "type": "timestamp with time zone",
1558+ "primaryKey": false,
1559+ "notNull": true,
1560+ "default": "now()"
1561+ },
1562+ "updated_at": {
1563+ "name": "updated_at",
1564+ "type": "timestamp with time zone",
1565+ "primaryKey": false,
1566+ "notNull": true,
1567+ "default": "now()"
1568+ }
1569+ },
1570+ "indexes": {
1571+ "models_provider_idx": {
1572+ "name": "models_provider_idx",
1573+ "columns": [
1574+ {
1575+ "expression": "provider",
1576+ "isExpression": false,
1577+ "asc": true,
1578+ "nulls": "last"
1579+ }
1580+ ],
1581+ "isUnique": false,
1582+ "concurrently": false,
1583+ "method": "btree",
1584+ "with": {}
1585+ },
1586+ "models_status_idx": {
1587+ "name": "models_status_idx",
1588+ "columns": [
1589+ {
1590+ "expression": "status",
1591+ "isExpression": false,
1592+ "asc": true,
1593+ "nulls": "last"
1594+ }
1595+ ],
1596+ "isUnique": false,
1597+ "concurrently": false,
1598+ "method": "btree",
1599+ "with": {}
1600+ }
1601+ },
1602+ "foreignKeys": {},
1603+ "compositePrimaryKeys": {},
1604+ "uniqueConstraints": {},
1605+ "policies": {},
1606+ "checkConstraints": {},
1607+ "isRLSEnabled": false
1608+ },
1609+ "public.prompt_presets": {
1610+ "name": "prompt_presets",
1611+ "schema": "",
1612+ "columns": {
1613+ "id": {
1614+ "name": "id",
1615+ "type": "text",
1616+ "primaryKey": true,
1617+ "notNull": true
1618+ },
1619+ "user_id": {
1620+ "name": "user_id",
1621+ "type": "text",
1622+ "primaryKey": false,
1623+ "notNull": true
1624+ },
1625+ "name": {
1626+ "name": "name",
1627+ "type": "text",
1628+ "primaryKey": false,
1629+ "notNull": true
1630+ },
1631+ "description": {
1632+ "name": "description",
1633+ "type": "text",
1634+ "primaryKey": false,
1635+ "notNull": false
1636+ },
1637+ "icon": {
1638+ "name": "icon",
1639+ "type": "text",
1640+ "primaryKey": false,
1641+ "notNull": false
1642+ },
1643+ "system_prompt": {
1644+ "name": "system_prompt",
1645+ "type": "text",
1646+ "primaryKey": false,
1647+ "notNull": true
1648+ },
1649+ "default_model_key": {
1650+ "name": "default_model_key",
1651+ "type": "text",
1652+ "primaryKey": false,
1653+ "notNull": false
1654+ },
1655+ "parameters": {
1656+ "name": "parameters",
1657+ "type": "jsonb",
1658+ "primaryKey": false,
1659+ "notNull": true,
1660+ "default": "'{}'::jsonb"
1661+ },
1662+ "tools": {
1663+ "name": "tools",
1664+ "type": "jsonb",
1665+ "primaryKey": false,
1666+ "notNull": true,
1667+ "default": "'{}'::jsonb"
1668+ },
1669+ "sort_order": {
1670+ "name": "sort_order",
1671+ "type": "integer",
1672+ "primaryKey": false,
1673+ "notNull": true,
1674+ "default": 0
1675+ },
1676+ "created_at": {
1677+ "name": "created_at",
1678+ "type": "timestamp with time zone",
1679+ "primaryKey": false,
1680+ "notNull": true,
1681+ "default": "now()"
1682+ },
1683+ "updated_at": {
1684+ "name": "updated_at",
1685+ "type": "timestamp with time zone",
1686+ "primaryKey": false,
1687+ "notNull": true,
1688+ "default": "now()"
1689+ }
1690+ },
1691+ "indexes": {
1692+ "prompt_presets_user_idx": {
1693+ "name": "prompt_presets_user_idx",
1694+ "columns": [
1695+ {
1696+ "expression": "user_id",
1697+ "isExpression": false,
1698+ "asc": true,
1699+ "nulls": "last"
1700+ }
1701+ ],
1702+ "isUnique": false,
1703+ "concurrently": false,
1704+ "method": "btree",
1705+ "with": {}
1706+ }
1707+ },
1708+ "foreignKeys": {
1709+ "prompt_presets_user_id_users_id_fk": {
1710+ "name": "prompt_presets_user_id_users_id_fk",
1711+ "tableFrom": "prompt_presets",
1712+ "tableTo": "users",
1713+ "columnsFrom": [
1714+ "user_id"
1715+ ],
1716+ "columnsTo": [
1717+ "id"
1718+ ],
1719+ "onDelete": "cascade",
1720+ "onUpdate": "no action"
1721+ }
1722+ },
1723+ "compositePrimaryKeys": {},
1724+ "uniqueConstraints": {},
1725+ "policies": {},
1726+ "checkConstraints": {},
1727+ "isRLSEnabled": false
1728+ },
1729+ "public.provider_connections": {
1730+ "name": "provider_connections",
1731+ "schema": "",
1732+ "columns": {
1733+ "id": {
1734+ "name": "id",
1735+ "type": "text",
1736+ "primaryKey": true,
1737+ "notNull": true
1738+ },
1739+ "user_id": {
1740+ "name": "user_id",
1741+ "type": "text",
1742+ "primaryKey": false,
1743+ "notNull": true
1744+ },
1745+ "provider": {
1746+ "name": "provider",
1747+ "type": "text",
1748+ "primaryKey": false,
1749+ "notNull": true
1750+ },
1751+ "encrypted_key": {
1752+ "name": "encrypted_key",
1753+ "type": "text",
1754+ "primaryKey": false,
1755+ "notNull": true
1756+ },
1757+ "key_hint": {
1758+ "name": "key_hint",
1759+ "type": "text",
1760+ "primaryKey": false,
1761+ "notNull": true
1762+ },
1763+ "key_fingerprint": {
1764+ "name": "key_fingerprint",
1765+ "type": "text",
1766+ "primaryKey": false,
1767+ "notNull": true
1768+ },
1769+ "status": {
1770+ "name": "status",
1771+ "type": "text",
1772+ "primaryKey": false,
1773+ "notNull": true,
1774+ "default": "'unverified'"
1775+ },
1776+ "last_validated_at": {
1777+ "name": "last_validated_at",
1778+ "type": "timestamp with time zone",
1779+ "primaryKey": false,
1780+ "notNull": false
1781+ },
1782+ "last_validation_error": {
1783+ "name": "last_validation_error",
1784+ "type": "text",
1785+ "primaryKey": false,
1786+ "notNull": false
1787+ },
1788+ "last_success_at": {
1789+ "name": "last_success_at",
1790+ "type": "timestamp with time zone",
1791+ "primaryKey": false,
1792+ "notNull": false
1793+ },
1794+ "last_error_at": {
1795+ "name": "last_error_at",
1796+ "type": "timestamp with time zone",
1797+ "primaryKey": false,
1798+ "notNull": false
1799+ },
1800+ "last_error_code": {
1801+ "name": "last_error_code",
1802+ "type": "text",
1803+ "primaryKey": false,
1804+ "notNull": false
1805+ },
1806+ "models_available": {
1807+ "name": "models_available",
1808+ "type": "integer",
1809+ "primaryKey": false,
1810+ "notNull": false
1811+ },
1812+ "created_at": {
1813+ "name": "created_at",
1814+ "type": "timestamp with time zone",
1815+ "primaryKey": false,
1816+ "notNull": true,
1817+ "default": "now()"
1818+ },
1819+ "updated_at": {
1820+ "name": "updated_at",
1821+ "type": "timestamp with time zone",
1822+ "primaryKey": false,
1823+ "notNull": true,
1824+ "default": "now()"
1825+ }
1826+ },
1827+ "indexes": {
1828+ "provider_connections_user_provider_uq": {
1829+ "name": "provider_connections_user_provider_uq",
1830+ "columns": [
1831+ {
1832+ "expression": "user_id",
1833+ "isExpression": false,
1834+ "asc": true,
1835+ "nulls": "last"
1836+ },
1837+ {
1838+ "expression": "provider",
1839+ "isExpression": false,
1840+ "asc": true,
1841+ "nulls": "last"
1842+ }
1843+ ],
1844+ "isUnique": true,
1845+ "concurrently": false,
1846+ "method": "btree",
1847+ "with": {}
1848+ }
1849+ },
1850+ "foreignKeys": {
1851+ "provider_connections_user_id_users_id_fk": {
1852+ "name": "provider_connections_user_id_users_id_fk",
1853+ "tableFrom": "provider_connections",
1854+ "tableTo": "users",
1855+ "columnsFrom": [
1856+ "user_id"
1857+ ],
1858+ "columnsTo": [
1859+ "id"
1860+ ],
1861+ "onDelete": "cascade",
1862+ "onUpdate": "no action"
1863+ }
1864+ },
1865+ "compositePrimaryKeys": {},
1866+ "uniqueConstraints": {},
1867+ "policies": {},
1868+ "checkConstraints": {},
1869+ "isRLSEnabled": false
1870+ },
1871+ "public.sessions": {
1872+ "name": "sessions",
1873+ "schema": "",
1874+ "columns": {
1875+ "id": {
1876+ "name": "id",
1877+ "type": "text",
1878+ "primaryKey": true,
1879+ "notNull": true
1880+ },
1881+ "expires_at": {
1882+ "name": "expires_at",
1883+ "type": "timestamp with time zone",
1884+ "primaryKey": false,
1885+ "notNull": true
1886+ },
1887+ "token": {
1888+ "name": "token",
1889+ "type": "text",
1890+ "primaryKey": false,
1891+ "notNull": true
1892+ },
1893+ "created_at": {
1894+ "name": "created_at",
1895+ "type": "timestamp with time zone",
1896+ "primaryKey": false,
1897+ "notNull": true,
1898+ "default": "now()"
1899+ },
1900+ "updated_at": {
1901+ "name": "updated_at",
1902+ "type": "timestamp with time zone",
1903+ "primaryKey": false,
1904+ "notNull": true,
1905+ "default": "now()"
1906+ },
1907+ "ip_address": {
1908+ "name": "ip_address",
1909+ "type": "text",
1910+ "primaryKey": false,
1911+ "notNull": false
1912+ },
1913+ "user_agent": {
1914+ "name": "user_agent",
1915+ "type": "text",
1916+ "primaryKey": false,
1917+ "notNull": false
1918+ },
1919+ "user_id": {
1920+ "name": "user_id",
1921+ "type": "text",
1922+ "primaryKey": false,
1923+ "notNull": true
1924+ }
1925+ },
1926+ "indexes": {
1927+ "sessions_token_uq": {
1928+ "name": "sessions_token_uq",
1929+ "columns": [
1930+ {
1931+ "expression": "token",
1932+ "isExpression": false,
1933+ "asc": true,
1934+ "nulls": "last"
1935+ }
1936+ ],
1937+ "isUnique": true,
1938+ "concurrently": false,
1939+ "method": "btree",
1940+ "with": {}
1941+ },
1942+ "sessions_user_idx": {
1943+ "name": "sessions_user_idx",
1944+ "columns": [
1945+ {
1946+ "expression": "user_id",
1947+ "isExpression": false,
1948+ "asc": true,
1949+ "nulls": "last"
1950+ }
1951+ ],
1952+ "isUnique": false,
1953+ "concurrently": false,
1954+ "method": "btree",
1955+ "with": {}
1956+ }
1957+ },
1958+ "foreignKeys": {
1959+ "sessions_user_id_users_id_fk": {
1960+ "name": "sessions_user_id_users_id_fk",
1961+ "tableFrom": "sessions",
1962+ "tableTo": "users",
1963+ "columnsFrom": [
1964+ "user_id"
1965+ ],
1966+ "columnsTo": [
1967+ "id"
1968+ ],
1969+ "onDelete": "cascade",
1970+ "onUpdate": "no action"
1971+ }
1972+ },
1973+ "compositePrimaryKeys": {},
1974+ "uniqueConstraints": {},
1975+ "policies": {},
1976+ "checkConstraints": {},
1977+ "isRLSEnabled": false
1978+ },
1979+ "public.shared_conversations": {
1980+ "name": "shared_conversations",
1981+ "schema": "",
1982+ "columns": {
1983+ "id": {
1984+ "name": "id",
1985+ "type": "text",
1986+ "primaryKey": true,
1987+ "notNull": true
1988+ },
1989+ "conversation_id": {
1990+ "name": "conversation_id",
1991+ "type": "text",
1992+ "primaryKey": false,
1993+ "notNull": true
1994+ },
1995+ "user_id": {
1996+ "name": "user_id",
1997+ "type": "text",
1998+ "primaryKey": false,
1999+ "notNull": true
2000+ },
2001+ "title": {
2002+ "name": "title",
2003+ "type": "text",
2004+ "primaryKey": false,
2005+ "notNull": true
2006+ },
2007+ "snapshot": {
2008+ "name": "snapshot",
2009+ "type": "jsonb",
2010+ "primaryKey": false,
2011+ "notNull": true
2012+ },
2013+ "is_public": {
2014+ "name": "is_public",
2015+ "type": "boolean",
2016+ "primaryKey": false,
2017+ "notNull": true,
2018+ "default": true
2019+ },
2020+ "view_count": {
2021+ "name": "view_count",
2022+ "type": "integer",
2023+ "primaryKey": false,
2024+ "notNull": true,
2025+ "default": 0
2026+ },
2027+ "created_at": {
2028+ "name": "created_at",
2029+ "type": "timestamp with time zone",
2030+ "primaryKey": false,
2031+ "notNull": true,
2032+ "default": "now()"
2033+ },
2034+ "revoked_at": {
2035+ "name": "revoked_at",
2036+ "type": "timestamp with time zone",
2037+ "primaryKey": false,
2038+ "notNull": false
2039+ }
2040+ },
2041+ "indexes": {
2042+ "shared_conversation_idx": {
2043+ "name": "shared_conversation_idx",
2044+ "columns": [
2045+ {
2046+ "expression": "conversation_id",
2047+ "isExpression": false,
2048+ "asc": true,
2049+ "nulls": "last"
2050+ }
2051+ ],
2052+ "isUnique": false,
2053+ "concurrently": false,
2054+ "method": "btree",
2055+ "with": {}
2056+ }
2057+ },
2058+ "foreignKeys": {
2059+ "shared_conversations_conversation_id_conversations_id_fk": {
2060+ "name": "shared_conversations_conversation_id_conversations_id_fk",
2061+ "tableFrom": "shared_conversations",
2062+ "tableTo": "conversations",
2063+ "columnsFrom": [
2064+ "conversation_id"
2065+ ],
2066+ "columnsTo": [
2067+ "id"
2068+ ],
2069+ "onDelete": "cascade",
2070+ "onUpdate": "no action"
2071+ },
2072+ "shared_conversations_user_id_users_id_fk": {
2073+ "name": "shared_conversations_user_id_users_id_fk",
2074+ "tableFrom": "shared_conversations",
2075+ "tableTo": "users",
2076+ "columnsFrom": [
2077+ "user_id"
2078+ ],
2079+ "columnsTo": [
2080+ "id"
2081+ ],
2082+ "onDelete": "cascade",
2083+ "onUpdate": "no action"
2084+ }
2085+ },
2086+ "compositePrimaryKeys": {},
2087+ "uniqueConstraints": {},
2088+ "policies": {},
2089+ "checkConstraints": {},
2090+ "isRLSEnabled": false
2091+ },
2092+ "public.tags": {
2093+ "name": "tags",
2094+ "schema": "",
2095+ "columns": {
2096+ "id": {
2097+ "name": "id",
2098+ "type": "text",
2099+ "primaryKey": true,
2100+ "notNull": true
2101+ },
2102+ "user_id": {
2103+ "name": "user_id",
2104+ "type": "text",
2105+ "primaryKey": false,
2106+ "notNull": true
2107+ },
2108+ "name": {
2109+ "name": "name",
2110+ "type": "text",
2111+ "primaryKey": false,
2112+ "notNull": true
2113+ },
2114+ "color": {
2115+ "name": "color",
2116+ "type": "text",
2117+ "primaryKey": false,
2118+ "notNull": false
2119+ },
2120+ "created_at": {
2121+ "name": "created_at",
2122+ "type": "timestamp with time zone",
2123+ "primaryKey": false,
2124+ "notNull": true,
2125+ "default": "now()"
2126+ }
2127+ },
2128+ "indexes": {
2129+ "tags_user_name_uq": {
2130+ "name": "tags_user_name_uq",
2131+ "columns": [
2132+ {
2133+ "expression": "user_id",
2134+ "isExpression": false,
2135+ "asc": true,
2136+ "nulls": "last"
2137+ },
2138+ {
2139+ "expression": "name",
2140+ "isExpression": false,
2141+ "asc": true,
2142+ "nulls": "last"
2143+ }
2144+ ],
2145+ "isUnique": true,
2146+ "concurrently": false,
2147+ "method": "btree",
2148+ "with": {}
2149+ }
2150+ },
2151+ "foreignKeys": {
2152+ "tags_user_id_users_id_fk": {
2153+ "name": "tags_user_id_users_id_fk",
2154+ "tableFrom": "tags",
2155+ "tableTo": "users",
2156+ "columnsFrom": [
2157+ "user_id"
2158+ ],
2159+ "columnsTo": [
2160+ "id"
2161+ ],
2162+ "onDelete": "cascade",
2163+ "onUpdate": "no action"
2164+ }
2165+ },
2166+ "compositePrimaryKeys": {},
2167+ "uniqueConstraints": {},
2168+ "policies": {},
2169+ "checkConstraints": {},
2170+ "isRLSEnabled": false
2171+ },
2172+ "public.usage_records": {
2173+ "name": "usage_records",
2174+ "schema": "",
2175+ "columns": {
2176+ "id": {
2177+ "name": "id",
2178+ "type": "text",
2179+ "primaryKey": true,
2180+ "notNull": true
2181+ },
2182+ "user_id": {
2183+ "name": "user_id",
2184+ "type": "text",
2185+ "primaryKey": false,
2186+ "notNull": true
2187+ },
2188+ "conversation_id": {
2189+ "name": "conversation_id",
2190+ "type": "text",
2191+ "primaryKey": false,
2192+ "notNull": false
2193+ },
2194+ "message_id": {
2195+ "name": "message_id",
2196+ "type": "text",
2197+ "primaryKey": false,
2198+ "notNull": false
2199+ },
2200+ "arena_session_id": {
2201+ "name": "arena_session_id",
2202+ "type": "text",
2203+ "primaryKey": false,
2204+ "notNull": false
2205+ },
2206+ "provider": {
2207+ "name": "provider",
2208+ "type": "text",
2209+ "primaryKey": false,
2210+ "notNull": true
2211+ },
2212+ "model_key": {
2213+ "name": "model_key",
2214+ "type": "text",
2215+ "primaryKey": false,
2216+ "notNull": true
2217+ },
2218+ "kind": {
2219+ "name": "kind",
2220+ "type": "text",
2221+ "primaryKey": false,
2222+ "notNull": true,
2223+ "default": "'chat'"
2224+ },
2225+ "status": {
2226+ "name": "status",
2227+ "type": "text",
2228+ "primaryKey": false,
2229+ "notNull": true,
2230+ "default": "'ok'"
2231+ },
2232+ "error_code": {
2233+ "name": "error_code",
2234+ "type": "text",
2235+ "primaryKey": false,
2236+ "notNull": false
2237+ },
2238+ "input_tokens": {
2239+ "name": "input_tokens",
2240+ "type": "integer",
2241+ "primaryKey": false,
2242+ "notNull": true,
2243+ "default": 0
2244+ },
2245+ "output_tokens": {
2246+ "name": "output_tokens",
2247+ "type": "integer",
2248+ "primaryKey": false,
2249+ "notNull": true,
2250+ "default": 0
2251+ },
2252+ "cached_tokens": {
2253+ "name": "cached_tokens",
2254+ "type": "integer",
2255+ "primaryKey": false,
2256+ "notNull": true,
2257+ "default": 0
2258+ },
2259+ "reasoning_tokens": {
2260+ "name": "reasoning_tokens",
2261+ "type": "integer",
2262+ "primaryKey": false,
2263+ "notNull": true,
2264+ "default": 0
2265+ },
2266+ "cost_usd": {
2267+ "name": "cost_usd",
2268+ "type": "double precision",
2269+ "primaryKey": false,
2270+ "notNull": false
2271+ },
2272+ "latency_ms": {
2273+ "name": "latency_ms",
2274+ "type": "integer",
2275+ "primaryKey": false,
2276+ "notNull": false
2277+ },
2278+ "ttft_ms": {
2279+ "name": "ttft_ms",
2280+ "type": "integer",
2281+ "primaryKey": false,
2282+ "notNull": false
2283+ },
2284+ "created_at": {
2285+ "name": "created_at",
2286+ "type": "timestamp with time zone",
2287+ "primaryKey": false,
2288+ "notNull": true,
2289+ "default": "now()"
2290+ }
2291+ },
2292+ "indexes": {
2293+ "usage_user_created_idx": {
2294+ "name": "usage_user_created_idx",
2295+ "columns": [
2296+ {
2297+ "expression": "user_id",
2298+ "isExpression": false,
2299+ "asc": true,
2300+ "nulls": "last"
2301+ },
2302+ {
2303+ "expression": "created_at",
2304+ "isExpression": false,
2305+ "asc": true,
2306+ "nulls": "last"
2307+ }
2308+ ],
2309+ "isUnique": false,
2310+ "concurrently": false,
2311+ "method": "btree",
2312+ "with": {}
2313+ },
2314+ "usage_user_provider_idx": {
2315+ "name": "usage_user_provider_idx",
2316+ "columns": [
2317+ {
2318+ "expression": "user_id",
2319+ "isExpression": false,
2320+ "asc": true,
2321+ "nulls": "last"
2322+ },
2323+ {
2324+ "expression": "provider",
2325+ "isExpression": false,
2326+ "asc": true,
2327+ "nulls": "last"
2328+ }
2329+ ],
2330+ "isUnique": false,
2331+ "concurrently": false,
2332+ "method": "btree",
2333+ "with": {}
2334+ },
2335+ "usage_user_model_idx": {
2336+ "name": "usage_user_model_idx",
2337+ "columns": [
2338+ {
2339+ "expression": "user_id",
2340+ "isExpression": false,
2341+ "asc": true,
2342+ "nulls": "last"
2343+ },
2344+ {
2345+ "expression": "model_key",
2346+ "isExpression": false,
2347+ "asc": true,
2348+ "nulls": "last"
2349+ }
2350+ ],
2351+ "isUnique": false,
2352+ "concurrently": false,
2353+ "method": "btree",
2354+ "with": {}
2355+ }
2356+ },
2357+ "foreignKeys": {
2358+ "usage_records_user_id_users_id_fk": {
2359+ "name": "usage_records_user_id_users_id_fk",
2360+ "tableFrom": "usage_records",
2361+ "tableTo": "users",
2362+ "columnsFrom": [
2363+ "user_id"
2364+ ],
2365+ "columnsTo": [
2366+ "id"
2367+ ],
2368+ "onDelete": "cascade",
2369+ "onUpdate": "no action"
2370+ }
2371+ },
2372+ "compositePrimaryKeys": {},
2373+ "uniqueConstraints": {},
2374+ "policies": {},
2375+ "checkConstraints": {},
2376+ "isRLSEnabled": false
2377+ },
2378+ "public.user_model_favorites": {
2379+ "name": "user_model_favorites",
2380+ "schema": "",
2381+ "columns": {
2382+ "user_id": {
2383+ "name": "user_id",
2384+ "type": "text",
2385+ "primaryKey": false,
2386+ "notNull": true
2387+ },
2388+ "model_key": {
2389+ "name": "model_key",
2390+ "type": "text",
2391+ "primaryKey": false,
2392+ "notNull": true
2393+ },
2394+ "created_at": {
2395+ "name": "created_at",
2396+ "type": "timestamp with time zone",
2397+ "primaryKey": false,
2398+ "notNull": true,
2399+ "default": "now()"
2400+ }
2401+ },
2402+ "indexes": {},
2403+ "foreignKeys": {
2404+ "user_model_favorites_user_id_users_id_fk": {
2405+ "name": "user_model_favorites_user_id_users_id_fk",
2406+ "tableFrom": "user_model_favorites",
2407+ "tableTo": "users",
2408+ "columnsFrom": [
2409+ "user_id"
2410+ ],
2411+ "columnsTo": [
2412+ "id"
2413+ ],
2414+ "onDelete": "cascade",
2415+ "onUpdate": "no action"
2416+ }
2417+ },
2418+ "compositePrimaryKeys": {
2419+ "user_model_favorites_user_id_model_key_pk": {
2420+ "name": "user_model_favorites_user_id_model_key_pk",
2421+ "columns": [
2422+ "user_id",
2423+ "model_key"
2424+ ]
2425+ }
2426+ },
2427+ "uniqueConstraints": {},
2428+ "policies": {},
2429+ "checkConstraints": {},
2430+ "isRLSEnabled": false
2431+ },
2432+ "public.user_model_recents": {
2433+ "name": "user_model_recents",
2434+ "schema": "",
2435+ "columns": {
2436+ "user_id": {
2437+ "name": "user_id",
2438+ "type": "text",
2439+ "primaryKey": false,
2440+ "notNull": true
2441+ },
2442+ "model_key": {
2443+ "name": "model_key",
2444+ "type": "text",
2445+ "primaryKey": false,
2446+ "notNull": true
2447+ },
2448+ "used_at": {
2449+ "name": "used_at",
2450+ "type": "timestamp with time zone",
2451+ "primaryKey": false,
2452+ "notNull": true,
2453+ "default": "now()"
2454+ },
2455+ "uses": {
2456+ "name": "uses",
2457+ "type": "integer",
2458+ "primaryKey": false,
2459+ "notNull": true,
2460+ "default": 1
2461+ }
2462+ },
2463+ "indexes": {},
2464+ "foreignKeys": {
2465+ "user_model_recents_user_id_users_id_fk": {
2466+ "name": "user_model_recents_user_id_users_id_fk",
2467+ "tableFrom": "user_model_recents",
2468+ "tableTo": "users",
2469+ "columnsFrom": [
2470+ "user_id"
2471+ ],
2472+ "columnsTo": [
2473+ "id"
2474+ ],
2475+ "onDelete": "cascade",
2476+ "onUpdate": "no action"
2477+ }
2478+ },
2479+ "compositePrimaryKeys": {
2480+ "user_model_recents_user_id_model_key_pk": {
2481+ "name": "user_model_recents_user_id_model_key_pk",
2482+ "columns": [
2483+ "user_id",
2484+ "model_key"
2485+ ]
2486+ }
2487+ },
2488+ "uniqueConstraints": {},
2489+ "policies": {},
2490+ "checkConstraints": {},
2491+ "isRLSEnabled": false
2492+ },
2493+ "public.user_preferences": {
2494+ "name": "user_preferences",
2495+ "schema": "",
2496+ "columns": {
2497+ "user_id": {
2498+ "name": "user_id",
2499+ "type": "text",
2500+ "primaryKey": true,
2501+ "notNull": true
2502+ },
2503+ "theme": {
2504+ "name": "theme",
2505+ "type": "text",
2506+ "primaryKey": false,
2507+ "notNull": true,
2508+ "default": "'system'"
2509+ },
2510+ "language": {
2511+ "name": "language",
2512+ "type": "text",
2513+ "primaryKey": false,
2514+ "notNull": true,
2515+ "default": "'en'"
2516+ },
2517+ "default_model_key": {
2518+ "name": "default_model_key",
2519+ "type": "text",
2520+ "primaryKey": false,
2521+ "notNull": false
2522+ },
2523+ "default_system_prompt": {
2524+ "name": "default_system_prompt",
2525+ "type": "text",
2526+ "primaryKey": false,
2527+ "notNull": false
2528+ },
2529+ "enter_to_send": {
2530+ "name": "enter_to_send",
2531+ "type": "boolean",
2532+ "primaryKey": false,
2533+ "notNull": true,
2534+ "default": true
2535+ },
2536+ "streaming": {
2537+ "name": "streaming",
2538+ "type": "boolean",
2539+ "primaryKey": false,
2540+ "notNull": true,
2541+ "default": true
2542+ },
2543+ "code_wrap": {
2544+ "name": "code_wrap",
2545+ "type": "boolean",
2546+ "primaryKey": false,
2547+ "notNull": true,
2548+ "default": false
2549+ },
2550+ "show_reasoning": {
2551+ "name": "show_reasoning",
2552+ "type": "boolean",
2553+ "primaryKey": false,
2554+ "notNull": true,
2555+ "default": true
2556+ },
2557+ "show_costs": {
2558+ "name": "show_costs",
2559+ "type": "boolean",
2560+ "primaryKey": false,
2561+ "notNull": true,
2562+ "default": true
2563+ },
2564+ "auto_title": {
2565+ "name": "auto_title",
2566+ "type": "boolean",
2567+ "primaryKey": false,
2568+ "notNull": true,
2569+ "default": true
2570+ },
2571+ "extra": {
2572+ "name": "extra",
2573+ "type": "jsonb",
2574+ "primaryKey": false,
2575+ "notNull": true,
2576+ "default": "'{}'::jsonb"
2577+ },
2578+ "updated_at": {
2579+ "name": "updated_at",
2580+ "type": "timestamp with time zone",
2581+ "primaryKey": false,
2582+ "notNull": true,
2583+ "default": "now()"
2584+ }
2585+ },
2586+ "indexes": {},
2587+ "foreignKeys": {
2588+ "user_preferences_user_id_users_id_fk": {
2589+ "name": "user_preferences_user_id_users_id_fk",
2590+ "tableFrom": "user_preferences",
2591+ "tableTo": "users",
2592+ "columnsFrom": [
2593+ "user_id"
2594+ ],
2595+ "columnsTo": [
2596+ "id"
2597+ ],
2598+ "onDelete": "cascade",
2599+ "onUpdate": "no action"
2600+ }
2601+ },
2602+ "compositePrimaryKeys": {},
2603+ "uniqueConstraints": {},
2604+ "policies": {},
2605+ "checkConstraints": {},
2606+ "isRLSEnabled": false
2607+ },
2608+ "public.users": {
2609+ "name": "users",
2610+ "schema": "",
2611+ "columns": {
2612+ "id": {
2613+ "name": "id",
2614+ "type": "text",
2615+ "primaryKey": true,
2616+ "notNull": true
2617+ },
2618+ "name": {
2619+ "name": "name",
2620+ "type": "text",
2621+ "primaryKey": false,
2622+ "notNull": true,
2623+ "default": "''"
2624+ },
2625+ "email": {
2626+ "name": "email",
2627+ "type": "text",
2628+ "primaryKey": false,
2629+ "notNull": true
2630+ },
2631+ "email_verified": {
2632+ "name": "email_verified",
2633+ "type": "boolean",
2634+ "primaryKey": false,
2635+ "notNull": true,
2636+ "default": false
2637+ },
2638+ "image": {
2639+ "name": "image",
2640+ "type": "text",
2641+ "primaryKey": false,
2642+ "notNull": false
2643+ },
2644+ "role": {
2645+ "name": "role",
2646+ "type": "text",
2647+ "primaryKey": false,
2648+ "notNull": true,
2649+ "default": "'user'"
2650+ },
2651+ "onboarding_completed_at": {
2652+ "name": "onboarding_completed_at",
2653+ "type": "timestamp with time zone",
2654+ "primaryKey": false,
2655+ "notNull": false
2656+ },
2657+ "created_at": {
2658+ "name": "created_at",
2659+ "type": "timestamp with time zone",
2660+ "primaryKey": false,
2661+ "notNull": true,
2662+ "default": "now()"
2663+ },
2664+ "updated_at": {
2665+ "name": "updated_at",
2666+ "type": "timestamp with time zone",
2667+ "primaryKey": false,
2668+ "notNull": true,
2669+ "default": "now()"
2670+ }
2671+ },
2672+ "indexes": {
2673+ "users_email_uq": {
2674+ "name": "users_email_uq",
2675+ "columns": [
2676+ {
2677+ "expression": "email",
2678+ "isExpression": false,
2679+ "asc": true,
2680+ "nulls": "last"
2681+ }
2682+ ],
2683+ "isUnique": true,
2684+ "concurrently": false,
2685+ "method": "btree",
2686+ "with": {}
2687+ }
2688+ },
2689+ "foreignKeys": {},
2690+ "compositePrimaryKeys": {},
2691+ "uniqueConstraints": {},
2692+ "policies": {},
2693+ "checkConstraints": {},
2694+ "isRLSEnabled": false
2695+ },
2696+ "public.verifications": {
2697+ "name": "verifications",
2698+ "schema": "",
2699+ "columns": {
2700+ "id": {
2701+ "name": "id",
2702+ "type": "text",
2703+ "primaryKey": true,
2704+ "notNull": true
2705+ },
2706+ "identifier": {
2707+ "name": "identifier",
2708+ "type": "text",
2709+ "primaryKey": false,
2710+ "notNull": true
2711+ },
2712+ "value": {
2713+ "name": "value",
2714+ "type": "text",
2715+ "primaryKey": false,
2716+ "notNull": true
2717+ },
2718+ "expires_at": {
2719+ "name": "expires_at",
2720+ "type": "timestamp with time zone",
2721+ "primaryKey": false,
2722+ "notNull": true
2723+ },
2724+ "created_at": {
2725+ "name": "created_at",
2726+ "type": "timestamp with time zone",
2727+ "primaryKey": false,
2728+ "notNull": true,
2729+ "default": "now()"
2730+ },
2731+ "updated_at": {
2732+ "name": "updated_at",
2733+ "type": "timestamp with time zone",
2734+ "primaryKey": false,
2735+ "notNull": true,
2736+ "default": "now()"
2737+ }
2738+ },
2739+ "indexes": {
2740+ "verifications_identifier_idx": {
2741+ "name": "verifications_identifier_idx",
2742+ "columns": [
2743+ {
2744+ "expression": "identifier",
2745+ "isExpression": false,
2746+ "asc": true,
2747+ "nulls": "last"
2748+ }
2749+ ],
2750+ "isUnique": false,
2751+ "concurrently": false,
2752+ "method": "btree",
2753+ "with": {}
2754+ }
2755+ },
2756+ "foreignKeys": {},
2757+ "compositePrimaryKeys": {},
2758+ "uniqueConstraints": {},
2759+ "policies": {},
2760+ "checkConstraints": {},
2761+ "isRLSEnabled": false
2762+ },
2763+ "public.arena_votes": {
2764+ "name": "arena_votes",
2765+ "schema": "",
2766+ "columns": {
2767+ "id": {
2768+ "name": "id",
2769+ "type": "text",
2770+ "primaryKey": true,
2771+ "notNull": true
2772+ },
2773+ "user_id": {
2774+ "name": "user_id",
2775+ "type": "text",
2776+ "primaryKey": false,
2777+ "notNull": true
2778+ },
2779+ "session_id": {
2780+ "name": "session_id",
2781+ "type": "text",
2782+ "primaryKey": false,
2783+ "notNull": true
2784+ },
2785+ "response_id": {
2786+ "name": "response_id",
2787+ "type": "text",
2788+ "primaryKey": false,
2789+ "notNull": true
2790+ },
2791+ "model_key": {
2792+ "name": "model_key",
2793+ "type": "text",
2794+ "primaryKey": false,
2795+ "notNull": true
2796+ },
2797+ "criterion": {
2798+ "name": "criterion",
2799+ "type": "text",
2800+ "primaryKey": false,
2801+ "notNull": true
2802+ },
2803+ "category": {
2804+ "name": "category",
2805+ "type": "text",
2806+ "primaryKey": false,
2807+ "notNull": false
2808+ },
2809+ "created_at": {
2810+ "name": "created_at",
2811+ "type": "timestamp with time zone",
2812+ "primaryKey": false,
2813+ "notNull": true,
2814+ "default": "now()"
2815+ }
2816+ },
2817+ "indexes": {
2818+ "arena_votes_session_criterion_uq": {
2819+ "name": "arena_votes_session_criterion_uq",
2820+ "columns": [
2821+ {
2822+ "expression": "session_id",
2823+ "isExpression": false,
2824+ "asc": true,
2825+ "nulls": "last"
2826+ },
2827+ {
2828+ "expression": "criterion",
2829+ "isExpression": false,
2830+ "asc": true,
2831+ "nulls": "last"
2832+ }
2833+ ],
2834+ "isUnique": true,
2835+ "concurrently": false,
2836+ "method": "btree",
2837+ "with": {}
2838+ },
2839+ "arena_votes_user_model_idx": {
2840+ "name": "arena_votes_user_model_idx",
2841+ "columns": [
2842+ {
2843+ "expression": "user_id",
2844+ "isExpression": false,
2845+ "asc": true,
2846+ "nulls": "last"
2847+ },
2848+ {
2849+ "expression": "model_key",
2850+ "isExpression": false,
2851+ "asc": true,
2852+ "nulls": "last"
2853+ }
2854+ ],
2855+ "isUnique": false,
2856+ "concurrently": false,
2857+ "method": "btree",
2858+ "with": {}
2859+ }
2860+ },
2861+ "foreignKeys": {
2862+ "arena_votes_user_id_users_id_fk": {
2863+ "name": "arena_votes_user_id_users_id_fk",
2864+ "tableFrom": "arena_votes",
2865+ "tableTo": "users",
2866+ "columnsFrom": [
2867+ "user_id"
2868+ ],
2869+ "columnsTo": [
2870+ "id"
2871+ ],
2872+ "onDelete": "cascade",
2873+ "onUpdate": "no action"
2874+ }
2875+ },
2876+ "compositePrimaryKeys": {},
2877+ "uniqueConstraints": {},
2878+ "policies": {},
2879+ "checkConstraints": {},
2880+ "isRLSEnabled": false
2881+ },
2882+ "public.custom_endpoints": {
2883+ "name": "custom_endpoints",
2884+ "schema": "",
2885+ "columns": {
2886+ "id": {
2887+ "name": "id",
2888+ "type": "text",
2889+ "primaryKey": true,
2890+ "notNull": true
2891+ },
2892+ "user_id": {
2893+ "name": "user_id",
2894+ "type": "text",
2895+ "primaryKey": false,
2896+ "notNull": true
2897+ },
2898+ "name": {
2899+ "name": "name",
2900+ "type": "text",
2901+ "primaryKey": false,
2902+ "notNull": true
2903+ },
2904+ "base_url": {
2905+ "name": "base_url",
2906+ "type": "text",
2907+ "primaryKey": false,
2908+ "notNull": true
2909+ },
2910+ "encrypted_key": {
2911+ "name": "encrypted_key",
2912+ "type": "text",
2913+ "primaryKey": false,
2914+ "notNull": false
2915+ },
2916+ "key_hint": {
2917+ "name": "key_hint",
2918+ "type": "text",
2919+ "primaryKey": false,
2920+ "notNull": false
2921+ },
2922+ "encrypted_headers": {
2923+ "name": "encrypted_headers",
2924+ "type": "text",
2925+ "primaryKey": false,
2926+ "notNull": false
2927+ },
2928+ "models_path": {
2929+ "name": "models_path",
2930+ "type": "text",
2931+ "primaryKey": false,
2932+ "notNull": true,
2933+ "default": "'/models'"
2934+ },
2935+ "manual_models": {
2936+ "name": "manual_models",
2937+ "type": "jsonb",
2938+ "primaryKey": false,
2939+ "notNull": true,
2940+ "default": "'[]'::jsonb"
2941+ },
2942+ "status": {
2943+ "name": "status",
2944+ "type": "text",
2945+ "primaryKey": false,
2946+ "notNull": true,
2947+ "default": "'unverified'"
2948+ },
2949+ "last_validated_at": {
2950+ "name": "last_validated_at",
2951+ "type": "timestamp with time zone",
2952+ "primaryKey": false,
2953+ "notNull": false
2954+ },
2955+ "last_validation_error": {
2956+ "name": "last_validation_error",
2957+ "type": "text",
2958+ "primaryKey": false,
2959+ "notNull": false
2960+ },
2961+ "last_latency_ms": {
2962+ "name": "last_latency_ms",
2963+ "type": "integer",
2964+ "primaryKey": false,
2965+ "notNull": false
2966+ },
2967+ "models_available": {
2968+ "name": "models_available",
2969+ "type": "integer",
2970+ "primaryKey": false,
2971+ "notNull": false
2972+ },
2973+ "created_at": {
2974+ "name": "created_at",
2975+ "type": "timestamp with time zone",
2976+ "primaryKey": false,
2977+ "notNull": true,
2978+ "default": "now()"
2979+ },
2980+ "updated_at": {
2981+ "name": "updated_at",
2982+ "type": "timestamp with time zone",
2983+ "primaryKey": false,
2984+ "notNull": true,
2985+ "default": "now()"
2986+ }
2987+ },
2988+ "indexes": {
2989+ "custom_endpoints_user_idx": {
2990+ "name": "custom_endpoints_user_idx",
2991+ "columns": [
2992+ {
2993+ "expression": "user_id",
2994+ "isExpression": false,
2995+ "asc": true,
2996+ "nulls": "last"
2997+ }
2998+ ],
2999+ "isUnique": false,
3000+ "concurrently": false,
3001+ "method": "btree",
3002+ "with": {}
3003+ }
3004+ },
3005+ "foreignKeys": {
3006+ "custom_endpoints_user_id_users_id_fk": {
3007+ "name": "custom_endpoints_user_id_users_id_fk",
3008+ "tableFrom": "custom_endpoints",
3009+ "tableTo": "users",
3010+ "columnsFrom": [
3011+ "user_id"
3012+ ],
3013+ "columnsTo": [
3014+ "id"
3015+ ],
3016+ "onDelete": "cascade",
3017+ "onUpdate": "no action"
3018+ }
3019+ },
3020+ "compositePrimaryKeys": {},
3021+ "uniqueConstraints": {},
3022+ "policies": {},
3023+ "checkConstraints": {},
3024+ "isRLSEnabled": false
3025+ },
3026+ "public.project_files": {
3027+ "name": "project_files",
3028+ "schema": "",
3029+ "columns": {
3030+ "id": {
3031+ "name": "id",
3032+ "type": "text",
3033+ "primaryKey": true,
3034+ "notNull": true
3035+ },
3036+ "user_id": {
3037+ "name": "user_id",
3038+ "type": "text",
3039+ "primaryKey": false,
3040+ "notNull": true
3041+ },
3042+ "project_id": {
3043+ "name": "project_id",
3044+ "type": "text",
3045+ "primaryKey": false,
3046+ "notNull": false
3047+ },
3048+ "kind": {
3049+ "name": "kind",
3050+ "type": "text",
3051+ "primaryKey": false,
3052+ "notNull": true
3053+ },
3054+ "name": {
3055+ "name": "name",
3056+ "type": "text",
3057+ "primaryKey": false,
3058+ "notNull": true
3059+ },
3060+ "mime_type": {
3061+ "name": "mime_type",
3062+ "type": "text",
3063+ "primaryKey": false,
3064+ "notNull": true
3065+ },
3066+ "size_bytes": {
3067+ "name": "size_bytes",
3068+ "type": "integer",
3069+ "primaryKey": false,
3070+ "notNull": true
3071+ },
3072+ "data_base64": {
3073+ "name": "data_base64",
3074+ "type": "text",
3075+ "primaryKey": false,
3076+ "notNull": true
3077+ },
3078+ "width": {
3079+ "name": "width",
3080+ "type": "integer",
3081+ "primaryKey": false,
3082+ "notNull": false
3083+ },
3084+ "height": {
3085+ "name": "height",
3086+ "type": "integer",
3087+ "primaryKey": false,
3088+ "notNull": false
3089+ },
3090+ "estimated_tokens": {
3091+ "name": "estimated_tokens",
3092+ "type": "integer",
3093+ "primaryKey": false,
3094+ "notNull": false
3095+ },
3096+ "description": {
3097+ "name": "description",
3098+ "type": "text",
3099+ "primaryKey": false,
3100+ "notNull": false
3101+ },
3102+ "created_at": {
3103+ "name": "created_at",
3104+ "type": "timestamp with time zone",
3105+ "primaryKey": false,
3106+ "notNull": true,
3107+ "default": "now()"
3108+ }
3109+ },
3110+ "indexes": {
3111+ "project_files_user_idx": {
3112+ "name": "project_files_user_idx",
3113+ "columns": [
3114+ {
3115+ "expression": "user_id",
3116+ "isExpression": false,
3117+ "asc": true,
3118+ "nulls": "last"
3119+ }
3120+ ],
3121+ "isUnique": false,
3122+ "concurrently": false,
3123+ "method": "btree",
3124+ "with": {}
3125+ },
3126+ "project_files_project_idx": {
3127+ "name": "project_files_project_idx",
3128+ "columns": [
3129+ {
3130+ "expression": "project_id",
3131+ "isExpression": false,
3132+ "asc": true,
3133+ "nulls": "last"
3134+ }
3135+ ],
3136+ "isUnique": false,
3137+ "concurrently": false,
3138+ "method": "btree",
3139+ "with": {}
3140+ }
3141+ },
3142+ "foreignKeys": {
3143+ "project_files_user_id_users_id_fk": {
3144+ "name": "project_files_user_id_users_id_fk",
3145+ "tableFrom": "project_files",
3146+ "tableTo": "users",
3147+ "columnsFrom": [
3148+ "user_id"
3149+ ],
3150+ "columnsTo": [
3151+ "id"
3152+ ],
3153+ "onDelete": "cascade",
3154+ "onUpdate": "no action"
3155+ },
3156+ "project_files_project_id_projects_id_fk": {
3157+ "name": "project_files_project_id_projects_id_fk",
3158+ "tableFrom": "project_files",
3159+ "tableTo": "projects",
3160+ "columnsFrom": [
3161+ "project_id"
3162+ ],
3163+ "columnsTo": [
3164+ "id"
3165+ ],
3166+ "onDelete": "cascade",
3167+ "onUpdate": "no action"
3168+ }
3169+ },
3170+ "compositePrimaryKeys": {},
3171+ "uniqueConstraints": {},
3172+ "policies": {},
3173+ "checkConstraints": {},
3174+ "isRLSEnabled": false
3175+ },
3176+ "public.projects": {
3177+ "name": "projects",
3178+ "schema": "",
3179+ "columns": {
3180+ "id": {
3181+ "name": "id",
3182+ "type": "text",
3183+ "primaryKey": true,
3184+ "notNull": true
3185+ },
3186+ "user_id": {
3187+ "name": "user_id",
3188+ "type": "text",
3189+ "primaryKey": false,
3190+ "notNull": true
3191+ },
3192+ "name": {
3193+ "name": "name",
3194+ "type": "text",
3195+ "primaryKey": false,
3196+ "notNull": true
3197+ },
3198+ "description": {
3199+ "name": "description",
3200+ "type": "text",
3201+ "primaryKey": false,
3202+ "notNull": false
3203+ },
3204+ "icon": {
3205+ "name": "icon",
3206+ "type": "text",
3207+ "primaryKey": false,
3208+ "notNull": false
3209+ },
3210+ "color": {
3211+ "name": "color",
3212+ "type": "text",
3213+ "primaryKey": false,
3214+ "notNull": false
3215+ },
3216+ "instructions": {
3217+ "name": "instructions",
3218+ "type": "text",
3219+ "primaryKey": false,
3220+ "notNull": false
3221+ },
3222+ "preferred_model_keys": {
3223+ "name": "preferred_model_keys",
3224+ "type": "jsonb",
3225+ "primaryKey": false,
3226+ "notNull": true,
3227+ "default": "'[]'::jsonb"
3228+ },
3229+ "default_settings": {
3230+ "name": "default_settings",
3231+ "type": "jsonb",
3232+ "primaryKey": false,
3233+ "notNull": true,
3234+ "default": "'{}'::jsonb"
3235+ },
3236+ "notes": {
3237+ "name": "notes",
3238+ "type": "text",
3239+ "primaryKey": false,
3240+ "notNull": false
3241+ },
3242+ "archived": {
3243+ "name": "archived",
3244+ "type": "boolean",
3245+ "primaryKey": false,
3246+ "notNull": true,
3247+ "default": false
3248+ },
3249+ "sort_order": {
3250+ "name": "sort_order",
3251+ "type": "integer",
3252+ "primaryKey": false,
3253+ "notNull": true,
3254+ "default": 0
3255+ },
3256+ "created_at": {
3257+ "name": "created_at",
3258+ "type": "timestamp with time zone",
3259+ "primaryKey": false,
3260+ "notNull": true,
3261+ "default": "now()"
3262+ },
3263+ "updated_at": {
3264+ "name": "updated_at",
3265+ "type": "timestamp with time zone",
3266+ "primaryKey": false,
3267+ "notNull": true,
3268+ "default": "now()"
3269+ }
3270+ },
3271+ "indexes": {
3272+ "projects_user_idx": {
3273+ "name": "projects_user_idx",
3274+ "columns": [
3275+ {
3276+ "expression": "user_id",
3277+ "isExpression": false,
3278+ "asc": true,
3279+ "nulls": "last"
3280+ }
3281+ ],
3282+ "isUnique": false,
3283+ "concurrently": false,
3284+ "method": "btree",
3285+ "with": {}
3286+ }
3287+ },
3288+ "foreignKeys": {
3289+ "projects_user_id_users_id_fk": {
3290+ "name": "projects_user_id_users_id_fk",
3291+ "tableFrom": "projects",
3292+ "tableTo": "users",
3293+ "columnsFrom": [
3294+ "user_id"
3295+ ],
3296+ "columnsTo": [
3297+ "id"
3298+ ],
3299+ "onDelete": "cascade",
3300+ "onUpdate": "no action"
3301+ }
3302+ },
3303+ "compositePrimaryKeys": {},
3304+ "uniqueConstraints": {},
3305+ "policies": {},
3306+ "checkConstraints": {},
3307+ "isRLSEnabled": false
3308+ },
3309+ "public.prompts": {
3310+ "name": "prompts",
3311+ "schema": "",
3312+ "columns": {
3313+ "id": {
3314+ "name": "id",
3315+ "type": "text",
3316+ "primaryKey": true,
3317+ "notNull": true
3318+ },
3319+ "user_id": {
3320+ "name": "user_id",
3321+ "type": "text",
3322+ "primaryKey": false,
3323+ "notNull": true
3324+ },
3325+ "project_id": {
3326+ "name": "project_id",
3327+ "type": "text",
3328+ "primaryKey": false,
3329+ "notNull": false
3330+ },
3331+ "kind": {
3332+ "name": "kind",
3333+ "type": "text",
3334+ "primaryKey": false,
3335+ "notNull": true,
3336+ "default": "'user'"
3337+ },
3338+ "name": {
3339+ "name": "name",
3340+ "type": "text",
3341+ "primaryKey": false,
3342+ "notNull": true
3343+ },
3344+ "description": {
3345+ "name": "description",
3346+ "type": "text",
3347+ "primaryKey": false,
3348+ "notNull": false
3349+ },
3350+ "content": {
3351+ "name": "content",
3352+ "type": "text",
3353+ "primaryKey": false,
3354+ "notNull": true
3355+ },
3356+ "variables": {
3357+ "name": "variables",
3358+ "type": "jsonb",
3359+ "primaryKey": false,
3360+ "notNull": true,
3361+ "default": "'[]'::jsonb"
3362+ },
3363+ "schema": {
3364+ "name": "schema",
3365+ "type": "jsonb",
3366+ "primaryKey": false,
3367+ "notNull": false
3368+ },
3369+ "folder": {
3370+ "name": "folder",
3371+ "type": "text",
3372+ "primaryKey": false,
3373+ "notNull": false
3374+ },
3375+ "tags": {
3376+ "name": "tags",
3377+ "type": "jsonb",
3378+ "primaryKey": false,
3379+ "notNull": true,
3380+ "default": "'[]'::jsonb"
3381+ },
3382+ "favorite": {
3383+ "name": "favorite",
3384+ "type": "boolean",
3385+ "primaryKey": false,
3386+ "notNull": true,
3387+ "default": false
3388+ },
3389+ "default_model_key": {
3390+ "name": "default_model_key",
3391+ "type": "text",
3392+ "primaryKey": false,
3393+ "notNull": false
3394+ },
3395+ "uses": {
3396+ "name": "uses",
3397+ "type": "integer",
3398+ "primaryKey": false,
3399+ "notNull": true,
3400+ "default": 0
3401+ },
3402+ "last_used_at": {
3403+ "name": "last_used_at",
3404+ "type": "timestamp with time zone",
3405+ "primaryKey": false,
3406+ "notNull": false
3407+ },
3408+ "created_at": {
3409+ "name": "created_at",
3410+ "type": "timestamp with time zone",
3411+ "primaryKey": false,
3412+ "notNull": true,
3413+ "default": "now()"
3414+ },
3415+ "updated_at": {
3416+ "name": "updated_at",
3417+ "type": "timestamp with time zone",
3418+ "primaryKey": false,
3419+ "notNull": true,
3420+ "default": "now()"
3421+ }
3422+ },
3423+ "indexes": {
3424+ "prompts_user_idx": {
3425+ "name": "prompts_user_idx",
3426+ "columns": [
3427+ {
3428+ "expression": "user_id",
3429+ "isExpression": false,
3430+ "asc": true,
3431+ "nulls": "last"
3432+ }
3433+ ],
3434+ "isUnique": false,
3435+ "concurrently": false,
3436+ "method": "btree",
3437+ "with": {}
3438+ },
3439+ "prompts_user_folder_idx": {
3440+ "name": "prompts_user_folder_idx",
3441+ "columns": [
3442+ {
3443+ "expression": "user_id",
3444+ "isExpression": false,
3445+ "asc": true,
3446+ "nulls": "last"
3447+ },
3448+ {
3449+ "expression": "folder",
3450+ "isExpression": false,
3451+ "asc": true,
3452+ "nulls": "last"
3453+ }
3454+ ],
3455+ "isUnique": false,
3456+ "concurrently": false,
3457+ "method": "btree",
3458+ "with": {}
3459+ }
3460+ },
3461+ "foreignKeys": {
3462+ "prompts_user_id_users_id_fk": {
3463+ "name": "prompts_user_id_users_id_fk",
3464+ "tableFrom": "prompts",
3465+ "tableTo": "users",
3466+ "columnsFrom": [
3467+ "user_id"
3468+ ],
3469+ "columnsTo": [
3470+ "id"
3471+ ],
3472+ "onDelete": "cascade",
3473+ "onUpdate": "no action"
3474+ },
3475+ "prompts_project_id_projects_id_fk": {
3476+ "name": "prompts_project_id_projects_id_fk",
3477+ "tableFrom": "prompts",
3478+ "tableTo": "projects",
3479+ "columnsFrom": [
3480+ "project_id"
3481+ ],
3482+ "columnsTo": [
3483+ "id"
3484+ ],
3485+ "onDelete": "set null",
3486+ "onUpdate": "no action"
3487+ }
3488+ },
3489+ "compositePrimaryKeys": {},
3490+ "uniqueConstraints": {},
3491+ "policies": {},
3492+ "checkConstraints": {},
3493+ "isRLSEnabled": false
3494+ },
3495+ "public.shared_arena_sessions": {
3496+ "name": "shared_arena_sessions",
3497+ "schema": "",
3498+ "columns": {
3499+ "id": {
3500+ "name": "id",
3501+ "type": "text",
3502+ "primaryKey": true,
3503+ "notNull": true
3504+ },
3505+ "session_id": {
3506+ "name": "session_id",
3507+ "type": "text",
3508+ "primaryKey": false,
3509+ "notNull": true
3510+ },
3511+ "user_id": {
3512+ "name": "user_id",
3513+ "type": "text",
3514+ "primaryKey": false,
3515+ "notNull": true
3516+ },
3517+ "snapshot": {
3518+ "name": "snapshot",
3519+ "type": "jsonb",
3520+ "primaryKey": false,
3521+ "notNull": true
3522+ },
3523+ "is_public": {
3524+ "name": "is_public",
3525+ "type": "boolean",
3526+ "primaryKey": false,
3527+ "notNull": true,
3528+ "default": true
3529+ },
3530+ "view_count": {
3531+ "name": "view_count",
3532+ "type": "integer",
3533+ "primaryKey": false,
3534+ "notNull": true,
3535+ "default": 0
3536+ },
3537+ "created_at": {
3538+ "name": "created_at",
3539+ "type": "timestamp with time zone",
3540+ "primaryKey": false,
3541+ "notNull": true,
3542+ "default": "now()"
3543+ },
3544+ "revoked_at": {
3545+ "name": "revoked_at",
3546+ "type": "timestamp with time zone",
3547+ "primaryKey": false,
3548+ "notNull": false
3549+ }
3550+ },
3551+ "indexes": {
3552+ "shared_arena_session_idx": {
3553+ "name": "shared_arena_session_idx",
3554+ "columns": [
3555+ {
3556+ "expression": "session_id",
3557+ "isExpression": false,
3558+ "asc": true,
3559+ "nulls": "last"
3560+ }
3561+ ],
3562+ "isUnique": false,
3563+ "concurrently": false,
3564+ "method": "btree",
3565+ "with": {}
3566+ }
3567+ },
3568+ "foreignKeys": {
3569+ "shared_arena_sessions_user_id_users_id_fk": {
3570+ "name": "shared_arena_sessions_user_id_users_id_fk",
3571+ "tableFrom": "shared_arena_sessions",
3572+ "tableTo": "users",
3573+ "columnsFrom": [
3574+ "user_id"
3575+ ],
3576+ "columnsTo": [
3577+ "id"
3578+ ],
3579+ "onDelete": "cascade",
3580+ "onUpdate": "no action"
3581+ }
3582+ },
3583+ "compositePrimaryKeys": {},
3584+ "uniqueConstraints": {},
3585+ "policies": {},
3586+ "checkConstraints": {},
3587+ "isRLSEnabled": false
3588+ },
3589+ "public.user_model_labels": {
3590+ "name": "user_model_labels",
3591+ "schema": "",
3592+ "columns": {
3593+ "user_id": {
3594+ "name": "user_id",
3595+ "type": "text",
3596+ "primaryKey": false,
3597+ "notNull": true
3598+ },
3599+ "model_key": {
3600+ "name": "model_key",
3601+ "type": "text",
3602+ "primaryKey": false,
3603+ "notNull": true
3604+ },
3605+ "label": {
3606+ "name": "label",
3607+ "type": "text",
3608+ "primaryKey": false,
3609+ "notNull": true
3610+ },
3611+ "updated_at": {
3612+ "name": "updated_at",
3613+ "type": "timestamp with time zone",
3614+ "primaryKey": false,
3615+ "notNull": true,
3616+ "default": "now()"
3617+ }
3618+ },
3619+ "indexes": {},
3620+ "foreignKeys": {
3621+ "user_model_labels_user_id_users_id_fk": {
3622+ "name": "user_model_labels_user_id_users_id_fk",
3623+ "tableFrom": "user_model_labels",
3624+ "tableTo": "users",
3625+ "columnsFrom": [
3626+ "user_id"
3627+ ],
3628+ "columnsTo": [
3629+ "id"
3630+ ],
3631+ "onDelete": "cascade",
3632+ "onUpdate": "no action"
3633+ }
3634+ },
3635+ "compositePrimaryKeys": {
3636+ "user_model_labels_user_id_model_key_pk": {
3637+ "name": "user_model_labels_user_id_model_key_pk",
3638+ "columns": [
3639+ "user_id",
3640+ "model_key"
3641+ ]
3642+ }
3643+ },
3644+ "uniqueConstraints": {},
3645+ "policies": {},
3646+ "checkConstraints": {},
3647+ "isRLSEnabled": false
3648+ }
3649+ },
3650+ "enums": {},
3651+ "schemas": {},
3652+ "sequences": {},
3653+ "roles": {},
3654+ "policies": {},
3655+ "views": {},
3656+ "_meta": {
3657+ "columns": {},
3658+ "schemas": {},
3659+ "tables": {}
3660+ }
3661+}
\ No newline at end of file
modified drizzle/meta/_journal.json +7 −0
@@ -8,6 +8,13 @@
8 "when": 1788844960714, 8 "when": 1788844960714,
9 "tag": "0000_foamy_loners", 9 "tag": "0000_foamy_loners",
10 "breakpoints": true 10 "breakpoints": true
11+ },
12+ {
13+ "idx": 1,
14+ "version": "7",
15+ "when": 1789110687474,
16+ "tag": "0001_workspace_upgrade",
17+ "breakpoints": true
11 } 18 }
12 ] 19 ]
13 } 20 }
\ No newline at end of file
modified package.json +2 −1
@@ -1,6 +1,6 @@
1 { 1 {
2 "name": "polyllm", 2 "name": "polyllm",
3− "version": "0.1.0", 3+ "version": "1.0.0",
4 "private": true, 4 "private": true,
5 "description": "PolyLLM — One interface. Every model. Bring your own keys.", 5 "description": "PolyLLM — One interface. Every model. Bring your own keys.",
6 "type": "module", 6 "type": "module",
@@ -18,6 +18,7 @@
18 "test:e2e": "playwright test", 18 "test:e2e": "playwright test",
19 "db:generate": "drizzle-kit generate", 19 "db:generate": "drizzle-kit generate",
20 "db:migrate": "tsx src/db/migrate.ts", 20 "db:migrate": "tsx src/db/migrate.ts",
21+ "db:push": "drizzle-kit push",
21 "db:studio": "drizzle-kit studio", 22 "db:studio": "drizzle-kit studio",
22 "models:sync": "tsx --tsconfig tsconfig.json --import ./scripts/hooks.mjs scripts/sync-models.ts", 23 "models:sync": "tsx --tsconfig tsconfig.json --import ./scripts/hooks.mjs scripts/sync-models.ts",
23 "providers:matrix": "tsx --tsconfig tsconfig.json --import ./scripts/hooks.mjs scripts/provider-matrix.ts" 24 "providers:matrix": "tsx --tsconfig tsconfig.json --import ./scripts/hooks.mjs scripts/provider-matrix.ts"
added public/apple-icon.png +0 −0

Binary file not shown.

added public/brand/icon-maskable.svg +1 −0
@@ -0,0 +1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" fill="none"><defs><linearGradient id="g" x1="40" y1="40" x2="472" y2="472" gradientUnits="userSpaceOnUse"><stop stop-color="#6366f1"/><stop offset="0.52" stop-color="#8b5cf6"/><stop offset="1" stop-color="#f472b6"/></linearGradient><radialGradient id="h" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(140 90) rotate(48) scale(420 360)"><stop stop-color="#fff" stop-opacity="0.32"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient></defs><rect width="512" height="512" fill="url(#g)"/><rect width="512" height="512" fill="url(#h)"/><g fill="#fff" transform="translate(76 76) scale(11.25)"><rect x="7.5" y="7" width="5" height="18" rx="2.5"/><path fill-rule="evenodd" d="M12 7h5.4a5.6 5.6 0 0 1 0 11.2H12V7Zm0 3.4v4.4h5.3a2.2 2.2 0 0 0 0-4.4H12Z"/><rect x="24.6" y="9.1" width="3.2" height="2.3" rx="1.15" opacity="0.95"/><rect x="24.6" y="12.55" width="3.2" height="2.3" rx="1.15" opacity="0.62"/><rect x="24.6" y="16" width="3.2" height="2.3" rx="1.15" opacity="0.34"/></g></svg>
added public/brand/og.svg +32 −0
@@ -0,0 +1,32 @@
1+<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
2+ <defs>
3+ <linearGradient id="bg" x1="0" y1="0" x2="1200" y2="630" gradientUnits="userSpaceOnUse"><stop stop-color="#0b0c10"/><stop offset="1" stop-color="#12131c"/></linearGradient>
4+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#6366f1"/><stop offset="0.52" stop-color="#8b5cf6"/><stop offset="1" stop-color="#f472b6"/></linearGradient>
5+ <radialGradient id="glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(900 120) scale(620 420)"><stop stop-color="#8b5cf6" stop-opacity="0.35"/><stop offset="1" stop-color="#8b5cf6" stop-opacity="0"/></radialGradient>
6+ <radialGradient id="glow2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(200 620) scale(520 360)"><stop stop-color="#f472b6" stop-opacity="0.22"/><stop offset="1" stop-color="#f472b6" stop-opacity="0"/></radialGradient>
7+ <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r="1" fill="#ffffff" fill-opacity="0.08"/></pattern>
8+ </defs>
9+ <rect width="1200" height="630" fill="url(#bg)"/>
10+ <rect width="1200" height="630" fill="url(#grid)"/>
11+ <rect width="1200" height="630" fill="url(#glow)"/>
12+ <rect width="1200" height="630" fill="url(#glow2)"/>
13+ <!-- mark -->
14+ <g transform="translate(92 88)">
15+ <rect x="1" y="1" width="30" height="30" rx="9" fill="url(#g)" transform="scale(2.6)"/>
16+ <g fill="#fff" transform="scale(2.6)"><rect x="7.5" y="7" width="5" height="18" rx="2.5"/><path fill-rule="evenodd" d="M12 7h5.4a5.6 5.6 0 0 1 0 11.2H12V7Zm0 3.4v4.4h5.3a2.2 2.2 0 0 0 0-4.4H12Z"/><rect x="24.6" y="9.1" width="3.2" height="2.3" rx="1.15" opacity="0.95"/><rect x="24.6" y="12.55" width="3.2" height="2.3" rx="1.15" opacity="0.62"/><rect x="24.6" y="16" width="3.2" height="2.3" rx="1.15" opacity="0.34"/></g>
17+ </g>
18+ <text x="196" y="150" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="46" font-weight="700" fill="#f1f2f6" letter-spacing="-1.5">Poly<tspan fill="#9a9fb1" font-weight="500">LLM</tspan></text>
19+ <text x="92" y="292" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="86" font-weight="700" fill="#f1f2f6" letter-spacing="-3.5">One interface.</text>
20+ <text x="92" y="384" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="86" font-weight="700" fill="url(#g)" letter-spacing="-3.5">Every model.</text>
21+ <text x="92" y="448" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="27" font-weight="400" fill="#9a9fb1" letter-spacing="-0.3">Bring your own keys. Compare models. Control every parameter. Track every token.</text>
22+ <!-- model strip -->
23+ <g font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="500" fill="#e5e7ef">
24+ <g transform="translate(92 508)"><rect width="196" height="48" rx="24" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.12"/><circle cx="26" cy="24" r="7" fill="#d97757"/><text x="44" y="31">Claude Opus 5</text></g>
25+ <g transform="translate(302 508)"><rect width="126" height="48" rx="24" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.12"/><circle cx="26" cy="24" r="7" fill="#34c99b"/><text x="44" y="31">GPT-5.5</text></g>
26+ <g transform="translate(442 508)"><rect width="188" height="48" rx="24" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.12"/><circle cx="26" cy="24" r="7" fill="#6aa2ff"/><text x="44" y="31">Gemini 3.8 Pro</text></g>
27+ <g transform="translate(644 508)"><rect width="110" height="48" rx="24" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.12"/><circle cx="26" cy="24" r="7" fill="#d5d8e0"/><text x="44" y="31">Grok</text></g>
28+ <g transform="translate(768 508)"><rect width="150" height="48" rx="24" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.12"/><circle cx="26" cy="24" r="7" fill="#7d93ff"/><text x="44" y="31">DeepSeek</text></g>
29+ <g transform="translate(932 508)"><rect width="176" height="48" rx="24" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.12"/><text x="24" y="31" fill="#9a9fb1">+ 9 providers</text></g>
30+ </g>
31+ <text x="1108" y="596" text-anchor="end" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="20" fill="#676d7e">www.polyllm.io</text>
32+</svg>
added public/favicon-16.png +0 −0

Binary file not shown.

added public/favicon-32.png +0 −0

Binary file not shown.

added public/favicon.ico +0 −0

Binary file not shown.

added public/icon-192.png +0 −0

Binary file not shown.

added public/icon-512.png +0 −0

Binary file not shown.

added public/icon-maskable-512.png +0 −0

Binary file not shown.

modified public/icon.svg +1 −1
@@ -1 +1 @@
1−<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none"><defs><linearGradient id="g" x1="4" y1="4" x2="28" y2="28" gradientUnits="userSpaceOnUse"><stop stop-color="#8b8dff"/><stop offset="0.55" stop-color="#b58cff"/><stop offset="1" stop-color="#ff8fab"/></linearGradient></defs><rect x="1" y="1" width="30" height="30" rx="9" fill="url(#g)"/><g stroke="#0b0c10" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 10.5h4.5M9 16h6M9 21.5h4.5"/><path d="M23 10.5h-4.5M23 16h-6M23 21.5h-4.5"/><circle cx="16" cy="16" r="2.4" fill="#0b0c10" stroke="none"/></g></svg> 1+<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none"><defs><linearGradient id="g" x1="3" y1="3" x2="29" y2="29" gradientUnits="userSpaceOnUse"><stop stop-color="#6366f1"/><stop offset="0.52" stop-color="#8b5cf6"/><stop offset="1" stop-color="#f472b6"/></linearGradient><radialGradient id="h" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(9 6) rotate(48) scale(26 22)"><stop stop-color="#fff" stop-opacity="0.32"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient></defs><rect x="1" y="1" width="30" height="30" rx="9" fill="url(#g)"/><rect x="1" y="1" width="30" height="30" rx="9" fill="url(#h)"/><g fill="#fff"><rect x="7.5" y="7" width="5" height="18" rx="2.5"/><path fill-rule="evenodd" d="M12 7h5.4a5.6 5.6 0 0 1 0 11.2H12V7Zm0 3.4v4.4h5.3a2.2 2.2 0 0 0 0-4.4H12Z"/><rect x="24.6" y="9.1" width="3.2" height="2.3" rx="1.15" opacity="0.95"/><rect x="24.6" y="12.55" width="3.2" height="2.3" rx="1.15" opacity="0.62"/><rect x="24.6" y="16" width="3.2" height="2.3" rx="1.15" opacity="0.34"/></g></svg>
added public/og.png +0 −0

Binary file not shown.

added scripts/brand-assets.sh +20 −0
@@ -0,0 +1,20 @@
1+#!/usr/bin/env bash
2+# Regenerates the raster brand assets from the SVG sources (needs librsvg: `brew install librsvg`).
3+set -euo pipefail
4+cd "$(dirname "$0")/.."
5+r() { rsvg-convert -w "$2" -h "$3" "$1" -o "$4"; echo " $4"; }
6+echo "Rendering brand assets…"
7+r public/icon.svg 32 32 public/favicon-32.png
8+r public/icon.svg 16 16 public/favicon-16.png
9+r public/icon.svg 180 180 public/apple-icon.png
10+r public/icon.svg 192 192 public/icon-192.png
11+r public/icon.svg 512 512 public/icon-512.png
12+r public/brand/icon-maskable.svg 512 512 public/icon-maskable-512.png
13+r public/brand/og.svg 1200 630 public/og.png
14+# Multi-size ICO for legacy user agents (Pillow optional).
15+python3 - <<'PY' || echo " (Pillow missing — favicon.ico skipped)"
16+from PIL import Image
17+im = Image.open("public/icon-512.png").convert("RGBA")
18+im.save("public/favicon.ico", sizes=[(16,16),(32,32),(48,48),(64,64)])
19+print(" public/favicon.ico")
20+PY
modified src/app/api/models/route.ts +16 −6
@@ -1,19 +1,29 @@
1 import { z } from "zod"; 1 import { z } from "zod";
2 import { withUser, parseBody, json } from "@/lib/api"; 2 import { withUser, parseBody, json } from "@/lib/api";
3 import { listRegistryModels, favoriteModels, recentModels, toggleFavorite } from "@/lib/ai/registry"; 3 import { listRegistryModels, favoriteModels, recentModels, toggleFavorite } from "@/lib/ai/registry";
4+import { modelLabels, setModelLabel } from "@/lib/ai/registry/user-models";
4 import { listConnections } from "@/lib/providers/keys"; 5 import { listConnections } from "@/lib/providers/keys";
5 6
6 export const dynamic = "force-dynamic"; 7 export const dynamic = "force-dynamic";
7 8
8−/** GET /api/models — the normalized registry plus the caller's favorites/recents and connected providers. */ 9+/** GET /api/models — the normalized registry plus the caller's favorites/recents/labels and connected providers. */
9 export const GET = withUser(async ({ req, user }) => { 10 export const GET = withUser(async ({ req, user }) => {
10 const includeDeprecated = new URL(req.url).searchParams.get("deprecated") === "1"; 11 const includeDeprecated = new URL(req.url).searchParams.get("deprecated") === "1";
11− const [models, favorites, recents, connections] = await Promise.all([listRegistryModels({ includeDeprecated }), favoriteModels(user.id), recentModels(user.id), listConnections(user.id)]); 12+ const [models, favorites, recents, labels, connections] = await Promise.all([listRegistryModels({ includeDeprecated }), favoriteModels(user.id), recentModels(user.id), modelLabels(user.id), listConnections(user.id)]);
12− return json({ models, favorites, recents, connectedProviders: connections.filter((c) => c.status !== "invalid").map((c) => c.provider) }); 13+ return json({ models, favorites, recents, labels, connectedProviders: connections.filter((c) => c.status !== "invalid").map((c) => c.provider) });
13 }); 14 });
14 15
16+const bodySchema = z.discriminatedUnion("action", [
17+ z.object({ action: z.literal("toggle-favorite"), modelKey: z.string().max(120) }),
18+ z.object({ action: z.literal("set-label"), modelKey: z.string().max(120), label: z.string().max(60).nullable() }),
19+]);
20+
15 export const POST = withUser(async ({ req, user }) => { 21 export const POST = withUser(async ({ req, user }) => {
16− const body = await parseBody(req, z.object({ action: z.literal("toggle-favorite"), modelKey: z.string().max(120) })); 22+ const body = await parseBody(req, bodySchema);
17− const favorite = await toggleFavorite(user.id, body.modelKey); 23+ if (body.action === "toggle-favorite") {
18− return json({ favorite }); 24+ const favorite = await toggleFavorite(user.id, body.modelKey);
25+ return json({ favorite });
26+ }
27+ await setModelLabel(user.id, body.modelKey, body.label);
28+ return json({ ok: true });
19 }); 29 });
modified src/app/globals.css +178 −41
@@ -1,21 +1,28 @@
1 @import "tailwindcss"; 1 @import "tailwindcss";
2 2
3 @custom-variant dark (&:where(.dark, .dark *)); 3 @custom-variant dark (&:where(.dark, .dark *));
4+/* Pointer-aware variants: `coarse:` = touch devices, `fine:` = mouse/trackpad. */
5+@custom-variant coarse (@media (pointer: coarse));
6+@custom-variant fine (@media (pointer: fine));
7+@custom-variant standalone (@media (display-mode: standalone));
4 8
5 @theme { 9 @theme {
6 --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; 10 --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
7 --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; 11 --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
8 12
13+ /* ---- Semantic colors (values live in :root / .dark below) ---------------- */
9 --color-bg: var(--bg); 14 --color-bg: var(--bg);
10 --color-bg-subtle: var(--bg-subtle); 15 --color-bg-subtle: var(--bg-subtle);
11 --color-bg-muted: var(--bg-muted); 16 --color-bg-muted: var(--bg-muted);
12 --color-bg-elevated: var(--bg-elevated); 17 --color-bg-elevated: var(--bg-elevated);
13 --color-bg-overlay: var(--bg-overlay); 18 --color-bg-overlay: var(--bg-overlay);
19+ --color-bg-inset: var(--bg-inset);
14 --color-fg: var(--fg); 20 --color-fg: var(--fg);
15 --color-fg-muted: var(--fg-muted); 21 --color-fg-muted: var(--fg-muted);
16 --color-fg-subtle: var(--fg-subtle); 22 --color-fg-subtle: var(--fg-subtle);
17 --color-border: var(--border); 23 --color-border: var(--border);
18 --color-border-strong: var(--border-strong); 24 --color-border-strong: var(--border-strong);
25+ --color-hairline: var(--hairline);
19 --color-accent: var(--accent); 26 --color-accent: var(--accent);
20 --color-accent-fg: var(--accent-fg); 27 --color-accent-fg: var(--accent-fg);
21 --color-accent-soft: var(--accent-soft); 28 --color-accent-soft: var(--accent-soft);
@@ -39,59 +46,116 @@
39 --color-kimi: var(--p-kimi); 46 --color-kimi: var(--p-kimi);
40 --color-openrouter: var(--p-openrouter); 47 --color-openrouter: var(--p-openrouter);
41 --color-cerebras: var(--p-cerebras); 48 --color-cerebras: var(--p-cerebras);
49+ --color-custom: var(--p-custom);
42 50
51+ /* ---- Radii ---------------------------------------------------------------- */
43 --radius-xs: 4px; 52 --radius-xs: 4px;
44 --radius-sm: 6px; 53 --radius-sm: 6px;
45 --radius-md: 8px; 54 --radius-md: 8px;
46 --radius-lg: 12px; 55 --radius-lg: 12px;
47 --radius-xl: 16px; 56 --radius-xl: 16px;
48 --radius-2xl: 22px; 57 --radius-2xl: 22px;
58+ --radius-3xl: 28px;
49 59
50− --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.06); 60+ /* ---- Elevation: soft, layered, low-contrast -------------------------------- */
51− --shadow-sm: 0 1px 3px rgb(0 0 0 / 0.08), 0 1px 2px rgb(0 0 0 / 0.04); 61+ --shadow-xs: 0 1px 2px rgb(15 17 26 / 0.05);
52− --shadow-md: 0 6px 16px rgb(0 0 0 / 0.10), 0 1px 3px rgb(0 0 0 / 0.06); 62+ --shadow-sm: 0 1px 2px rgb(15 17 26 / 0.06), 0 2px 6px -2px rgb(15 17 26 / 0.06);
53− --shadow-lg: 0 16px 40px rgb(0 0 0 / 0.16), 0 2px 8px rgb(0 0 0 / 0.08); 63+ --shadow-md: 0 2px 4px -1px rgb(15 17 26 / 0.06), 0 10px 24px -8px rgb(15 17 26 / 0.12);
54− --shadow-glow: 0 0 0 1px var(--accent-soft), 0 8px 32px -8px var(--accent); 64+ --shadow-lg: 0 4px 8px -2px rgb(15 17 26 / 0.08), 0 24px 56px -16px rgb(15 17 26 / 0.22);
65+ --shadow-sheet: 0 -8px 40px -12px rgb(15 17 26 / 0.28);
66+ --shadow-glow: 0 0 0 1px var(--accent-soft), 0 8px 32px -8px color-mix(in srgb, var(--accent) 55%, transparent);
67+ --shadow-ring: 0 0 0 1px var(--border);
55 68
56− --animate-fade-in: fade-in 0.2s ease-out both; 69+ /* ---- Type scale (fluid on small screens) ----------------------------------- */
57− --animate-fade-up: fade-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; 70+ --text-2xs: 10.5px;
58− --animate-scale-in: scale-in 0.18s cubic-bezier(0.16, 1, 0.3, 1) both; 71+ --text-2xs--line-height: 14px;
72+ --text-xs: 11.5px;
73+ --text-xs--line-height: 16px;
74+ --text-sm: 13px;
75+ --text-sm--line-height: 18px;
76+ --text-base: 15px;
77+ --text-base--line-height: 22px;
78+ --text-lg: 17px;
79+ --text-lg--line-height: 24px;
80+ --text-xl: 20px;
81+ --text-xl--line-height: 26px;
82+ --text-2xl: 24px;
83+ --text-2xl--line-height: 30px;
84+ --text-3xl: clamp(28px, 2.4vw + 18px, 34px);
85+ --text-3xl--line-height: 1.15;
86+ --text-4xl: clamp(34px, 3.2vw + 20px, 46px);
87+ --text-4xl--line-height: 1.08;
88+ --text-5xl: clamp(40px, 4.4vw + 22px, 62px);
89+ --text-5xl--line-height: 1.02;
90+
91+ /* ---- Motion --------------------------------------------------------------- */
92+ --ease-out-soft: cubic-bezier(0.16, 1, 0.3, 1);
93+ --ease-spring: cubic-bezier(0.34, 1.3, 0.64, 1);
94+ --animate-fade-in: fade-in 0.18s ease-out both;
95+ --animate-fade-out: fade-out 0.16s ease-in both;
96+ --animate-fade-up: fade-up 0.32s var(--ease-out-soft) both;
97+ --animate-scale-in: scale-in 0.18s var(--ease-out-soft) both;
98+ --animate-scale-out: scale-out 0.14s ease-in both;
99+ --animate-sheet-in: sheet-in 0.32s var(--ease-out-soft) both;
100+ --animate-sheet-out: sheet-out 0.22s ease-in both;
101+ --animate-drawer-in: drawer-in 0.28s var(--ease-out-soft) both;
102+ --animate-drawer-out: drawer-out 0.2s ease-in both;
103+ --animate-slide-down-in: slide-down-in 0.22s var(--ease-out-soft) both;
59 --animate-blink: blink 1s steps(2, start) infinite; 104 --animate-blink: blink 1s steps(2, start) infinite;
60 --animate-shimmer: shimmer 1.6s linear infinite; 105 --animate-shimmer: shimmer 1.6s linear infinite;
61 --animate-pulse-soft: pulse-soft 2s ease-in-out infinite; 106 --animate-pulse-soft: pulse-soft 2s ease-in-out infinite;
107+ --animate-pop: pop 0.32s var(--ease-spring) both;
108+ --animate-marquee: marquee 48s linear infinite;
109+ --animate-stream-glow: stream-glow 1.8s ease-in-out infinite;
62 110
63 @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } 111 @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
112+ @keyframes fade-out { from { opacity: 1; } to { opacity: 0; } }
64 @keyframes fade-up { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } } 113 @keyframes fade-up { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
65 @keyframes scale-in { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: scale(1); } } 114 @keyframes scale-in { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: scale(1); } }
115+ @keyframes scale-out { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.97); } }
116+ @keyframes sheet-in { from { transform: translateY(100%); } to { transform: translateY(0); } }
117+ @keyframes sheet-out { from { transform: translateY(0); } to { transform: translateY(100%); } }
118+ @keyframes drawer-in { from { transform: translateX(-100%); } to { transform: translateX(0); } }
119+ @keyframes drawer-out { from { transform: translateX(0); } to { transform: translateX(-100%); } }
120+ @keyframes slide-down-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
66 @keyframes blink { to { visibility: hidden; } } 121 @keyframes blink { to { visibility: hidden; } }
67 @keyframes shimmer { from { background-position: -200% 0; } to { background-position: 200% 0; } } 122 @keyframes shimmer { from { background-position: -200% 0; } to { background-position: 200% 0; } }
68 @keyframes pulse-soft { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } } 123 @keyframes pulse-soft { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
124+ @keyframes pop { 0% { transform: scale(0.6); opacity: 0; } 60% { transform: scale(1.12); opacity: 1; } 100% { transform: scale(1); } }
125+ @keyframes marquee { from { transform: translateX(0); } to { transform: translateX(-50%); } }
126+ @keyframes stream-glow { 0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); } 50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent); } }
69 } 127 }
70 128
129+/* ---------------------------------------------------------------------------
130+ Palette — warm graphite neutrals + iris accent. Light and dark are tuned to
131+ the same perceptual contrast so components need no per-theme overrides.
132+ --------------------------------------------------------------------------- */
71 :root { 133 :root {
72 color-scheme: light; 134 color-scheme: light;
73− --bg: #fafaf9; 135+ --bg: #f7f7f5;
74− --bg-subtle: #f3f3f1; 136+ --bg-subtle: #f1f1ee;
75− --bg-muted: #ebebe8; 137+ --bg-muted: #e9e9e5;
76 --bg-elevated: #ffffff; 138 --bg-elevated: #ffffff;
77− --bg-overlay: rgb(250 250 249 / 0.85); 139+ --bg-inset: #ececea;
78− --fg: #16171d; 140+ --bg-overlay: rgb(247 247 245 / 0.82);
79− --fg-muted: #5b6070; 141+ --fg: #14151a;
80− --fg-subtle: #8a8f9e; 142+ --fg-muted: #5c6070;
81− --border: #e4e4e0; 143+ --fg-subtle: #8b8f9d;
82− --border-strong: #cfcfca; 144+ --border: #e3e3df;
83− --accent: #5b5bd6; 145+ --border-strong: #cdcdc8;
84− --accent-strong: #4646c4; 146+ --hairline: rgb(20 21 26 / 0.07);
147+ --accent: #5457d6;
148+ --accent-strong: #4144c4;
85 --accent-fg: #ffffff; 149 --accent-fg: #ffffff;
86− --accent-soft: #e9e9fb; 150+ --accent-soft: #e8e8fb;
87− --success: #1f9d61; 151+ --success: #1c9a5d;
88− --success-soft: #e3f6ea; 152+ --success-soft: #e1f5e9;
89− --warning: #c07a12; 153+ --warning: #b8720f;
90− --warning-soft: #fbf0d9; 154+ --warning-soft: #fbefd6;
91− --danger: #d2373f; 155+ --danger: #d03540;
92− --danger-soft: #fbe5e6; 156+ --danger-soft: #fbe4e6;
93− --info: #2a7fd4; 157+ --info: #2779cf;
94− --info-soft: #e2effb; 158+ --info-soft: #e1eefb;
95 159
96 --p-openai: #10a37f; 160 --p-openai: #10a37f;
97 --p-anthropic: #d97757; 161 --p-anthropic: #d97757;
@@ -102,8 +166,17 @@
102 --p-kimi: #0f766e; 166 --p-kimi: #0f766e;
103 --p-openrouter: #6b46c1; 167 --p-openrouter: #6b46c1;
104 --p-cerebras: #c2410c; 168 --p-cerebras: #c2410c;
169+ --p-custom: #64748b;
105 170
106− --selection: rgb(91 91 214 / 0.18); 171+ --selection: rgb(84 87 214 / 0.18);
172+ --sat: env(safe-area-inset-top, 0px);
173+ --sab: env(safe-area-inset-bottom, 0px);
174+ --sal: env(safe-area-inset-left, 0px);
175+ --sar: env(safe-area-inset-right, 0px);
176+ /* Height of the mobile bottom navigation (without the safe area). */
177+ --bottom-nav-h: 56px;
178+ /* Set by the keyboard hook: pixels hidden by the on-screen keyboard. */
179+ --kb: 0px;
107 } 180 }
108 181
109 .dark { 182 .dark {
@@ -111,13 +184,15 @@
111 --bg: #0b0c10; 184 --bg: #0b0c10;
112 --bg-subtle: #10121a; 185 --bg-subtle: #10121a;
113 --bg-muted: #171923; 186 --bg-muted: #171923;
114− --bg-elevated: #14161e; 187+ --bg-elevated: #13151d;
115− --bg-overlay: rgb(11 12 16 / 0.82); 188+ --bg-inset: #0e0f15;
116− --fg: #f2f3f7; 189+ --bg-overlay: rgb(11 12 16 / 0.78);
117− --fg-muted: #9aa0b2; 190+ --fg: #f1f2f6;
118− --fg-subtle: #6a7080; 191+ --fg-muted: #9a9fb1;
192+ --fg-subtle: #676d7e;
119 --border: #1f222c; 193 --border: #1f222c;
120− --border-strong: #2d3140; 194+ --border-strong: #2c3040;
195+ --hairline: rgb(241 242 246 / 0.07);
121 --accent: #8b8dff; 196 --accent: #8b8dff;
122 --accent-strong: #a3a5ff; 197 --accent-strong: #a3a5ff;
123 --accent-fg: #0b0c10; 198 --accent-fg: #0b0c10;
@@ -140,13 +215,21 @@
140 --p-kimi: #2dd4bf; 215 --p-kimi: #2dd4bf;
141 --p-openrouter: #a78bfa; 216 --p-openrouter: #a78bfa;
142 --p-cerebras: #fb923c; 217 --p-cerebras: #fb923c;
218+ --p-custom: #9aa3b5;
143 219
144 --selection: rgb(139 141 255 / 0.28); 220 --selection: rgb(139 141 255 / 0.28);
145 } 221 }
146 222
147 @layer base { 223 @layer base {
148 * { border-color: var(--border); } 224 * { border-color: var(--border); }
149− html { -webkit-text-size-adjust: 100%; text-rendering: optimizeLegibility; scroll-behavior: auto; } 225+ html {
226+ -webkit-text-size-adjust: 100%;
227+ text-rendering: optimizeLegibility;
228+ scroll-behavior: auto;
229+ /* iOS: never let the root rubber-band; scroll containers own overscroll. */
230+ overscroll-behavior: none;
231+ -webkit-tap-highlight-color: transparent;
232+ }
150 body { 233 body {
151 background: var(--bg); 234 background: var(--bg);
152 color: var(--fg); 235 color: var(--fg);
@@ -158,12 +241,14 @@
158 } 241 }
159 ::selection { background: var(--selection); } 242 ::selection { background: var(--selection); }
160 :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } 243 :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
161− button, [role="button"] { cursor: pointer; } 244+ button, [role="button"] { cursor: pointer; touch-action: manipulation; }
162 button:disabled { cursor: not-allowed; } 245 button:disabled { cursor: not-allowed; }
163 input, textarea, select { font: inherit; } 246 input, textarea, select { font: inherit; }
247+ /* iOS zooms into inputs below 16px; keep them at 16px on coarse pointers. */
248+ @media (pointer: coarse) { input, textarea, select { font-size: max(16px, 1em); } }
164 code, kbd, pre, samp { font-family: var(--font-mono); font-feature-settings: "liga" 0; } 249 code, kbd, pre, samp { font-family: var(--font-mono); font-feature-settings: "liga" 0; }
165 @media (prefers-reduced-motion: reduce) { 250 @media (prefers-reduced-motion: reduce) {
166− *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } 251+ *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }
167 } 252 }
168 } 253 }
169 254
@@ -174,13 +259,18 @@
174 .scrollbar-none { scrollbar-width: none; } 259 .scrollbar-none { scrollbar-width: none; }
175 .scrollbar-none::-webkit-scrollbar { display: none; } 260 .scrollbar-none::-webkit-scrollbar { display: none; }
176 261
177− .glass { background: var(--bg-overlay); backdrop-filter: saturate(160%) blur(14px); -webkit-backdrop-filter: saturate(160%) blur(14px); } 262+ .glass { background: var(--bg-overlay); backdrop-filter: saturate(160%) blur(16px); -webkit-backdrop-filter: saturate(160%) blur(16px); }
263+ .glass-strong { background: color-mix(in srgb, var(--bg-elevated) 88%, transparent); backdrop-filter: saturate(180%) blur(24px); -webkit-backdrop-filter: saturate(180%) blur(24px); }
178 264
179 .text-gradient { 265 .text-gradient {
180 background: linear-gradient(120deg, var(--accent) 0%, #b58cff 55%, #ff8fab 100%); 266 background: linear-gradient(120deg, var(--accent) 0%, #b58cff 55%, #ff8fab 100%);
181 -webkit-background-clip: text; background-clip: text; color: transparent; 267 -webkit-background-clip: text; background-clip: text; color: transparent;
182 } 268 }
183 .surface { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-lg); } 269 .surface { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-lg); }
270+ /* Soft, borderless grouping — preferred over cards for dense professional UI. */
271+ .panel { background: var(--bg-subtle); border-radius: var(--radius-xl); }
272+ .hairline-b { box-shadow: inset 0 -1px 0 var(--hairline); }
273+ .hairline-t { box-shadow: inset 0 1px 0 var(--hairline); }
184 .kbd { 274 .kbd {
185 display: inline-flex; align-items: center; justify-content: center; min-width: 1.4rem; height: 1.25rem; padding: 0 0.35rem; 275 display: inline-flex; align-items: center; justify-content: center; min-width: 1.4rem; height: 1.25rem; padding: 0 0.35rem;
186 border-radius: 5px; border: 1px solid var(--border-strong); background: var(--bg-subtle); color: var(--fg-muted); 276 border-radius: 5px; border: 1px solid var(--border-strong); background: var(--bg-subtle); color: var(--fg-muted);
@@ -195,10 +285,47 @@
195 background-image: radial-gradient(var(--border-strong) 1px, transparent 1px); 285 background-image: radial-gradient(var(--border-strong) 1px, transparent 1px);
196 background-size: 22px 22px; 286 background-size: 22px 22px;
197 } 287 }
288+ /* Minimum 44px hit area regardless of the visual size. */
289+ .tap { position: relative; }
290+ .tap::after { content: ""; position: absolute; inset: 50% auto auto 50%; width: max(100%, 44px); height: max(100%, 44px); transform: translate(-50%, -50%); }
291+ /* Safe-area helpers */
292+ .pt-safe { padding-top: var(--sat); }
293+ .pb-safe { padding-bottom: var(--sab); }
294+ .pl-safe { padding-left: var(--sal); }
295+ .pr-safe { padding-right: var(--sar); }
296+ .px-safe { padding-left: var(--sal); padding-right: var(--sar); }
297+ .mb-safe { margin-bottom: var(--sab); }
298+ /* Space reserved for the mobile bottom navigation. */
299+ .pb-nav { padding-bottom: calc(var(--bottom-nav-h) + var(--sab)); }
300+ /* Full app height that tracks the on-screen keyboard (set by useKeyboardInset). */
301+ .h-app { height: calc(100dvh - var(--kb)); }
302+ .min-h-app { min-height: calc(100dvh - var(--kb)); }
303+ /* Sheet drag handle */
304+ .sheet-handle { width: 36px; height: 5px; border-radius: 999px; background: var(--border-strong); margin: 8px auto 4px; }
305+ /* Horizontal snap carousel (Arena / compare panels on mobile) */
306+ .snap-row { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
307+ .snap-row::-webkit-scrollbar { display: none; }
308+ .snap-row > * { scroll-snap-align: center; scroll-snap-stop: always; flex: 0 0 100%; }
309+ /* Hide only the scroll chaining, keep the scroll */
310+ .contain-scroll { overscroll-behavior: contain; }
311+ .touch-pan-y { touch-action: pan-y; }
312+ .touch-pan-x { touch-action: pan-x; }
313+ /* Hover-revealed controls: always visible on touch devices. */
314+ .hover-reveal { opacity: 0; transition: opacity 150ms; }
315+ .group:hover .hover-reveal, .group:focus-within .hover-reveal, .hover-reveal:focus-visible { opacity: 1; }
316+ @media (pointer: coarse) { .hover-reveal { opacity: 1; } }
317+ /* Marquee strip */
318+ .marquee { display: flex; width: max-content; animation: var(--animate-marquee); }
319+ .marquee:hover { animation-play-state: paused; }
320+ .mask-x { mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); }
321+ .mask-b { mask-image: linear-gradient(180deg, #000 70%, transparent); }
322+ .tabular { font-variant-numeric: tabular-nums; }
323+ .text-balance { text-wrap: balance; }
324+ .text-pretty { text-wrap: pretty; }
198 } 325 }
199 326
200 /* Markdown (chat messages) */ 327 /* Markdown (chat messages) */
201−.prose-chat { line-height: 1.65; font-size: 15px; word-break: break-word; } 328+.prose-chat { line-height: 1.65; font-size: 15px; word-break: break-word; overflow-wrap: anywhere; }
202 .prose-chat > * + * { margin-top: 0.75em; } 329 .prose-chat > * + * { margin-top: 0.75em; }
203 .prose-chat h1, .prose-chat h2, .prose-chat h3, .prose-chat h4 { font-weight: 650; letter-spacing: -0.01em; line-height: 1.3; margin-top: 1.2em; } 330 .prose-chat h1, .prose-chat h2, .prose-chat h3, .prose-chat h4 { font-weight: 650; letter-spacing: -0.01em; line-height: 1.3; margin-top: 1.2em; }
204 .prose-chat h1 { font-size: 1.35em; } .prose-chat h2 { font-size: 1.2em; } .prose-chat h3 { font-size: 1.08em; } .prose-chat h4 { font-size: 1em; } 331 .prose-chat h1 { font-size: 1.35em; } .prose-chat h2 { font-size: 1.2em; } .prose-chat h3 { font-size: 1.08em; } .prose-chat h4 { font-size: 1em; }
@@ -213,12 +340,22 @@
213 .prose-chat hr { border: 0; border-top: 1px solid var(--border); margin: 1.2em 0; } 340 .prose-chat hr { border: 0; border-top: 1px solid var(--border); margin: 1.2em 0; }
214 .prose-chat :not(pre) > code { font-size: 0.86em; background: var(--bg-muted); border: 1px solid var(--border); padding: 0.1em 0.38em; border-radius: 5px; } 341 .prose-chat :not(pre) > code { font-size: 0.86em; background: var(--bg-muted); border: 1px solid var(--border); padding: 0.1em 0.38em; border-radius: 5px; }
215 .prose-chat pre { margin: 0; } 342 .prose-chat pre { margin: 0; }
216−.prose-chat table { width: 100%; border-collapse: collapse; font-size: 0.92em; display: block; overflow-x: auto; } 343+.prose-chat table { width: 100%; border-collapse: collapse; font-size: 0.92em; display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
217 .prose-chat th, .prose-chat td { border: 1px solid var(--border); padding: 0.4em 0.7em; text-align: left; vertical-align: top; } 344 .prose-chat th, .prose-chat td { border: 1px solid var(--border); padding: 0.4em 0.7em; text-align: left; vertical-align: top; }
218 .prose-chat th { background: var(--bg-subtle); font-weight: 600; } 345 .prose-chat th { background: var(--bg-subtle); font-weight: 600; }
219 .prose-chat img { max-width: 100%; border-radius: 8px; } 346 .prose-chat img { max-width: 100%; border-radius: 8px; }
220 .prose-chat.wrap-code pre code { white-space: pre-wrap; word-break: break-word; } 347 .prose-chat.wrap-code pre code { white-space: pre-wrap; word-break: break-word; }
348+.prose-chat .katex-display { overflow-x: auto; overflow-y: hidden; padding: 0.2em 0; }
221 349
222 /* Shiki */ 350 /* Shiki */
223 .shiki, .shiki span { background-color: transparent !important; } 351 .shiki, .shiki span { background-color: transparent !important; }
224 .dark .shiki, .dark .shiki span { color: var(--shiki-dark) !important; } 352 .dark .shiki, .dark .shiki span { color: var(--shiki-dark) !important; }
353+
354+/* Recharts: inherit the type system */
355+.recharts-text { font-family: var(--font-sans); }
356+.recharts-default-tooltip { border-radius: 10px !important; border: 1px solid var(--border) !important; background: var(--bg-elevated) !important; box-shadow: var(--shadow-md); }
357+
358+/* Standalone PWA: reserve the status bar area */
359+@media (display-mode: standalone) {
360+ body { padding-top: 0; }
361+}
modified src/app/layout.tsx +21 −5
@@ -17,24 +17,40 @@ export const metadata: Metadata = {
17 openGraph: { 17 openGraph: {
18 type: "website", 18 type: "website",
19 siteName: "PolyLLM", 19 siteName: "PolyLLM",
20− title: "PolyLLM — Your models. Your keys. One workspace.", 20+ title: "PolyLLM — One interface. Every model.",
21− description: "A universal control center for AI models. Bring your own keys.", 21+ description: "Bring your own keys. Compare models. Control every parameter. Track every token. Your models. Your keys. One workspace.",
22+ locale: "en_US",
22 url: APP_URL, 23 url: APP_URL,
23 images: [{ url: "/og.png", width: 1200, height: 630, alt: "PolyLLM" }], 24 images: [{ url: "/og.png", width: 1200, height: 630, alt: "PolyLLM" }],
24 }, 25 },
25− twitter: { card: "summary_large_image", title: "PolyLLM", description: "One interface. Every model. Bring your own keys." }, 26+ twitter: { card: "summary_large_image", title: "PolyLLM — One interface. Every model.", description: "Bring your own keys. Compare models. Control every parameter. Track every token.", images: ["/og.png"] },
26− icons: { icon: [{ url: "/icon.svg", type: "image/svg+xml" }], apple: "/apple-icon.png" }, 27+ authors: [{ name: "Simon-Pierre Boucher", url: "https://www.maclustr.io" }],
28+ creator: "Simon-Pierre Boucher",
29+ icons: {
30+ icon: [
31+ { url: "/icon.svg", type: "image/svg+xml" },
32+ { url: "/favicon-32.png", sizes: "32x32", type: "image/png" },
33+ { url: "/favicon-16.png", sizes: "16x16", type: "image/png" },
34+ ],
35+ shortcut: "/favicon.ico",
36+ apple: [{ url: "/apple-icon.png", sizes: "180x180" }],
37+ },
38+ manifest: "/manifest.webmanifest",
39+ appleWebApp: { capable: true, title: "PolyLLM", statusBarStyle: "black-translucent" },
40+ formatDetection: { telephone: false },
27 robots: { index: true, follow: true }, 41 robots: { index: true, follow: true },
28 }; 42 };
29 43
30 export const viewport: Viewport = { 44 export const viewport: Viewport = {
31 themeColor: [ 45 themeColor: [
32− { media: "(prefers-color-scheme: light)", color: "#fafaf9" }, 46+ { media: "(prefers-color-scheme: light)", color: "#f7f7f5" },
33 { media: "(prefers-color-scheme: dark)", color: "#0b0c10" }, 47 { media: "(prefers-color-scheme: dark)", color: "#0b0c10" },
34 ], 48 ],
35 width: "device-width", 49 width: "device-width",
36 initialScale: 1, 50 initialScale: 1,
37 viewportFit: "cover", 51 viewportFit: "cover",
52+ // Let the browser resize the layout viewport when the keyboard opens (Chrome/Android); iOS uses visualViewport.
53+ interactiveWidget: "resizes-content",
38 }; 54 };
39 55
40 export default function RootLayout({ children }: { children: React.ReactNode }) { 56 export default function RootLayout({ children }: { children: React.ReactNode }) {
added src/app/manifest.ts +30 −0
@@ -0,0 +1,30 @@
1+import type { MetadataRoute } from "next";
2+
3+export default function manifest(): MetadataRoute.Manifest {
4+ return {
5+ name: "PolyLLM — One interface. Every model.",
6+ short_name: "PolyLLM",
7+ description: "Bring your own keys. Compare models. Control every parameter. Track every token.",
8+ id: "/app/chat",
9+ start_url: "/app/chat",
10+ scope: "/",
11+ display: "standalone",
12+ display_override: ["standalone", "minimal-ui"],
13+ orientation: "portrait",
14+ background_color: "#0b0c10",
15+ theme_color: "#0b0c10",
16+ lang: "en",
17+ categories: ["productivity", "developer tools", "utilities"],
18+ icons: [
19+ { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
20+ { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
21+ { src: "/icon-maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
22+ ],
23+ shortcuts: [
24+ { name: "New chat", url: "/app/chat", icons: [{ src: "/icon-192.png", sizes: "192x192" }] },
25+ { name: "Arena", url: "/app/arena", icons: [{ src: "/icon-192.png", sizes: "192x192" }] },
26+ { name: "Models", url: "/app/models", icons: [{ src: "/icon-192.png", sizes: "192x192" }] },
27+ ],
28+ screenshots: [{ src: "/og.png", sizes: "1200x630", type: "image/png", form_factor: "wide" }],
29+ };
30+}
added src/components/app/bottom-nav.tsx +52 −0
@@ -0,0 +1,52 @@
1+"use client";
2+import * as React from "react";
3+import Link from "next/link";
4+import { usePathname } from "next/navigation";
5+import { BarChart3, Boxes, MessageSquare, Swords, UserRound } from "lucide-react";
6+import { cn } from "@/lib/utils";
7+
8+export const MOBILE_TABS = [
9+ { href: "/app/chat", label: "Chat", icon: MessageSquare, match: /^\/app\/(chat|projects|prompts|presets)/ },
10+ { href: "/app/arena", label: "Arena", icon: Swords, match: /^\/app\/arena/ },
11+ { href: "/app/models", label: "Models", icon: Boxes, match: /^\/app\/models/ },
12+ { href: "/app/usage", label: "Usage", icon: BarChart3, match: /^\/app\/usage/ },
13+ { href: "/app/settings/account", label: "Account", icon: UserRound, match: /^\/app\/settings/ },
14+] as const;
15+
16+/**
17+ * Persistent bottom navigation for phones. Hidden automatically while the on-screen keyboard is open
18+ * (`html[data-keyboard]`) so the composer sits directly above the keyboard.
19+ */
20+export function BottomNav({ className }: { className?: string }) {
21+ const pathname = usePathname();
22+ return (
23+ <nav
24+ aria-label="Primary"
25+ className={cn(
26+ "glass-strong fixed inset-x-0 bottom-0 z-30 border-t border-hairline md:hidden",
27+ "pb-[var(--sab)] transition-transform duration-200 ease-out [html[data-keyboard]_&]:translate-y-full",
28+ className,
29+ )}
30+ >
31+ <ul className="mx-auto grid h-[var(--bottom-nav-h)] max-w-lg grid-cols-5">
32+ {MOBILE_TABS.map((t) => {
33+ const active = t.match.test(pathname);
34+ return (
35+ <li key={t.href} className="min-w-0">
36+ <Link
37+ href={t.href}
38+ aria-current={active ? "page" : undefined}
39+ className={cn("flex h-full flex-col items-center justify-center gap-0.5 text-[10.5px] font-medium tracking-tight transition-colors", active ? "text-fg" : "text-fg-subtle active:text-fg-muted")}
40+ >
41+ <span className={cn("flex h-7 w-12 items-center justify-center rounded-full transition-[background-color,transform] duration-200", active ? "bg-accent-soft text-accent" : "")}>
42+ <t.icon className={cn("size-[21px]", active && "scale-105")} strokeWidth={active ? 2.2 : 1.8} />
43+ </span>
44+ {t.label}
45+ </Link>
46+ </li>
47+ );
48+ })}
49+ </ul>
50+ </nav>
51+ );
52+}
modified src/components/app/shell.tsx +76 −23
@@ -5,36 +5,41 @@ import { PanelLeft } from "lucide-react";
5 import { useApp } from "./store"; 5 import { useApp } from "./store";
6 import { Sidebar } from "./sidebar"; 6 import { Sidebar } from "./sidebar";
7 import { CommandPalette } from "./command-palette"; 7 import { CommandPalette } from "./command-palette";
8+import { BottomNav } from "./bottom-nav";
8 import { Button } from "@/components/ui/button"; 9 import { Button } from "@/components/ui/button";
10+import { useIsMobile, useKeyboardInset, useLocalStorage, usePrefersReducedMotion, useSwipe } from "@/lib/client/hooks";
9 import { cn } from "@/lib/utils"; 11 import { cn } from "@/lib/utils";
10 12
11 const LS_COLLAPSED = "polyllm:sidebar-collapsed"; 13 const LS_COLLAPSED = "polyllm:sidebar-collapsed";
12 14
15+/**
16+ * Application shell.
17+ *
18+ * Desktop (≥ md): persistent left sidebar (collapsible, ⌘B) + content.
19+ * Phone: full-viewport content (`.h-app` tracks the keyboard), a slide-over conversation drawer
20+ * (edge-swipe to open, swipe left / tap scrim to close) and a persistent bottom navigation.
21+ */
13 export function AppShell({ children }: { children: React.ReactNode }) { 22 export function AppShell({ children }: { children: React.ReactNode }) {
14 const { sidebarOpen, setSidebarOpen, setPaletteOpen, paletteOpen } = useApp(); 23 const { sidebarOpen, setSidebarOpen, setPaletteOpen, paletteOpen } = useApp();
15− const [collapsed, setCollapsed] = React.useState(false); 24+ const [collapsed, setCollapsed] = useLocalStorage<boolean>(LS_COLLAPSED, false);
16 const router = useRouter(); 25 const router = useRouter();
17 const pathname = usePathname(); 26 const pathname = usePathname();
27+ const isMobile = useIsMobile();
28+ useKeyboardInset();
18 29
19− React.useEffect(() => { 30+ // Close the drawer on navigation.
20− // eslint-disable-next-line react-hooks/set-state-in-effect
21− setCollapsed(window.localStorage.getItem(LS_COLLAPSED) === "1");
22− }, []);
23 React.useEffect(() => { 31 React.useEffect(() => {
24 setSidebarOpen(false); 32 setSidebarOpen(false);
25 }, [pathname, setSidebarOpen]); 33 }, [pathname, setSidebarOpen]);
26 34
27− const toggleCollapsed = React.useCallback(() => { 35+ const toggleCollapsed = React.useCallback(() => setCollapsed((c) => !c), [setCollapsed]);
28− setCollapsed((c) => {
29− window.localStorage.setItem(LS_COLLAPSED, c ? "0" : "1");
30− return !c;
31− });
32− }, []);
33 36
34 // Global shortcuts 37 // Global shortcuts
35 React.useEffect(() => { 38 React.useEffect(() => {
36 const onKey = (e: KeyboardEvent) => { 39 const onKey = (e: KeyboardEvent) => {
37 const mod = e.metaKey || e.ctrlKey; 40 const mod = e.metaKey || e.ctrlKey;
41+ const target = e.target as HTMLElement | null;
42+ const typing = target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);
38 if (mod && e.key.toLowerCase() === "k") { 43 if (mod && e.key.toLowerCase() === "k") {
39 e.preventDefault(); 44 e.preventDefault();
40 setPaletteOpen(!paletteOpen); 45 setPaletteOpen(!paletteOpen);
@@ -44,30 +49,34 @@ export function AppShell({ children }: { children: React.ReactNode }) {
44 } else if (mod && e.key === "b") { 49 } else if (mod && e.key === "b") {
45 e.preventDefault(); 50 e.preventDefault();
46 toggleCollapsed(); 51 toggleCollapsed();
52+ } else if (!mod && !typing && e.key === "?" ) {
53+ e.preventDefault();
54+ setPaletteOpen(true);
47 } 55 }
48 }; 56 };
49 window.addEventListener("keydown", onKey); 57 window.addEventListener("keydown", onKey);
50 return () => window.removeEventListener("keydown", onKey); 58 return () => window.removeEventListener("keydown", onKey);
51 }, [paletteOpen, router, setPaletteOpen, toggleCollapsed]); 59 }, [paletteOpen, router, setPaletteOpen, toggleCollapsed]);
52 60
61+ // Edge swipe (from the left 24px) opens the drawer on phones.
62+ const contentRef = React.useRef<HTMLDivElement>(null);
63+ const openDrawer = React.useCallback(() => setSidebarOpen(true), [setSidebarOpen]);
64+ useSwipe(contentRef, { onSwipeRight: openDrawer, edge: 28, disabled: !isMobile || sidebarOpen, ignoreSelector: ".snap-row, [data-no-edge-swipe]" });
65+
66+ // Hide the bottom nav on full-screen surfaces (e.g. share preview) via a data attribute set by pages.
67+ const showBottomNav = isMobile && !pathname.startsWith("/app/onboarding");
68+
53 return ( 69 return (
54− <div className="flex h-dvh w-full overflow-hidden bg-bg"> 70+ <div className="flex h-app w-full overflow-hidden bg-bg">
55 {/* Desktop sidebar */} 71 {/* Desktop sidebar */}
56− <aside className={cn("hidden md:flex h-full shrink-0 flex-col border-r border-border bg-bg-subtle transition-[width] duration-200 ease-out", collapsed ? "w-0 overflow-hidden border-r-0" : "w-[272px]")}> 72+ <aside className={cn("hidden md:flex h-full shrink-0 flex-col border-r border-border bg-bg-subtle transition-[width] duration-200 ease-out", collapsed ? "w-0 overflow-hidden border-r-0" : "w-[276px]")}>
57 <Sidebar onCollapse={toggleCollapsed} /> 73 <Sidebar onCollapse={toggleCollapsed} />
58 </aside> 74 </aside>
59 75
60 {/* Mobile drawer */} 76 {/* Mobile drawer */}
61− {sidebarOpen ? ( 77+ <MobileDrawer open={sidebarOpen && isMobile} onClose={() => setSidebarOpen(false)} />
62− <div className="fixed inset-0 z-40 md:hidden"> 78+
63− <div className="absolute inset-0 bg-black/50 animate-fade-in" onClick={() => setSidebarOpen(false)} /> 79+ <div ref={contentRef} className={cn("relative flex min-w-0 flex-1 flex-col", showBottomNav && "pb-[calc(var(--bottom-nav-h)+var(--sab))] [html[data-keyboard]_&]:pb-0")}>
64− <aside className="absolute inset-y-0 left-0 flex w-[86vw] max-w-[320px] flex-col border-r border-border bg-bg-subtle shadow-lg animate-fade-in">
65− <Sidebar onCollapse={() => setSidebarOpen(false)} mobile />
66− </aside>
67− </div>
68− ) : null}
69−
70− <div className="relative flex min-w-0 flex-1 flex-col">
71 {collapsed ? ( 80 {collapsed ? (
72 <Button variant="ghost" size="icon-sm" className="absolute left-2 top-2 z-20 hidden md:inline-flex" onClick={toggleCollapsed} aria-label="Show sidebar"> 81 <Button variant="ghost" size="icon-sm" className="absolute left-2 top-2 z-20 hidden md:inline-flex" onClick={toggleCollapsed} aria-label="Show sidebar">
73 <PanelLeft /> 82 <PanelLeft />
@@ -75,7 +84,51 @@ export function AppShell({ children }: { children: React.ReactNode }) {
75 ) : null} 84 ) : null}
76 {children} 85 {children}
77 </div> 86 </div>
87+
88+ {showBottomNav ? <BottomNav /> : null}
78 <CommandPalette /> 89 <CommandPalette />
79 </div> 90 </div>
80 ); 91 );
81 } 92 }
93+
94+function MobileDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
95+ const reduced = usePrefersReducedMotion();
96+ const [render, setRender] = React.useState(open);
97+ const [closing, setClosing] = React.useState(false);
98+ const panelRef = React.useRef<HTMLDivElement>(null);
99+
100+ React.useEffect(() => {
101+ if (open) {
102+ // eslint-disable-next-line react-hooks/set-state-in-effect
103+ setRender(true);
104+ setClosing(false);
105+ } else if (render) {
106+ setClosing(true);
107+ const t = setTimeout(() => {
108+ setRender(false);
109+ setClosing(false);
110+ }, reduced ? 0 : 200);
111+ return () => clearTimeout(t);
112+ }
113+ }, [open, render, reduced]);
114+
115+ useSwipe(panelRef, { onSwipeLeft: onClose, disabled: !open });
116+
117+ // Lock body scroll and close on Escape while open.
118+ React.useEffect(() => {
119+ if (!open) return;
120+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
121+ window.addEventListener("keydown", onKey);
122+ return () => window.removeEventListener("keydown", onKey);
123+ }, [open, onClose]);
124+
125+ if (!render) return null;
126+ return (
127+ <div className="fixed inset-0 z-40 md:hidden" role="dialog" aria-modal="true" aria-label="Conversations">
128+ <div className={cn("absolute inset-0 bg-black/45 backdrop-blur-[2px]", closing ? "animate-fade-out" : "animate-fade-in")} onClick={onClose} />
129+ <div ref={panelRef} className={cn("absolute inset-y-0 left-0 flex w-[min(86vw,340px)] flex-col bg-bg-subtle shadow-lg pt-[var(--sat)] pb-[var(--sab)]", closing ? "animate-drawer-out" : "animate-drawer-in")}>
130+ <Sidebar onCollapse={onClose} mobile />
131+ </div>
132+ </div>
133+ );
134+}
modified src/components/app/sidebar.tsx +155 −108
@@ -2,18 +2,22 @@
2 import * as React from "react"; 2 import * as React from "react";
3 import Link from "next/link"; 3 import Link from "next/link";
4 import { usePathname, useRouter } from "next/navigation"; 4 import { usePathname, useRouter } from "next/navigation";
5−import { Archive, BarChart3, Boxes, ChevronDown, ChevronRight, Copy, Download, Folder as FolderIcon, FolderPlus, MessageSquare, MoreHorizontal, PanelLeftClose, Pencil, Pin, PinOff, Plus, Search, Settings, ShieldCheck, Sparkles, Swords, Trash2, WandSparkles, X, Share2, LogOut } from "lucide-react"; 5+import { Archive, BarChart3, Boxes, ChevronDown, ChevronRight, Copy, Download, Folder as FolderIcon, FolderPlus, MessageSquare, MoreHorizontal, PanelLeftClose, Pencil, Pin, PinOff, Plus, Search, Settings, ShieldCheck, Sparkles, Swords, Trash2, WandSparkles, X, Share2, LogOut, Library, BookMarked } from "lucide-react";
6 import { useApp, invalidateConversations } from "./store"; 6 import { useApp, invalidateConversations } from "./store";
7 import { useApi, api } from "@/lib/client/api"; 7 import { useApi, api } from "@/lib/client/api";
8 import type { ConversationsResponse, PublicConversation } from "@/lib/client/types"; 8 import type { ConversationsResponse, PublicConversation } from "@/lib/client/types";
9 import { Logo } from "@/components/brand/logo"; 9 import { Logo } from "@/components/brand/logo";
10 import { ProviderIcon } from "@/components/brand/provider-icon"; 10 import { ProviderIcon } from "@/components/brand/provider-icon";
11 import { Button } from "@/components/ui/button"; 11 import { Button } from "@/components/ui/button";
12−import { Input } from "@/components/ui/input";
13 import { Kbd, Skeleton } from "@/components/ui/misc"; 12 import { Kbd, Skeleton } from "@/components/ui/misc";
14 import { Tooltip } from "@/components/ui/tooltip"; 13 import { Tooltip } from "@/components/ui/tooltip";
15 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuLabel } from "@/components/ui/dropdown-menu"; 14 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuLabel } from "@/components/ui/dropdown-menu";
15+import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet";
16+import { PromptDialog } from "@/components/common/prompt-dialog";
17+import { ConfirmDialog } from "@/components/common/confirm-dialog";
18+import { ProjectsSidebarSection } from "@/components/projects/sidebar-section";
16 import { toast } from "@/components/ui/toast"; 19 import { toast } from "@/components/ui/toast";
20+import { useIsMobile, useLongPress } from "@/lib/client/hooks";
17 import { cn } from "@/lib/utils"; 21 import { cn } from "@/lib/utils";
18 import { signOut } from "@/lib/auth-client"; 22 import { signOut } from "@/lib/auth-client";
19 23
@@ -23,20 +27,29 @@ const NAV = [
23 { href: "/app/models", label: "Models", icon: Boxes, match: /^\/app\/models/ }, 27 { href: "/app/models", label: "Models", icon: Boxes, match: /^\/app\/models/ },
24 { href: "/app/prompts", label: "Prompts", icon: WandSparkles, match: /^\/app\/prompts/ }, 28 { href: "/app/prompts", label: "Prompts", icon: WandSparkles, match: /^\/app\/prompts/ },
25 { href: "/app/presets", label: "Presets", icon: Sparkles, match: /^\/app\/presets/ }, 29 { href: "/app/presets", label: "Presets", icon: Sparkles, match: /^\/app\/presets/ },
30+ { href: "/app/library", label: "Library", icon: Library, match: /^\/app\/library/ },
26 { href: "/app/usage", label: "Usage", icon: BarChart3, match: /^\/app\/usage/ }, 31 { href: "/app/usage", label: "Usage", icon: BarChart3, match: /^\/app\/usage/ },
27 ]; 32 ];
28 33
34+type Modal = { kind: "rename"; c: PublicConversation } | { kind: "delete"; c: PublicConversation } | { kind: "new-folder" } | { kind: "delete-folder"; id: string; name: string } | null;
35+
36+/**
37+ * Conversation sidebar. Desktop: persistent column. Phone (`mobile`): content of the slide-over drawer
38+ * with 44px rows, long-press actions (ActionSheet) and no browser `prompt()`/`confirm()` dialogs.
39+ */
29 export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile?: boolean }) { 40 export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile?: boolean }) {
30− const { user, folders, refreshFolders, setPaletteOpen } = useApp(); 41+ const { user, folders, refreshFolders, setPaletteOpen, setSearchOpen } = useApp();
31 const pathname = usePathname(); 42 const pathname = usePathname();
32 const router = useRouter(); 43 const router = useRouter();
44+ const isMobile = useIsMobile();
33 const [q, setQ] = React.useState(""); 45 const [q, setQ] = React.useState("");
34 const [showArchived, setShowArchived] = React.useState(false); 46 const [showArchived, setShowArchived] = React.useState(false);
47+ const [modal, setModal] = React.useState<Modal>(null);
48+ const [sheetFor, setSheetFor] = React.useState<PublicConversation | null>(null);
35 const key = `/api/conversations?limit=120${showArchived ? "&archived=1" : ""}${q ? `&q=${encodeURIComponent(q)}` : ""}`; 49 const key = `/api/conversations?limit=120${showArchived ? "&archived=1" : ""}${q ? `&q=${encodeURIComponent(q)}` : ""}`;
36 const { data, isLoading, mutate } = useApi<ConversationsResponse>(key, { keepPreviousData: true }); 50 const { data, isLoading, mutate } = useApi<ConversationsResponse>(key, { keepPreviousData: true });
37 const list = React.useMemo(() => data?.conversations ?? [], [data]); 51 const list = React.useMemo(() => data?.conversations ?? [], [data]);
38 const activeId = pathname.match(/^\/app\/chat\/([^/]+)/)?.[1]; 52 const activeId = pathname.match(/^\/app\/chat\/([^/]+)/)?.[1];
39−
40 const groups = React.useMemo(() => groupConversations(list, folders.map((f) => ({ id: f.id, name: f.name }))), [list, folders]); 53 const groups = React.useMemo(() => groupConversations(list, folders.map((f) => ({ id: f.id, name: f.name }))), [list, folders]);
41 54
42 const patch = async (id: string, body: Record<string, unknown>) => { 55 const patch = async (id: string, body: Record<string, unknown>) => {
@@ -44,26 +57,18 @@ export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile
44 await mutate(); 57 await mutate();
45 invalidateConversations(); 58 invalidateConversations();
46 }; 59 };
47−
48 const onDelete = async (c: PublicConversation) => { 60 const onDelete = async (c: PublicConversation) => {
49− if (!confirm(`Delete "${c.title}"? This cannot be undone.`)) return;
50 await api(`/api/conversations/${c.id}`, { method: "DELETE" }); 61 await api(`/api/conversations/${c.id}`, { method: "DELETE" });
51 await mutate(); 62 await mutate();
63+ invalidateConversations();
52 if (activeId === c.id) router.push("/app/chat"); 64 if (activeId === c.id) router.push("/app/chat");
53 toast.success("Conversation deleted"); 65 toast.success("Conversation deleted");
54 }; 66 };
55−
56− const onRename = async (c: PublicConversation) => {
57− const title = prompt("Rename conversation", c.title);
58− if (title && title.trim() && title !== c.title) await patch(c.id, { title: title.trim() });
59− };
60−
61 const onDuplicate = async (c: PublicConversation) => { 67 const onDuplicate = async (c: PublicConversation) => {
62 const res = await api<{ conversation: PublicConversation }>(`/api/conversations/${c.id}/actions`, { method: "POST", json: { action: "duplicate" } }); 68 const res = await api<{ conversation: PublicConversation }>(`/api/conversations/${c.id}/actions`, { method: "POST", json: { action: "duplicate" } });
63 await mutate(); 69 await mutate();
64 router.push(`/app/chat/${res.conversation.id}`); 70 router.push(`/app/chat/${res.conversation.id}`);
65 }; 71 };
66−
67 const onExport = async (c: PublicConversation, format: "json" | "markdown") => { 72 const onExport = async (c: PublicConversation, format: "json" | "markdown") => {
68 const res = await fetch(`/api/conversations/${c.id}/actions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "export", format }) }); 73 const res = await fetch(`/api/conversations/${c.id}/actions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "export", format }) });
69 const blob = await res.blob(); 74 const blob = await res.blob();
@@ -73,7 +78,6 @@ export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile
73 a.click(); 78 a.click();
74 URL.revokeObjectURL(a.href); 79 URL.revokeObjectURL(a.href);
75 }; 80 };
76−
77 const onShare = async (c: PublicConversation) => { 81 const onShare = async (c: PublicConversation) => {
78 const res = await api<{ id: string }>(`/api/conversations/${c.id}/actions`, { method: "POST", json: { action: "share" } }); 82 const res = await api<{ id: string }>(`/api/conversations/${c.id}/actions`, { method: "POST", json: { action: "share" } });
79 const url = `${window.location.origin}/share/${res.id}`; 83 const url = `${window.location.origin}/share/${res.id}`;
@@ -81,48 +85,67 @@ export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile
81 toast.success("Share link copied", url); 85 toast.success("Share link copied", url);
82 }; 86 };
83 87
84− const onNewFolder = async () => { 88+ const rowActions = (c: PublicConversation) => ({
85− const name = prompt("Folder name"); 89+ onRename: () => setModal({ kind: "rename", c }),
86− if (!name?.trim()) return; 90+ onPin: () => patch(c.id, { pinned: !c.pinned }),
87− await api("/api/folders", { method: "POST", json: { name: name.trim() } }); 91+ onArchive: () => patch(c.id, { archived: !c.archived }),
88− await refreshFolders(); 92+ onDelete: () => setModal({ kind: "delete", c }),
89− }; 93+ onDuplicate: () => onDuplicate(c),
94+ onExport: (f: "json" | "markdown") => onExport(c, f),
95+ onShare: () => onShare(c),
96+ onMove: (folderId: string | null) => patch(c.id, { folderId }),
97+ });
90 98
91− const onDeleteFolder = async (id: string, name: string) => { 99+ const sheetItems = (c: PublicConversation): (ActionSheetItem | "separator")[] => {
92− if (!confirm(`Delete folder "${name}"? Conversations inside are kept.`)) return; 100+ const a = rowActions(c);
93− await api(`/api/folders?id=${id}`, { method: "DELETE" }); 101+ return [
94− await Promise.all([refreshFolders(), mutate()]); 102+ { key: "rename", label: "Rename", icon: <Pencil />, onSelect: a.onRename },
103+ { key: "pin", label: c.pinned ? "Unpin" : "Pin", icon: c.pinned ? <PinOff /> : <Pin />, onSelect: a.onPin },
104+ { key: "share", label: "Copy share link", icon: <Share2 />, onSelect: a.onShare },
105+ { key: "dup", label: "Duplicate", icon: <Copy />, onSelect: a.onDuplicate },
106+ { key: "md", label: "Export Markdown", icon: <Download />, onSelect: () => a.onExport("markdown") },
107+ ...(folders.length ? ([{ key: "nofolder", label: "Remove from folder", icon: <FolderIcon />, onSelect: () => a.onMove(null), disabled: !c.folderId }] as ActionSheetItem[]) : []),
108+ ...folders.map<ActionSheetItem>((f) => ({ key: `f-${f.id}`, label: `Move to ${f.name}`, icon: <FolderIcon />, onSelect: () => a.onMove(f.id), selected: c.folderId === f.id })),
109+ "separator",
110+ { key: "archive", label: c.archived ? "Unarchive" : "Archive", icon: <Archive />, onSelect: a.onArchive },
111+ { key: "delete", label: "Delete", icon: <Trash2 />, onSelect: a.onDelete, destructive: true },
112+ ];
95 }; 113 };
96 114
97 return ( 115 return (
98 <div className="flex h-full flex-col"> 116 <div className="flex h-full flex-col">
99− <div className="flex items-center justify-between px-3 pt-3 pb-2"> 117+ <div className="flex items-center justify-between px-3 pb-1 pt-3">
100 <Link href="/app/chat" className="rounded-md px-1 py-0.5 hover:bg-bg-muted"> 118 <Link href="/app/chat" className="rounded-md px-1 py-0.5 hover:bg-bg-muted">
101 <Logo size={22} /> 119 <Logo size={22} />
102 </Link> 120 </Link>
103 <Tooltip content={mobile ? "Close" : "Collapse sidebar ⌘B"}> 121 <Tooltip content={mobile ? "Close" : "Collapse sidebar ⌘B"}>
104− <Button variant="ghost" size="icon-sm" onClick={onCollapse} aria-label="Collapse sidebar"> 122+ <Button variant="ghost" size={mobile ? "icon" : "icon-sm"} onClick={onCollapse} aria-label={mobile ? "Close" : "Collapse sidebar"}>
105 {mobile ? <X /> : <PanelLeftClose />} 123 {mobile ? <X /> : <PanelLeftClose />}
106 </Button> 124 </Button>
107 </Tooltip> 125 </Tooltip>
108 </div> 126 </div>
109 127
110− <div className="px-3 pb-2"> 128+ <div className="flex gap-1.5 px-3 pb-2 pt-1">
111− <Button asChild variant="primary" className="w-full justify-between"> 129+ <Button asChild variant="primary" size={mobile ? "lg" : "md"} className={cn("flex-1 justify-between", mobile ? "rounded-xl text-[15px]" : "")}>
112 <Link href="/app/chat"> 130 <Link href="/app/chat">
113 <span className="inline-flex items-center gap-2"> 131 <span className="inline-flex items-center gap-2">
114 <Plus className="size-4" /> New chat 132 <Plus className="size-4" /> New chat
115 </span> 133 </span>
116− <Kbd className="bg-transparent border-border-strong/40 text-bg/70">⌘N</Kbd> 134+ {!mobile ? <Kbd className="border-border-strong/40 bg-transparent text-bg/70">⌘N</Kbd> : null}
117 </Link> 135 </Link>
118 </Button> 136 </Button>
137+ <Tooltip content="Search (⌘K)">
138+ <Button variant="outline" size={mobile ? "icon-lg" : "icon"} onClick={() => (isMobile ? setSearchOpen(true) : setPaletteOpen(true))} aria-label="Search conversations">
139+ <Search />
140+ </Button>
141+ </Tooltip>
119 </div> 142 </div>
120 143
121 <nav className="px-2 pb-1"> 144 <nav className="px-2 pb-1">
122 {NAV.map((n) => { 145 {NAV.map((n) => {
123 const active = n.match.test(pathname); 146 const active = n.match.test(pathname);
124 return ( 147 return (
125− <Link key={n.href} href={n.href} className={cn("flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-[13.5px] font-medium transition-colors", active ? "bg-bg-muted text-fg" : "text-fg-muted hover:bg-bg-muted/70 hover:text-fg")}> 148+ <Link key={n.href} href={n.href} className={cn("flex items-center gap-2.5 rounded-md px-2.5 text-[13.5px] font-medium transition-colors", mobile ? "min-h-[42px] text-[15px]" : "py-1.5", active ? "bg-bg-muted text-fg" : "text-fg-muted hover:bg-bg-muted/70 hover:text-fg")}>
126 <n.icon className={cn("size-4", active ? "text-accent" : "text-fg-subtle")} /> 149 <n.icon className={cn("size-4", active ? "text-accent" : "text-fg-subtle")} />
127 {n.label} 150 {n.label}
128 </Link> 151 </Link>
@@ -130,32 +153,31 @@ export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile
130 })} 153 })}
131 </nav> 154 </nav>
132 155
133− <div className="mx-3 my-1.5 flex items-center gap-1.5"> 156+ <div className="mx-3 my-1.5">
134− <div className="relative flex-1"> 157+ <div className="relative">
135− <Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-fg-subtle" /> 158+ <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-fg-subtle" />
136− <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search chats" className="h-8 pl-7 text-[13px] bg-bg" aria-label="Search conversations" /> 159+ <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Filter chats" className={cn("w-full rounded-md border border-border bg-bg pl-8 pr-2 text-[13px] outline-none placeholder:text-fg-subtle focus:border-accent", mobile ? "h-10 text-[15px]" : "h-8")} aria-label="Filter conversations" />
137 </div> 160 </div>
138− <Tooltip content="Command palette ⌘K">
139− <Button variant="ghost" size="icon-sm" onClick={() => setPaletteOpen(true)} aria-label="Command palette">
140− <Kbd>⌘K</Kbd>
141− </Button>
142− </Tooltip>
143 </div> 161 </div>
144 162
145− <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2 scrollbar-thin"> 163+ <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2 scrollbar-thin contain-scroll">
164+ <ProjectsSidebarSection onNavigate={mobile ? onCollapse : undefined} />
146 {isLoading && !data ? ( 165 {isLoading && !data ? (
147− <div className="space-y-1.5 px-1 pt-2"> 166+ <div className="space-y-1.5 px-1 pt-3">
148 {Array.from({ length: 8 }).map((_, i) => ( 167 {Array.from({ length: 8 }).map((_, i) => (
149− <Skeleton key={i} className="h-7" /> 168+ <Skeleton key={i} className={mobile ? "h-9" : "h-7"} />
150 ))} 169 ))}
151 </div> 170 </div>
152 ) : list.length === 0 ? ( 171 ) : list.length === 0 ? (
153− <p className="px-2 pt-6 text-center text-xs text-fg-subtle">{q ? "No conversations match." : showArchived ? "No archived conversations." : "No conversations yet. Start a new chat."}</p> 172+ <div className="px-2 pt-8 text-center">
173+ <BookMarked className="mx-auto size-5 text-fg-subtle" />
174+ <p className="mt-2 text-xs text-fg-subtle">{q ? "No conversations match." : showArchived ? "No archived conversations." : "No conversations yet. Start a new chat."}</p>
175+ </div>
154 ) : ( 176 ) : (
155 groups.map((g) => ( 177 groups.map((g) => (
156− <Group key={g.key} title={g.title} count={g.items.length} icon={g.folderId ? <FolderIcon className="size-3.5" /> : undefined} defaultOpen={g.key !== "older"} onDeleteFolder={g.folderId ? () => onDeleteFolder(g.folderId!, g.title) : undefined}> 178+ <Group key={g.key} title={g.title} count={g.items.length} icon={g.folderId ? <FolderIcon className="size-3.5" /> : undefined} defaultOpen={g.key !== "older"} onDeleteFolder={g.folderId ? () => setModal({ kind: "delete-folder", id: g.folderId!, name: g.title }) : undefined}>
157 {g.items.map((c) => ( 179 {g.items.map((c) => (
158− <ConversationRow key={c.id} c={c} active={c.id === activeId} folders={folders} onRename={() => onRename(c)} onPin={() => patch(c.id, { pinned: !c.pinned })} onArchive={() => patch(c.id, { archived: !c.archived })} onDelete={() => onDelete(c)} onDuplicate={() => onDuplicate(c)} onExport={(f) => onExport(c, f)} onShare={() => onShare(c)} onMove={(folderId) => patch(c.id, { folderId })} /> 180+ <ConversationRow key={c.id} c={c} active={c.id === activeId} mobile={Boolean(mobile)} folders={folders} onLongPress={() => setSheetFor(c)} {...rowActions(c)} />
159 ))} 181 ))}
160 </Group> 182 </Group>
161 )) 183 ))
@@ -164,18 +186,18 @@ export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile
164 186
165 <div className="border-t border-border px-2 py-2"> 187 <div className="border-t border-border px-2 py-2">
166 <div className="flex items-center justify-between px-1 pb-1"> 188 <div className="flex items-center justify-between px-1 pb-1">
167− <button onClick={() => setShowArchived((v) => !v)} className={cn("inline-flex items-center gap-1.5 rounded px-1.5 py-1 text-[12px] text-fg-subtle hover:text-fg", showArchived && "text-fg")}> 189+ <button onClick={() => setShowArchived((v) => !v)} className={cn("tap inline-flex items-center gap-1.5 rounded px-1.5 py-1 text-[12px] text-fg-subtle hover:text-fg", showArchived && "text-fg")}>
168 <Archive className="size-3.5" /> {showArchived ? "Hide archived" : "Archived"} 190 <Archive className="size-3.5" /> {showArchived ? "Hide archived" : "Archived"}
169 </button> 191 </button>
170 <Tooltip content="New folder"> 192 <Tooltip content="New folder">
171− <Button variant="ghost" size="icon-sm" onClick={onNewFolder} aria-label="New folder"> 193+ <Button variant="ghost" size="icon-sm" onClick={() => setModal({ kind: "new-folder" })} aria-label="New folder">
172 <FolderPlus /> 194 <FolderPlus />
173 </Button> 195 </Button>
174 </Tooltip> 196 </Tooltip>
175 </div> 197 </div>
176 <DropdownMenu> 198 <DropdownMenu>
177 <DropdownMenuTrigger asChild> 199 <DropdownMenuTrigger asChild>
178− <button className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left hover:bg-bg-muted"> 200+ <button className={cn("flex w-full items-center gap-2.5 rounded-md px-2 text-left hover:bg-bg-muted", mobile ? "min-h-[48px]" : "py-1.5")}>
179 <span className="flex size-7 items-center justify-center rounded-full bg-accent-soft text-[12px] font-semibold text-accent">{(user.name || user.email).slice(0, 1).toUpperCase()}</span> 201 <span className="flex size-7 items-center justify-center rounded-full bg-accent-soft text-[12px] font-semibold text-accent">{(user.name || user.email).slice(0, 1).toUpperCase()}</span>
180 <span className="min-w-0 flex-1"> 202 <span className="min-w-0 flex-1">
181 <span className="block truncate text-[13px] font-medium">{user.name || user.email.split("@")[0]}</span> 203 <span className="block truncate text-[13px] font-medium">{user.name || user.email.split("@")[0]}</span>
@@ -208,6 +230,15 @@ export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile
208 </DropdownMenuContent> 230 </DropdownMenuContent>
209 </DropdownMenu> 231 </DropdownMenu>
210 </div> 232 </div>
233+
234+ {/* Mobile long-press actions */}
235+ <ActionSheet open={Boolean(sheetFor)} onOpenChange={(o) => !o && setSheetFor(null)} title={sheetFor?.title} items={sheetFor ? sheetItems(sheetFor) : []} />
236+
237+ {/* Dialogs (replace window.prompt/confirm) */}
238+ <PromptDialog open={modal?.kind === "rename"} onOpenChange={(o) => !o && setModal(null)} title="Rename conversation" defaultValue={modal?.kind === "rename" ? modal.c.title : ""} onSubmit={async (v) => { if (modal?.kind === "rename") await patch(modal.c.id, { title: v }); }} />
239+ <PromptDialog open={modal?.kind === "new-folder"} onOpenChange={(o) => !o && setModal(null)} title="New folder" placeholder="Research, Clients, Ideas…" confirmLabel="Create" onSubmit={async (v) => { await api("/api/folders", { method: "POST", json: { name: v } }); await refreshFolders(); }} />
240+ <ConfirmDialog open={modal?.kind === "delete"} onOpenChange={(o) => !o && setModal(null)} title="Delete conversation?" description={modal?.kind === "delete" ? `“${modal.c.title}” and its messages will be permanently deleted.` : undefined} confirmLabel="Delete" destructive onConfirm={async () => { if (modal?.kind === "delete") await onDelete(modal.c); }} />
241+ <ConfirmDialog open={modal?.kind === "delete-folder"} onOpenChange={(o) => !o && setModal(null)} title="Delete folder?" description={modal?.kind === "delete-folder" ? `“${modal.name}” will be removed. Conversations inside are kept.` : undefined} confirmLabel="Delete folder" destructive onConfirm={async () => { if (modal?.kind !== "delete-folder") return; await api(`/api/folders?id=${modal.id}`, { method: "DELETE" }); await Promise.all([refreshFolders(), mutate()]); }} />
211 </div> 242 </div>
212 ); 243 );
213 } 244 }
@@ -217,14 +248,14 @@ function Group({ title, count, children, icon, defaultOpen = true, onDeleteFolde
217 return ( 248 return (
218 <div className="pt-2"> 249 <div className="pt-2">
219 <div className="group flex items-center justify-between px-2 pb-0.5"> 250 <div className="group flex items-center justify-between px-2 pb-0.5">
220− <button onClick={() => setOpen((o) => !o)} className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wide text-fg-subtle hover:text-fg"> 251+ <button onClick={() => setOpen((o) => !o)} className="tap inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wide text-fg-subtle hover:text-fg" aria-expanded={open}>
221 {open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />} 252 {open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
222 {icon} 253 {icon}
223 {title} 254 {title}
224 <span className="ml-1 font-normal text-fg-subtle/70">{count}</span> 255 <span className="ml-1 font-normal text-fg-subtle/70">{count}</span>
225 </button> 256 </button>
226 {onDeleteFolder ? ( 257 {onDeleteFolder ? (
227− <button onClick={onDeleteFolder} className="invisible rounded p-0.5 text-fg-subtle hover:text-danger group-hover:visible" aria-label="Delete folder"> 258+ <button onClick={onDeleteFolder} className="hover-reveal tap rounded p-0.5 text-fg-subtle hover:text-danger" aria-label="Delete folder">
228 <Trash2 className="size-3" /> 259 <Trash2 className="size-3" />
229 </button> 260 </button>
230 ) : null} 261 ) : null}
@@ -234,66 +265,82 @@ function Group({ title, count, children, icon, defaultOpen = true, onDeleteFolde
234 ); 265 );
235 } 266 }
236 267
237−function ConversationRow({ c, active, folders, onRename, onPin, onArchive, onDelete, onDuplicate, onExport, onShare, onMove }: { c: PublicConversation; active: boolean; folders: { id: string; name: string }[]; onRename: () => void; onPin: () => void; onArchive: () => void; onDelete: () => void; onDuplicate: () => void; onExport: (f: "json" | "markdown") => void; onShare: () => void; onMove: (folderId: string | null) => void }) { 268+function ConversationRow({ c, active, mobile, folders, onLongPress, onRename, onPin, onArchive, onDelete, onDuplicate, onExport, onShare, onMove }: { c: PublicConversation; active: boolean; mobile: boolean; folders: { id: string; name: string }[]; onLongPress: () => void; onRename: () => void; onPin: () => void; onArchive: () => void; onDelete: () => void; onDuplicate: () => void; onExport: (f: "json" | "markdown") => void; onShare: () => void; onMove: (folderId: string | null) => void }) {
269+ const router = useRouter();
270+ const press = useLongPress({ onLongPress, onClick: () => router.push(`/app/chat/${c.id}`), disabled: !mobile });
238 return ( 271 return (
239 <div className={cn("group relative flex items-center rounded-md", active ? "bg-bg-muted" : "hover:bg-bg-muted/70")}> 272 <div className={cn("group relative flex items-center rounded-md", active ? "bg-bg-muted" : "hover:bg-bg-muted/70")}>
240− <Link href={`/app/chat/${c.id}`} className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5"> 273+ {mobile ? (
241− <ProviderIcon provider={c.provider} size={13} className="opacity-80" /> 274+ <button type="button" {...press} className="flex min-h-[44px] min-w-0 flex-1 select-none items-center gap-2.5 px-2 text-left" style={{ WebkitTouchCallout: "none" }}>
242− <span className={cn("min-w-0 flex-1 truncate text-[13px]", active ? "text-fg" : "text-fg-muted group-hover:text-fg")}>{c.title}</span> 275+ <ProviderIcon provider={c.provider} size={14} className="opacity-80" />
243− {c.pinned ? <Pin className="size-3 text-fg-subtle" /> : null} 276+ <span className={cn("min-w-0 flex-1 truncate text-[15px]", active ? "text-fg" : "text-fg-muted")}>{c.title}</span>
244− </Link> 277+ {c.pinned ? <Pin className="size-3 text-fg-subtle" /> : null}
245− <DropdownMenu> 278+ </button>
246− <DropdownMenuTrigger asChild> 279+ ) : (
247− <button className={cn("mr-1 rounded p-1 text-fg-subtle hover:bg-border hover:text-fg", active ? "" : "invisible group-hover:visible data-[state=open]:visible")} aria-label="Conversation actions"> 280+ <Link href={`/app/chat/${c.id}`} className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5">
248− <MoreHorizontal className="size-3.5" /> 281+ <ProviderIcon provider={c.provider} size={13} className="opacity-80" />
249− </button> 282+ <span className={cn("min-w-0 flex-1 truncate text-[13px]", active ? "text-fg" : "text-fg-muted group-hover:text-fg")}>{c.title}</span>
250− </DropdownMenuTrigger> 283+ {c.pinned ? <Pin className="size-3 text-fg-subtle" /> : null}
251− <DropdownMenuContent align="start" className="w-52"> 284+ </Link>
252− <DropdownMenuItem onSelect={onRename}> 285+ )}
253− <Pencil /> Rename 286+ {mobile ? (
254− </DropdownMenuItem> 287+ <button onClick={onLongPress} className="tap mr-1 rounded p-2 text-fg-subtle" aria-label="Conversation actions">
255− <DropdownMenuItem onSelect={onPin}> 288+ <MoreHorizontal className="size-4" />
256− {c.pinned ? <PinOff /> : <Pin />} {c.pinned ? "Unpin" : "Pin"} 289+ </button>
257− </DropdownMenuItem> 290+ ) : (
258− <DropdownMenuSub> 291+ <DropdownMenu>
259− <DropdownMenuSubTrigger> 292+ <DropdownMenuTrigger asChild>
260− <FolderIcon /> Move to folder 293+ <button className={cn("mr-1 rounded p-1 text-fg-subtle hover:bg-border hover:text-fg", active ? "" : "invisible group-hover:visible data-[state=open]:visible")} aria-label="Conversation actions">
261− </DropdownMenuSubTrigger> 294+ <MoreHorizontal className="size-3.5" />
262− <DropdownMenuSubContent> 295+ </button>
263− <DropdownMenuLabel>Folders</DropdownMenuLabel> 296+ </DropdownMenuTrigger>
264− <DropdownMenuItem onSelect={() => onMove(null)}>No folder</DropdownMenuItem> 297+ <DropdownMenuContent align="start" className="w-52">
265− {folders.map((f) => ( 298+ <DropdownMenuItem onSelect={onRename}>
266− <DropdownMenuItem key={f.id} onSelect={() => onMove(f.id)}> 299+ <Pencil /> Rename
267− {f.name} 300+ </DropdownMenuItem>
268− </DropdownMenuItem> 301+ <DropdownMenuItem onSelect={onPin}>
269− ))} 302+ {c.pinned ? <PinOff /> : <Pin />} {c.pinned ? "Unpin" : "Pin"}
270− {folders.length === 0 ? <p className="px-2 py-1 text-xs text-fg-subtle">Create a folder from the sidebar footer.</p> : null} 303+ </DropdownMenuItem>
271− </DropdownMenuSubContent> 304+ <DropdownMenuSub>
272− </DropdownMenuSub> 305+ <DropdownMenuSubTrigger>
273− <DropdownMenuItem onSelect={onDuplicate}> 306+ <FolderIcon /> Move to folder
274− <Copy /> Duplicate 307+ </DropdownMenuSubTrigger>
275− </DropdownMenuItem> 308+ <DropdownMenuSubContent>
276− <DropdownMenuItem onSelect={onShare}> 309+ <DropdownMenuLabel>Folders</DropdownMenuLabel>
277− <Share2 /> Share link 310+ <DropdownMenuItem onSelect={() => onMove(null)}>No folder</DropdownMenuItem>
278− </DropdownMenuItem> 311+ {folders.map((f) => (
279− <DropdownMenuSub> 312+ <DropdownMenuItem key={f.id} onSelect={() => onMove(f.id)}>
280− <DropdownMenuSubTrigger> 313+ {f.name}
281− <Download /> Export 314+ </DropdownMenuItem>
282− </DropdownMenuSubTrigger> 315+ ))}
283− <DropdownMenuSubContent> 316+ {folders.length === 0 ? <p className="px-2 py-1 text-xs text-fg-subtle">Create a folder from the sidebar footer.</p> : null}
284− <DropdownMenuItem onSelect={() => onExport("markdown")}>Markdown (.md)</DropdownMenuItem> 317+ </DropdownMenuSubContent>
285− <DropdownMenuItem onSelect={() => onExport("json")}>JSON</DropdownMenuItem> 318+ </DropdownMenuSub>
286− </DropdownMenuSubContent> 319+ <DropdownMenuItem onSelect={onDuplicate}>
287− </DropdownMenuSub> 320+ <Copy /> Duplicate
288− <DropdownMenuItem onSelect={onArchive}> 321+ </DropdownMenuItem>
289− <Archive /> {c.archived ? "Unarchive" : "Archive"} 322+ <DropdownMenuItem onSelect={onShare}>
290− </DropdownMenuItem> 323+ <Share2 /> Share link
291− <DropdownMenuSeparator /> 324+ </DropdownMenuItem>
292− <DropdownMenuItem destructive onSelect={onDelete}> 325+ <DropdownMenuSub>
293− <Trash2 /> Delete 326+ <DropdownMenuSubTrigger>
294− </DropdownMenuItem> 327+ <Download /> Export
295− </DropdownMenuContent> 328+ </DropdownMenuSubTrigger>
296− </DropdownMenu> 329+ <DropdownMenuSubContent>
330+ <DropdownMenuItem onSelect={() => onExport("markdown")}>Markdown (.md)</DropdownMenuItem>
331+ <DropdownMenuItem onSelect={() => onExport("json")}>JSON</DropdownMenuItem>
332+ </DropdownMenuSubContent>
333+ </DropdownMenuSub>
334+ <DropdownMenuItem onSelect={onArchive}>
335+ <Archive /> {c.archived ? "Unarchive" : "Archive"}
336+ </DropdownMenuItem>
337+ <DropdownMenuSeparator />
338+ <DropdownMenuItem destructive onSelect={onDelete}>
339+ <Trash2 /> Delete
340+ </DropdownMenuItem>
341+ </DropdownMenuContent>
342+ </DropdownMenu>
343+ )}
297 </div> 344 </div>
298 ); 345 );
299 } 346 }
modified src/components/app/store.tsx +38 −2
@@ -14,6 +14,9 @@ export interface AppUser {
14 createdAt: string; 14 createdAt: string;
15 } 15 }
16 16
17+/** Smart-router selection sentinel — stored in `selectedModelKey` when the user picks AUTO. */
18+export const AUTO_MODEL_KEY = "auto";
19+
17 interface AppState { 20 interface AppState {
18 user: AppUser; 21 user: AppUser;
19 preferences: UserPreferences; 22 preferences: UserPreferences;
@@ -22,21 +25,30 @@ interface AppState {
22 modelsByKey: Map<string, PolyModel>; 25 modelsByKey: Map<string, PolyModel>;
23 favorites: Set<string>; 26 favorites: Set<string>;
24 recents: string[]; 27 recents: string[];
28+ /** User-defined labels per model key ("My research model"). */
29+ labels: Record<string, string>;
25 connectedProviders: Set<ProviderId>; 30 connectedProviders: Set<ProviderId>;
26 connections: PublicConnection[]; 31 connections: PublicConnection[];
27 folders: Folder[]; 32 folders: Folder[];
28 loadingModels: boolean; 33 loadingModels: boolean;
29 toggleFavorite: (key: string) => Promise<void>; 34 toggleFavorite: (key: string) => Promise<void>;
35+ setLabel: (key: string, label: string | null) => Promise<void>;
30 refreshModels: () => Promise<void>; 36 refreshModels: () => Promise<void>;
31 refreshConnections: () => Promise<void>; 37 refreshConnections: () => Promise<void>;
32 refreshFolders: () => Promise<void>; 38 refreshFolders: () => Promise<void>;
33− /** Currently selected model key for new chats (persisted in localStorage). */ 39+ /** Currently selected model key for new chats (persisted in localStorage). `AUTO_MODEL_KEY` = smart router. */
34 selectedModelKey: string | null; 40 selectedModelKey: string | null;
35 setSelectedModelKey: (key: string | null) => void; 41 setSelectedModelKey: (key: string | null) => void;
42+ /** Active project (workspace) for new chats; null = no project. Persisted. */
43+ activeProjectId: string | null;
44+ setActiveProjectId: (id: string | null) => void;
36 sidebarOpen: boolean; 45 sidebarOpen: boolean;
37 setSidebarOpen: (v: boolean) => void; 46 setSidebarOpen: (v: boolean) => void;
38 paletteOpen: boolean; 47 paletteOpen: boolean;
39 setPaletteOpen: (v: boolean) => void; 48 setPaletteOpen: (v: boolean) => void;
49+ /** Open the global conversation search (⌘K search mode / mobile sheet). */
50+ searchOpen: boolean;
51+ setSearchOpen: (v: boolean) => void;
40 } 52 }
41 53
42 const Ctx = React.createContext<AppState | null>(null); 54 const Ctx = React.createContext<AppState | null>(null);
@@ -48,6 +60,7 @@ export function useApp(): AppState {
48 } 60 }
49 61
50 const LS_MODEL = "polyllm:selected-model"; 62 const LS_MODEL = "polyllm:selected-model";
63+const LS_PROJECT = "polyllm:active-project";
51 64
52 export function AppProvider({ user, preferences: initialPrefs, children }: { user: AppUser; preferences: UserPreferences; children: React.ReactNode }) { 65 export function AppProvider({ user, preferences: initialPrefs, children }: { user: AppUser; preferences: UserPreferences; children: React.ReactNode }) {
53 const [preferences, setPreferences] = React.useState(initialPrefs); 66 const [preferences, setPreferences] = React.useState(initialPrefs);
@@ -55,23 +68,28 @@ export function AppProvider({ user, preferences: initialPrefs, children }: { use
55 const connections = useApi<{ connections: PublicConnection[] }>("/api/providers"); 68 const connections = useApi<{ connections: PublicConnection[] }>("/api/providers");
56 const foldersQ = useApi<{ folders: Folder[] }>("/api/folders"); 69 const foldersQ = useApi<{ folders: Folder[] }>("/api/folders");
57 const [selectedModelKey, setSelected] = React.useState<string | null>(null); 70 const [selectedModelKey, setSelected] = React.useState<string | null>(null);
71+ const [activeProjectId, setProject] = React.useState<string | null>(null);
58 const [sidebarOpen, setSidebarOpen] = React.useState(false); 72 const [sidebarOpen, setSidebarOpen] = React.useState(false);
59 const [paletteOpen, setPaletteOpen] = React.useState(false); 73 const [paletteOpen, setPaletteOpen] = React.useState(false);
74+ const [searchOpen, setSearchOpen] = React.useState(false);
60 75
61 React.useEffect(() => { 76 React.useEffect(() => {
62 const stored = typeof window !== "undefined" ? window.localStorage.getItem(LS_MODEL) : null; 77 const stored = typeof window !== "undefined" ? window.localStorage.getItem(LS_MODEL) : null;
63 // eslint-disable-next-line react-hooks/set-state-in-effect 78 // eslint-disable-next-line react-hooks/set-state-in-effect
64 setSelected(stored ?? initialPrefs.defaultModelKey ?? null); 79 setSelected(stored ?? initialPrefs.defaultModelKey ?? null);
80+ setProject(window.localStorage.getItem(LS_PROJECT) || null);
65 }, [initialPrefs.defaultModelKey]); 81 }, [initialPrefs.defaultModelKey]);
66 82
67 const modelList = React.useMemo(() => models.data?.models ?? [], [models.data]); 83 const modelList = React.useMemo(() => models.data?.models ?? [], [models.data]);
68 const modelsByKey = React.useMemo(() => new Map(modelList.map((m) => [m.key, m])), [modelList]); 84 const modelsByKey = React.useMemo(() => new Map(modelList.map((m) => [m.key, m])), [modelList]);
69 const favorites = React.useMemo(() => new Set(models.data?.favorites ?? []), [models.data]); 85 const favorites = React.useMemo(() => new Set(models.data?.favorites ?? []), [models.data]);
86+ const labels = React.useMemo(() => models.data?.labels ?? {}, [models.data]);
70 const connectedProviders = React.useMemo(() => new Set(models.data?.connectedProviders ?? []), [models.data]); 87 const connectedProviders = React.useMemo(() => new Set(models.data?.connectedProviders ?? []), [models.data]);
71 88
72− // Fall back to the first connected model when the stored selection is unusable. 89+ // Fall back to the first connected model when the stored selection is unusable (AUTO is always valid).
73 React.useEffect(() => { 90 React.useEffect(() => {
74 if (!models.data) return; 91 if (!models.data) return;
92+ if (selectedModelKey === AUTO_MODEL_KEY) return;
75 if (selectedModelKey && modelsByKey.has(selectedModelKey) && connectedProviders.has(modelsByKey.get(selectedModelKey)!.provider)) return; 93 if (selectedModelKey && modelsByKey.has(selectedModelKey) && connectedProviders.has(modelsByKey.get(selectedModelKey)!.provider)) return;
76 const first = modelList.find((m) => connectedProviders.has(m.provider) && m.status !== "deprecated"); 94 const first = modelList.find((m) => connectedProviders.has(m.provider) && m.status !== "deprecated");
77 if (first && first.key !== selectedModelKey) { 95 if (first && first.key !== selectedModelKey) {
@@ -86,6 +104,12 @@ export function AppProvider({ user, preferences: initialPrefs, children }: { use
86 else window.localStorage.removeItem(LS_MODEL); 104 else window.localStorage.removeItem(LS_MODEL);
87 }, []); 105 }, []);
88 106
107+ const setActiveProjectId = React.useCallback((id: string | null) => {
108+ setProject(id);
109+ if (id) window.localStorage.setItem(LS_PROJECT, id);
110+ else window.localStorage.removeItem(LS_PROJECT);
111+ }, []);
112+
89 const value: AppState = { 113 const value: AppState = {
90 user, 114 user,
91 preferences, 115 preferences,
@@ -97,6 +121,7 @@ export function AppProvider({ user, preferences: initialPrefs, children }: { use
97 modelsByKey, 121 modelsByKey,
98 favorites, 122 favorites,
99 recents: models.data?.recents ?? [], 123 recents: models.data?.recents ?? [],
124+ labels,
100 connectedProviders, 125 connectedProviders,
101 connections: connections.data?.connections ?? [], 126 connections: connections.data?.connections ?? [],
102 folders: foldersQ.data?.folders ?? [], 127 folders: foldersQ.data?.folders ?? [],
@@ -105,6 +130,10 @@ export function AppProvider({ user, preferences: initialPrefs, children }: { use
105 await api("/api/models", { method: "POST", json: { action: "toggle-favorite", modelKey: key } }); 130 await api("/api/models", { method: "POST", json: { action: "toggle-favorite", modelKey: key } });
106 await models.mutate(); 131 await models.mutate();
107 }, 132 },
133+ setLabel: async (key, label) => {
134+ await api("/api/models", { method: "POST", json: { action: "set-label", modelKey: key, label } });
135+ await models.mutate();
136+ },
108 refreshModels: async () => { 137 refreshModels: async () => {
109 await models.mutate(); 138 await models.mutate();
110 }, 139 },
@@ -116,10 +145,14 @@ export function AppProvider({ user, preferences: initialPrefs, children }: { use
116 }, 145 },
117 selectedModelKey, 146 selectedModelKey,
118 setSelectedModelKey, 147 setSelectedModelKey,
148+ activeProjectId,
149+ setActiveProjectId,
119 sidebarOpen, 150 sidebarOpen,
120 setSidebarOpen, 151 setSidebarOpen,
121 paletteOpen, 152 paletteOpen,
122 setPaletteOpen, 153 setPaletteOpen,
154+ searchOpen,
155+ setSearchOpen,
123 }; 156 };
124 return <Ctx.Provider value={value}>{children}</Ctx.Provider>; 157 return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
125 } 158 }
@@ -127,3 +160,6 @@ export function AppProvider({ user, preferences: initialPrefs, children }: { use
127 export function invalidateConversations() { 160 export function invalidateConversations() {
128 return revalidate("/api/conversations"); 161 return revalidate("/api/conversations");
129 } 162 }
163+export function invalidateProjects() {
164+ return revalidate("/api/projects");
165+}
modified src/components/brand/logo.tsx +44 −15
@@ -2,35 +2,64 @@
2 import { useId } from "react"; 2 import { useId } from "react";
3 import { cn } from "@/lib/utils"; 3 import { cn } from "@/lib/utils";
4 4
5−/** PolyLLM mark: four overlapping nodes converging into one — "one interface, every model". */ 5+/**
6−export function LogoMark({ className, size = 28 }: { className?: string; size?: number }) { 6+ * PolyLLM mark — "the Prism P".
7+ * A bold P (Poly) whose bowl emits three beams of decreasing intensity: one interface, every model.
8+ * Keep in sync with `public/icon.svg` and `scripts/brand-assets.sh` (favicon / PWA / OG renders).
9+ */
10+export function LogoMark({ className, size = 28, mono = false }: { className?: string; size?: number; mono?: boolean }) {
7 // Unique gradient id per instance: a duplicate id inside a `display:none` sidebar would otherwise 11 // Unique gradient id per instance: a duplicate id inside a `display:none` sidebar would otherwise
8 // win the lookup and the mark would paint black on mobile. 12 // win the lookup and the mark would paint black on mobile.
9− const gid = `plm-g-${useId().replace(/[^a-zA-Z0-9]/g, "")}`; 13+ const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
14+ const gid = `plm-g-${uid}`;
15+ const hid = `plm-h-${uid}`;
10 return ( 16 return (
11 <svg width={size} height={size} viewBox="0 0 32 32" fill="none" className={cn("shrink-0", className)} aria-hidden> 17 <svg width={size} height={size} viewBox="0 0 32 32" fill="none" className={cn("shrink-0", className)} aria-hidden>
12 <defs> 18 <defs>
13− <linearGradient id={gid} x1="4" y1="4" x2="28" y2="28" gradientUnits="userSpaceOnUse"> 19+ <linearGradient id={gid} x1="3" y1="3" x2="29" y2="29" gradientUnits="userSpaceOnUse">
14− <stop stopColor="#8b8dff" /> 20+ <stop stopColor="#6366f1" />
15− <stop offset="0.55" stopColor="#b58cff" /> 21+ <stop offset="0.52" stopColor="#8b5cf6" />
16− <stop offset="1" stopColor="#ff8fab" /> 22+ <stop offset="1" stopColor="#f472b6" />
17 </linearGradient> 23 </linearGradient>
24+ <radialGradient id={hid} cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(9 6) rotate(48) scale(26 22)">
25+ <stop stopColor="#fff" stopOpacity="0.32" />
26+ <stop offset="1" stopColor="#fff" stopOpacity="0" />
27+ </radialGradient>
18 </defs> 28 </defs>
19− <rect x="1" y="1" width="30" height="30" rx="9" fill={`url(#${gid})`} /> 29+ {mono ? <rect x="1" y="1" width="30" height="30" rx="9" fill="currentColor" /> : (
20− <g stroke="#0b0c10" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"> 30+ <>
21− <path d="M9 10.5h4.5M9 16h6M9 21.5h4.5" /> 31+ <rect x="1" y="1" width="30" height="30" rx="9" fill={`url(#${gid})`} />
22− <path d="M23 10.5h-4.5M23 16h-6M23 21.5h-4.5" /> 32+ <rect x="1" y="1" width="30" height="30" rx="9" fill={`url(#${hid})`} />
23− <circle cx="16" cy="16" r="2.4" fill="#0b0c10" stroke="none" /> 33+ </>
34+ )}
35+ <g fill={mono ? "var(--bg, #fff)" : "#fff"}>
36+ {/* P stem */}
37+ <rect x="7.5" y="7" width="5" height="18" rx="2.5" />
38+ {/* P bowl (ring) */}
39+ <path fillRule="evenodd" d="M12 7h5.4a5.6 5.6 0 0 1 0 11.2H12V7Zm0 3.4v4.4h5.3a2.2 2.2 0 0 0 0-4.4H12Z" />
40+ {/* Beams: one → many */}
41+ <rect x="24.6" y="9.1" width="3.2" height="2.3" rx="1.15" opacity="0.95" />
42+ <rect x="24.6" y="12.55" width="3.2" height="2.3" rx="1.15" opacity="0.62" />
43+ <rect x="24.6" y="16" width="3.2" height="2.3" rx="1.15" opacity="0.34" />
24 </g> 44 </g>
25 </svg> 45 </svg>
26 ); 46 );
27 } 47 }
28 48
29−export function Logo({ className, size = 24, wordmark = true }: { className?: string; size?: number; wordmark?: boolean }) { 49+export function Logo({ className, size = 24, wordmark = true, mono }: { className?: string; size?: number; wordmark?: boolean; mono?: boolean }) {
30 return ( 50 return (
31 <span className={cn("inline-flex items-center gap-2", className)}> 51 <span className={cn("inline-flex items-center gap-2", className)}>
32− <LogoMark size={size} /> 52+ <LogoMark size={size} mono={mono} />
33− {wordmark ? <span className="text-[17px] font-semibold tracking-tight leading-none">PolyLLM</span> : null} 53+ {wordmark ? <Wordmark className="text-[17px]" /> : null}
54+ </span>
55+ );
56+}
57+
58+/** Text wordmark — "Poly" solid, "LLM" slightly lighter for rhythm. */
59+export function Wordmark({ className }: { className?: string }) {
60+ return (
61+ <span className={cn("font-semibold tracking-[-0.02em] leading-none", className)}>
62+ Poly<span className="font-medium text-fg-muted">LLM</span>
34 </span> 63 </span>
35 ); 64 );
36 } 65 }
added src/components/common/prompt-dialog.tsx +86 −0
@@ -0,0 +1,86 @@
1+"use client";
2+import * as React from "react";
3+import { ResponsiveDialog } from "@/components/ui/sheet";
4+import { Button } from "@/components/ui/button";
5+import { Input, Textarea } from "@/components/ui/input";
6+
7+export interface PromptDialogProps {
8+ open: boolean;
9+ onOpenChange: (open: boolean) => void;
10+ title: React.ReactNode;
11+ description?: React.ReactNode;
12+ label?: string;
13+ placeholder?: string;
14+ defaultValue?: string;
15+ confirmLabel?: string;
16+ multiline?: boolean;
17+ maxLength?: number;
18+ onSubmit: (value: string) => Promise<void> | void;
19+}
20+
21+/**
22+ * Replacement for `window.prompt()`: bottom sheet on phones, small dialog on desktop.
23+ * Resolves with the trimmed value; empty submissions are ignored.
24+ */
25+export function PromptDialog({ open, onOpenChange, title, description, label, placeholder, defaultValue = "", confirmLabel = "Save", multiline, maxLength = 200, onSubmit }: PromptDialogProps) {
26+ const [value, setValue] = React.useState(defaultValue);
27+ const [busy, setBusy] = React.useState(false);
28+ const ref = React.useRef<HTMLInputElement & HTMLTextAreaElement>(null);
29+ React.useEffect(() => {
30+ if (open) {
31+ // eslint-disable-next-line react-hooks/set-state-in-effect
32+ setValue(defaultValue);
33+ setTimeout(() => {
34+ ref.current?.focus();
35+ ref.current?.select();
36+ }, 60);
37+ }
38+ }, [open, defaultValue]);
39+
40+ const submit = async () => {
41+ const v = value.trim();
42+ if (!v || busy) return;
43+ setBusy(true);
44+ try {
45+ await onSubmit(v);
46+ onOpenChange(false);
47+ } finally {
48+ setBusy(false);
49+ }
50+ };
51+
52+ return (
53+ <ResponsiveDialog
54+ open={open}
55+ onOpenChange={onOpenChange}
56+ title={title}
57+ description={description}
58+ size="sm"
59+ footer={
60+ <div className="flex gap-2 sm:justify-end">
61+ <Button variant="ghost" className="flex-1 sm:flex-none" onClick={() => onOpenChange(false)} disabled={busy}>
62+ Cancel
63+ </Button>
64+ <Button className="flex-1 sm:flex-none" loading={busy} disabled={!value.trim()} onClick={submit}>
65+ {confirmLabel}
66+ </Button>
67+ </div>
68+ }
69+ >
70+ <form
71+ className="space-y-1.5 pt-1"
72+ onSubmit={(e) => {
73+ e.preventDefault();
74+ void submit();
75+ }}
76+ >
77+ {label ? <label className="text-[13px] font-medium text-fg-muted">{label}</label> : null}
78+ {multiline ? (
79+ <Textarea ref={ref} value={value} onChange={(e) => setValue(e.target.value)} placeholder={placeholder} maxLength={maxLength} className="min-h-[120px]" />
80+ ) : (
81+ <Input ref={ref} value={value} onChange={(e) => setValue(e.target.value)} placeholder={placeholder} maxLength={maxLength} className="h-11 sm:h-9" />
82+ )}
83+ </form>
84+ </ResponsiveDialog>
85+ );
86+}
added src/components/library/file-library-picker.tsx +110 −0
@@ -0,0 +1,110 @@
1+"use client";
2+import * as React from "react";
3+import { FileText, Image as ImageIcon, Library } from "lucide-react";
4+import { ResponsiveDialog } from "@/components/ui/sheet";
5+import { Button } from "@/components/ui/button";
6+import { EmptyState } from "@/components/ui/misc";
7+import { useApi, api } from "@/lib/client/api";
8+import { useApp } from "@/components/app/store";
9+import type { ProjectFile } from "@/lib/client/types";
10+import { formatTokens, formatRelative, cn } from "@/lib/utils";
11+
12+export interface PickedAttachment {
13+ id: string;
14+ kind: string;
15+ name: string;
16+ mimeType: string;
17+ sizeBytes: number;
18+ width?: number | null;
19+ height?: number | null;
20+}
21+
22+type PublicProjectFile = Omit<ProjectFile, "dataBase64" | "createdAt"> & { createdAt: string };
23+
24+/**
25+ * Pick stored files (project / global library) to attach to a new message.
26+ * Calls POST /api/library/files/attach which copies the file into `message_attachments` and returns
27+ * pending attachment descriptors compatible with the composer.
28+ * (Owned by the Projects/Library workstream; baseline implementation.)
29+ */
30+export function FileLibraryPicker({ open, onOpenChange, onPick, projectId }: { open: boolean; onOpenChange: (o: boolean) => void; onPick: (files: PickedAttachment[]) => void; projectId?: string | null }) {
31+ const { activeProjectId } = useApp();
32+ const pid = projectId ?? activeProjectId;
33+ const { data, isLoading } = useApi<{ files: PublicProjectFile[] }>(open ? `/api/library/files${pid ? `?projectId=${pid}` : ""}` : null);
34+ const [selected, setSelected] = React.useState<Set<string>>(new Set());
35+ const [busy, setBusy] = React.useState(false);
36+ React.useEffect(() => {
37+ // eslint-disable-next-line react-hooks/set-state-in-effect
38+ if (!open) setSelected(new Set());
39+ }, [open]);
40+
41+ const files = data?.files ?? [];
42+ const toggle = (id: string) =>
43+ setSelected((s) => {
44+ const n = new Set(s);
45+ if (n.has(id)) n.delete(id);
46+ else n.add(id);
47+ return n;
48+ });
49+
50+ const attach = async () => {
51+ setBusy(true);
52+ try {
53+ const res = await api<{ attachments: PickedAttachment[] }>("/api/library/files/attach", { method: "POST", json: { fileIds: [...selected] } });
54+ onPick(res.attachments);
55+ onOpenChange(false);
56+ } finally {
57+ setBusy(false);
58+ }
59+ };
60+
61+ return (
62+ <ResponsiveDialog
63+ open={open}
64+ onOpenChange={onOpenChange}
65+ title="Attach from library"
66+ description={pid ? "Files saved to this project and your global library." : "Files saved to your library."}
67+ size="md"
68+ footer={
69+ <div className="flex items-center gap-2">
70+ <span className="text-[13px] text-fg-muted">{selected.size} selected</span>
71+ <div className="flex-1" />
72+ <Button variant="ghost" onClick={() => onOpenChange(false)}>
73+ Cancel
74+ </Button>
75+ <Button disabled={!selected.size} loading={busy} onClick={attach}>
76+ Attach
77+ </Button>
78+ </div>
79+ }
80+ >
81+ {isLoading ? (
82+ <p className="py-8 text-center text-sm text-fg-muted">Loading…</p>
83+ ) : files.length === 0 ? (
84+ <EmptyState icon={<Library />} title="No stored files yet" description="Upload PDFs, images or documents once to a project and reuse them in any chat." className="border-0 py-8" />
85+ ) : (
86+ <ul className="divide-y divide-hairline">
87+ {files.map((f) => {
88+ const on = selected.has(f.id);
89+ return (
90+ <li key={f.id}>
91+ <button type="button" onClick={() => toggle(f.id)} className={cn("flex min-h-[52px] w-full items-center gap-3 px-1 py-2 text-left", on && "text-fg")} aria-pressed={on}>
92+ <span className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg bg-bg-muted text-fg-muted", on && "bg-accent-soft text-accent")}>{f.kind === "image" ? <ImageIcon className="size-4" /> : <FileText className="size-4" />}</span>
93+ <span className="min-w-0 flex-1">
94+ <span className="block truncate text-[14px] font-medium">{f.name}</span>
95+ <span className="block truncate text-[12px] text-fg-subtle">
96+ {(f.sizeBytes / 1024).toFixed(0)} KB{f.estimatedTokens ? ` · ~${formatTokens(f.estimatedTokens)} tokens` : ""} · {formatRelative(f.createdAt)}
97+ </span>
98+ </span>
99+ <span className={cn("flex size-5 items-center justify-center rounded-full border", on ? "border-accent bg-accent text-accent-fg" : "border-border-strong")} aria-hidden>
100+ {on ? "✓" : ""}
101+ </span>
102+ </button>
103+ </li>
104+ );
105+ })}
106+ </ul>
107+ )}
108+ </ResponsiveDialog>
109+ );
110+}
added src/components/projects/sidebar-section.tsx +46 −0
@@ -0,0 +1,46 @@
1+"use client";
2+import * as React from "react";
3+import Link from "next/link";
4+import { FolderKanban, Plus } from "lucide-react";
5+import { useApp } from "@/components/app/store";
6+import { useApi } from "@/lib/client/api";
7+import type { Project } from "@/lib/client/types";
8+import { cn } from "@/lib/utils";
9+
10+/**
11+ * Projects list rendered inside the sidebar / mobile drawer.
12+ * (Owned by the Projects workstream — this baseline lists projects and links to /app/projects.)
13+ */
14+export function ProjectsSidebarSection({ onNavigate }: { onNavigate?: () => void }) {
15+ const { activeProjectId } = useApp();
16+ const { data } = useApi<{ projects: Project[] }>("/api/projects");
17+ const list = React.useMemo(() => (data?.projects ?? []).filter((p) => !p.archived).slice(0, 8), [data]);
18+ return (
19+ <div className="pt-2">
20+ <div className="flex items-center justify-between px-2 pb-0.5">
21+ <Link href="/app/projects" onClick={onNavigate} className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wide text-fg-subtle hover:text-fg">
22+ <FolderKanban className="size-3" /> Projects
23+ </Link>
24+ <Link href="/app/projects?new=1" onClick={onNavigate} className="tap rounded p-0.5 text-fg-subtle hover:text-fg" aria-label="New project">
25+ <Plus className="size-3.5" />
26+ </Link>
27+ </div>
28+ {list.length === 0 ? (
29+ <Link href="/app/projects?new=1" onClick={onNavigate} className="mx-2 mt-1 block rounded-md border border-dashed border-border px-2.5 py-2 text-[12px] text-fg-subtle hover:border-border-strong hover:text-fg">
30+ Group chats, files and instructions into a project.
31+ </Link>
32+ ) : (
33+ <div className="space-y-px">
34+ {list.map((p) => (
35+ <Link key={p.id} href={`/app/projects/${p.id}`} onClick={onNavigate} className={cn("flex min-h-[36px] items-center gap-2 rounded-md px-2 text-[13px] md:min-h-[30px]", p.id === activeProjectId ? "bg-bg-muted text-fg" : "text-fg-muted hover:bg-bg-muted/70 hover:text-fg")}>
36+ <span className="flex size-4 items-center justify-center text-[12px]" aria-hidden>
37+ {p.icon ?? "◆"}
38+ </span>
39+ <span className="truncate">{p.name}</span>
40+ </Link>
41+ ))}
42+ </div>
43+ )}
44+ </div>
45+ );
46+}
modified src/components/ui/misc.tsx +5 −0
@@ -101,3 +101,8 @@ export function Spinner({ className }: { className?: string }) {
101 </svg> 101 </svg>
102 ); 102 );
103 } 103 }
104+
105+/** Marks a control whose feature is not implemented yet. Never ship a silent placeholder button. */
106+export function ComingSoon({ className }: { className?: string }) {
107+ return <span className={cn("inline-flex items-center rounded-full border border-dashed border-border-strong px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-fg-subtle", className)}>Coming soon</span>;
108+}
added src/components/ui/segmented.tsx +90 −0
@@ -0,0 +1,90 @@
1+"use client";
2+import * as React from "react";
3+import { cn } from "@/lib/utils";
4+
5+export interface SegmentedOption<T extends string> {
6+ value: T;
7+ label: React.ReactNode;
8+ icon?: React.ReactNode;
9+ disabled?: boolean;
10+ count?: number;
11+}
12+
13+/**
14+ * Segmented control (iOS-like). Keyboard: ←/→ move, Enter/Space select. Scrolls horizontally when it overflows.
15+ */
16+export function Segmented<T extends string>({ value, onChange, options, size = "md", className, ariaLabel, fill }: { value: T; onChange: (v: T) => void; options: SegmentedOption<T>[]; size?: "sm" | "md" | "lg"; className?: string; ariaLabel?: string; fill?: boolean }) {
17+ const ref = React.useRef<HTMLDivElement>(null);
18+ const idx = options.findIndex((o) => o.value === value);
19+ const onKey = (e: React.KeyboardEvent) => {
20+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
21+ e.preventDefault();
22+ const dir = e.key === "ArrowLeft" ? -1 : 1;
23+ let i = idx;
24+ for (let n = 0; n < options.length; n++) {
25+ i = (i + dir + options.length) % options.length;
26+ if (!options[i].disabled) break;
27+ }
28+ onChange(options[i].value);
29+ (ref.current?.children[i] as HTMLElement | undefined)?.focus();
30+ };
31+ const h = size === "sm" ? "h-8" : size === "lg" ? "h-11" : "h-9";
32+ const item = size === "sm" ? "h-6 px-2.5 text-[12.5px]" : size === "lg" ? "h-9 px-4 text-[15px]" : "h-7 px-3 text-[13px]";
33+ return (
34+ <div ref={ref} role="tablist" aria-label={ariaLabel} onKeyDown={onKey} className={cn("inline-flex max-w-full items-center gap-0.5 overflow-x-auto rounded-lg bg-bg-muted p-1 scrollbar-none", h, fill && "flex w-full", className)}>
35+ {options.map((o) => {
36+ const active = o.value === value;
37+ return (
38+ <button
39+ key={o.value}
40+ role="tab"
41+ type="button"
42+ aria-selected={active}
43+ tabIndex={active ? 0 : -1}
44+ disabled={o.disabled}
45+ onClick={() => onChange(o.value)}
46+ className={cn(
47+ "inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md font-medium transition-[background-color,color,box-shadow] duration-150 disabled:opacity-40 [&_svg]:size-3.5 [&_svg]:shrink-0",
48+ item,
49+ fill && "flex-1",
50+ active ? "bg-bg-elevated text-fg shadow-xs" : "text-fg-muted hover:text-fg",
51+ )}
52+ >
53+ {o.icon}
54+ {o.label}
55+ {o.count !== undefined ? <span className={cn("tabular-nums text-[11px]", active ? "text-fg-subtle" : "text-fg-subtle/70")}>{o.count}</span> : null}
56+ </button>
57+ );
58+ })}
59+ </div>
60+ );
61+}
62+
63+/** Pill filter chips (multi or single select). Horizontal scroll on phones. */
64+export function ChipRow<T extends string>({ value, onChange, options, className, multiple }: { value: T | T[] | null; onChange: (v: T) => void; options: { value: T; label: React.ReactNode; icon?: React.ReactNode; count?: number }[]; className?: string; multiple?: boolean }) {
65+ const isActive = (v: T) => (Array.isArray(value) ? value.includes(v) : value === v);
66+ return (
67+ <div className={cn("flex gap-1.5 overflow-x-auto scrollbar-none", className)} role={multiple ? "group" : "radiogroup"}>
68+ {options.map((o) => {
69+ const active = isActive(o.value);
70+ return (
71+ <button
72+ key={o.value}
73+ type="button"
74+ role={multiple ? "checkbox" : "radio"}
75+ aria-checked={active}
76+ onClick={() => onChange(o.value)}
77+ className={cn(
78+ "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full border px-3 text-[13px] font-medium transition-colors [&_svg]:size-3.5",
79+ active ? "border-fg bg-fg text-bg" : "border-border bg-bg-elevated text-fg-muted hover:border-border-strong hover:text-fg",
80+ )}
81+ >
82+ {o.icon}
83+ {o.label}
84+ {o.count !== undefined ? <span className={cn("tabular-nums text-[11px]", active ? "opacity-70" : "text-fg-subtle")}>{o.count}</span> : null}
85+ </button>
86+ );
87+ })}
88+ </div>
89+ );
90+}
added src/components/ui/sheet.tsx +265 −0
@@ -0,0 +1,265 @@
1+"use client";
2+import * as React from "react";
3+import * as DialogPrimitive from "@radix-ui/react-dialog";
4+import { X } from "lucide-react";
5+import { cn } from "@/lib/utils";
6+import { useIsMobile, usePrefersReducedMotion } from "@/lib/client/hooks";
7+
8+/**
9+ * BottomSheet — native-feeling mobile sheet built on Radix Dialog (focus trap, a11y, Esc).
10+ *
11+ * - drag handle + swipe-to-dismiss (pointer events; direction-locked so inner scrolling still works)
12+ * - snap points: `snap="content" | "half" | "full"` (max height); `expandable` lets the user drag up to full
13+ * - background blur, safe-area padding, `100dvh`-based sizing, keyboard-aware (`--kb`)
14+ *
15+ * Use `ResponsiveDialog` when the same content must be a centered dialog on desktop.
16+ */
17+export type SheetSnap = "content" | "half" | "full";
18+
19+export interface BottomSheetProps {
20+ open: boolean;
21+ onOpenChange: (open: boolean) => void;
22+ title?: React.ReactNode;
23+ description?: React.ReactNode;
24+ children: React.ReactNode;
25+ /** Sticky footer (e.g. primary action). Rendered above the safe area. */
26+ footer?: React.ReactNode;
27+ snap?: SheetSnap;
28+ /** Allow dragging up from `content`/`half` to `full`. */
29+ expandable?: boolean;
30+ /** Hide the visual title but keep it for screen readers. */
31+ hideTitle?: boolean;
32+ showClose?: boolean;
33+ className?: string;
34+ bodyClassName?: string;
35+ /** Prevent closing by tapping the overlay / swiping. */
36+ modal?: boolean;
37+ /** Remove body padding (lists that manage their own). */
38+ flush?: boolean;
39+ /** Let a fixed header (e.g. search input) sit above the scrolling body. */
40+ header?: React.ReactNode;
41+}
42+
43+const SNAP_MAX: Record<SheetSnap, string> = {
44+ content: "max-h-[min(88dvh,calc(100dvh-var(--kb)-16px))]",
45+ half: "h-[min(56dvh,calc(100dvh-var(--kb)-16px))]",
46+ full: "h-[calc(100dvh-var(--kb)-var(--sat)-8px)]",
47+};
48+
49+export function BottomSheet({ open, onOpenChange, title, description, children, footer, snap = "content", expandable = true, hideTitle, showClose = false, className, bodyClassName, modal = false, flush, header }: BottomSheetProps) {
50+ const reduced = usePrefersReducedMotion();
51+ const [current, setCurrent] = React.useState<SheetSnap>(snap);
52+ const [dragY, setDragY] = React.useState(0);
53+ const [dragging, setDragging] = React.useState(false);
54+ const bodyRef = React.useRef<HTMLDivElement>(null);
55+ const startY = React.useRef<number | null>(null);
56+ const startScrollTop = React.useRef(0);
57+ const axis = React.useRef<"y" | "none" | null>(null);
58+
59+ React.useEffect(() => {
60+ if (open) {
61+ // eslint-disable-next-line react-hooks/set-state-in-effect
62+ setCurrent(snap);
63+ setDragY(0);
64+ }
65+ }, [open, snap]);
66+
67+ const onPointerDown = (e: React.PointerEvent, fromHandle: boolean) => {
68+ if (e.pointerType === "mouse" && !fromHandle) return;
69+ startY.current = e.clientY;
70+ startScrollTop.current = bodyRef.current?.scrollTop ?? 0;
71+ axis.current = fromHandle ? "y" : null;
72+ };
73+ const onPointerMove = (e: React.PointerEvent) => {
74+ if (startY.current === null) return;
75+ const dy = e.clientY - startY.current;
76+ if (axis.current === null) {
77+ // Only take over when the body is scrolled to the top and the user pulls down.
78+ if (Math.abs(dy) < 8) return;
79+ axis.current = dy > 0 && startScrollTop.current <= 0 ? "y" : "none";
80+ }
81+ if (axis.current !== "y") return;
82+ if (dy > 0) {
83+ setDragging(true);
84+ setDragY(dy);
85+ } else if (expandable && current !== "full") {
86+ setDragging(true);
87+ setDragY(Math.max(dy, -80));
88+ }
89+ };
90+ const onPointerUp = () => {
91+ if (startY.current === null) return;
92+ const dy = dragY;
93+ startY.current = null;
94+ axis.current = null;
95+ setDragging(false);
96+ setDragY(0);
97+ if (dy > 110 && !modal) onOpenChange(false);
98+ else if (dy < -40 && expandable && current !== "full") setCurrent("full");
99+ else if (dy > 60 && current === "full" && snap !== "full") setCurrent(snap);
100+ };
101+
102+ return (
103+ <DialogPrimitive.Root open={open} onOpenChange={modal ? () => {} : onOpenChange}>
104+ <DialogPrimitive.Portal>
105+ <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/45 backdrop-blur-[3px] data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" />
106+ <DialogPrimitive.Content
107+ onOpenAutoFocus={(e) => e.preventDefault()}
108+ className={cn(
109+ "fixed inset-x-0 bottom-0 z-50 flex flex-col bg-bg-elevated text-fg shadow-sheet focus:outline-none",
110+ "rounded-t-[22px] border-t border-border/70",
111+ SNAP_MAX[current],
112+ !dragging && !reduced && "data-[state=open]:animate-sheet-in data-[state=closed]:animate-sheet-out transition-[height,max-height] duration-300 ease-[var(--ease-out-soft)]",
113+ className,
114+ )}
115+ style={dragging ? { transform: `translateY(${Math.max(0, dragY)}px)` } : undefined}
116+ onPointerMove={onPointerMove}
117+ onPointerUp={onPointerUp}
118+ onPointerCancel={onPointerUp}
119+ >
120+ {/* Handle: always draggable */}
121+ <div className="shrink-0 cursor-grab touch-none select-none active:cursor-grabbing" onPointerDown={(e) => onPointerDown(e, true)} aria-hidden>
122+ <div className="sheet-handle" />
123+ </div>
124+ <div className={cn("shrink-0 px-5", title && !hideTitle ? "pb-2 pt-1" : "")}>
125+ <DialogPrimitive.Title className={cn("text-[17px] font-semibold leading-6 tracking-tight", hideTitle && "sr-only")}>{title ?? "Sheet"}</DialogPrimitive.Title>
126+ {description ? <DialogPrimitive.Description className={cn("mt-0.5 text-[13px] text-fg-muted", hideTitle && "sr-only")}>{description}</DialogPrimitive.Description> : null}
127+ {showClose ? (
128+ <DialogPrimitive.Close className="tap absolute right-3 top-3 rounded-full bg-bg-muted p-1.5 text-fg-muted hover:text-fg" aria-label="Close">
129+ <X className="size-4" />
130+ </DialogPrimitive.Close>
131+ ) : null}
132+ </div>
133+ {header ? <div className="shrink-0">{header}</div> : null}
134+ <div ref={bodyRef} onPointerDown={(e) => onPointerDown(e, false)} className={cn("min-h-0 flex-1 overflow-y-auto contain-scroll scrollbar-thin", flush ? "" : "px-4 pb-4", !footer && "pb-[max(16px,var(--sab))]", bodyClassName)}>
135+ {children}
136+ </div>
137+ {footer ? <div className="shrink-0 border-t border-border bg-bg-elevated px-4 pt-3 pb-[max(12px,var(--sab))]">{footer}</div> : null}
138+ </DialogPrimitive.Content>
139+ </DialogPrimitive.Portal>
140+ </DialogPrimitive.Root>
141+ );
142+}
143+
144+/* ------------------------------------------------------------------------------------------------ */
145+
146+export interface ResponsiveDialogProps extends Omit<BottomSheetProps, "snap" | "expandable"> {
147+ /** Desktop width. */
148+ size?: "sm" | "md" | "lg" | "xl" | "full";
149+ /** Mobile snap point (default content). */
150+ snap?: SheetSnap;
151+ expandable?: boolean;
152+ /** Desktop variant: centered dialog (default) or right side panel. */
153+ desktop?: "dialog" | "panel";
154+ /** Desktop: remove the default body padding. */
155+ desktopFlush?: boolean;
156+}
157+
158+const SIZES = { sm: "sm:max-w-sm", md: "sm:max-w-lg", lg: "sm:max-w-2xl", xl: "sm:max-w-4xl", full: "sm:max-w-[min(96vw,1200px)]" };
159+
160+/**
161+ * Bottom sheet on phones, centered dialog (or side panel) on ≥ md. One API, two native-feeling shapes.
162+ */
163+export function ResponsiveDialog({ size = "md", desktop = "dialog", desktopFlush, ...props }: ResponsiveDialogProps) {
164+ const isMobile = useIsMobile();
165+ if (isMobile) return <BottomSheet {...props} />;
166+ const { open, onOpenChange, title, description, children, footer, hideTitle, showClose = true, className, bodyClassName, modal, flush, header } = props;
167+ const isPanel = desktop === "panel";
168+ return (
169+ <DialogPrimitive.Root open={open} onOpenChange={modal ? () => {} : onOpenChange}>
170+ <DialogPrimitive.Portal>
171+ <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/40 backdrop-blur-[2px] data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out" />
172+ <DialogPrimitive.Content
173+ className={cn(
174+ "fixed z-50 flex flex-col border border-border bg-bg-elevated text-fg shadow-lg focus:outline-none",
175+ isPanel
176+ ? "inset-y-2 right-2 w-[min(440px,calc(100vw-16px))] rounded-2xl data-[state=open]:animate-fade-up data-[state=closed]:animate-fade-out"
177+ : cn("left-1/2 top-1/2 w-full -translate-x-1/2 -translate-y-1/2 rounded-2xl max-h-[min(86vh,900px)] data-[state=open]:animate-scale-in data-[state=closed]:animate-scale-out", SIZES[size]),
178+ className,
179+ )}
180+ >
181+ <div className={cn("shrink-0", title && !hideTitle ? "px-6 pt-5 pb-3" : "")}>
182+ <DialogPrimitive.Title className={cn("text-base font-semibold leading-6 tracking-tight", hideTitle && "sr-only")}>{title ?? "Dialog"}</DialogPrimitive.Title>
183+ {description ? <DialogPrimitive.Description className={cn("mt-0.5 text-sm text-fg-muted", hideTitle && "sr-only")}>{description}</DialogPrimitive.Description> : null}
184+ </div>
185+ {showClose ? (
186+ <DialogPrimitive.Close className="absolute right-3 top-3 rounded-md p-1.5 text-fg-subtle transition-colors hover:bg-bg-muted hover:text-fg" aria-label="Close">
187+ <X className="size-4" />
188+ </DialogPrimitive.Close>
189+ ) : null}
190+ {header ? <div className="shrink-0">{header}</div> : null}
191+ <div className={cn("min-h-0 flex-1 overflow-y-auto scrollbar-thin", flush || desktopFlush ? "" : "px-6 pb-6", bodyClassName)}>{children}</div>
192+ {footer ? <div className="shrink-0 border-t border-border px-6 py-3">{footer}</div> : null}
193+ </DialogPrimitive.Content>
194+ </DialogPrimitive.Portal>
195+ </DialogPrimitive.Root>
196+ );
197+}
198+
199+/* ------------------------------------------------------------------------------------------------ */
200+
201+export interface ActionSheetItem {
202+ key: string;
203+ label: React.ReactNode;
204+ icon?: React.ReactNode;
205+ hint?: React.ReactNode;
206+ onSelect: () => void;
207+ destructive?: boolean;
208+ disabled?: boolean;
209+ /** Renders a check mark (selection lists). */
210+ selected?: boolean;
211+}
212+
213+export interface ActionSheetProps {
214+ open: boolean;
215+ onOpenChange: (open: boolean) => void;
216+ title?: React.ReactNode;
217+ description?: React.ReactNode;
218+ items: (ActionSheetItem | "separator")[];
219+ /** Optional content above the list (e.g. a preview of the message). */
220+ children?: React.ReactNode;
221+ cancelLabel?: string;
222+}
223+
224+/**
225+ * iOS-style action list inside a bottom sheet. Use for long-press / "more" menus on phones.
226+ * On desktop callers should keep using DropdownMenu; `useIsMobile()` decides.
227+ */
228+export function ActionSheet({ open, onOpenChange, title, description, items, children, cancelLabel = "Cancel" }: ActionSheetProps) {
229+ return (
230+ <BottomSheet open={open} onOpenChange={onOpenChange} title={title} description={description} hideTitle={!title} expandable={false} flush bodyClassName="px-3 pb-[max(12px,var(--sab))]">
231+ {children ? <div className="px-2 pb-3">{children}</div> : null}
232+ <div className="overflow-hidden rounded-2xl bg-bg-subtle">
233+ {items.map((it, i) =>
234+ it === "separator" ? (
235+ <div key={`sep-${i}`} className="h-2 bg-bg" />
236+ ) : (
237+ <button
238+ key={it.key}
239+ type="button"
240+ disabled={it.disabled}
241+ onClick={() => {
242+ onOpenChange(false);
243+ // Let the sheet start closing before running navigation-heavy handlers.
244+ setTimeout(it.onSelect, 10);
245+ }}
246+ className={cn(
247+ "flex min-h-[52px] w-full items-center gap-3 px-4 text-left text-[16px] transition-colors active:bg-bg-muted disabled:opacity-40 [&_svg]:size-[18px] [&_svg]:shrink-0",
248+ i > 0 && items[i - 1] !== "separator" && "border-t border-hairline",
249+ it.destructive ? "text-danger" : "text-fg",
250+ )}
251+ >
252+ <span className={cn("text-fg-muted", it.destructive && "text-danger")}>{it.icon}</span>
253+ <span className="min-w-0 flex-1 truncate">{it.label}</span>
254+ {it.hint ? <span className="text-[13px] text-fg-subtle">{it.hint}</span> : null}
255+ {it.selected ? <span className="text-accent">✓</span> : null}
256+ </button>
257+ ),
258+ )}
259+ </div>
260+ <button type="button" onClick={() => onOpenChange(false)} className="mt-2 flex min-h-[52px] w-full items-center justify-center rounded-2xl bg-bg-subtle text-[16px] font-semibold text-fg active:bg-bg-muted">
261+ {cancelLabel}
262+ </button>
263+ </BottomSheet>
264+ );
265+}
modified src/db/index.ts +4 −1
@@ -1,6 +1,8 @@
1 import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; 1 import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
2 import { Pool } from "pg"; 2 import { Pool } from "pg";
3−import * as schema from "./schema"; 3+import * as core from "./schema";
4+import * as workspace from "./schema-workspace";
5+const schema = { ...core, ...workspace };
4 6
5 export type Db = NodePgDatabase<typeof schema>; 7 export type Db = NodePgDatabase<typeof schema>;
6 8
@@ -46,3 +48,4 @@ export async function pingDb(): Promise<boolean> {
46 } 48 }
47 49
48 export * from "./schema"; 50 export * from "./schema";
51+export * from "./schema-workspace";
added src/db/schema-workspace.ts +201 −0
@@ -0,0 +1,201 @@
1+/**
2+ * Workspace tables added by the 2026-09 "AI operating system" upgrade:
3+ * projects (workspaces), project files (reusable context library), prompt library with variables,
4+ * user model labels, custom OpenAI-compatible endpoints and Arena votes/criteria.
5+ *
6+ * Kept in a separate file so parallel feature work never edits `schema.ts` concurrently.
7+ * `drizzle.config.ts` globs `schema*.ts`; `src/db/index.ts` re-exports everything.
8+ */
9+import { pgTable, text, timestamp, boolean, integer, jsonb, index, uniqueIndex, primaryKey } from "drizzle-orm/pg-core";
10+import { sql } from "drizzle-orm";
11+import { users } from "./schema";
12+
13+const ts = (name: string) => timestamp(name, { withTimezone: true, mode: "date" });
14+const now = () => sql`now()`;
15+
16+// ---------------------------------------------------------------------------
17+// Projects / workspaces
18+// ---------------------------------------------------------------------------
19+export const projects = pgTable(
20+ "projects",
21+ {
22+ id: text("id").primaryKey(),
23+ userId: text("user_id")
24+ .notNull()
25+ .references(() => users.id, { onDelete: "cascade" }),
26+ name: text("name").notNull(),
27+ description: text("description"),
28+ /** Emoji or lucide icon name. */
29+ icon: text("icon"),
30+ color: text("color"),
31+ /** Default system instructions prepended to every chat in the project. */
32+ instructions: text("instructions"),
33+ /** Preferred model keys (first = default for new chats). */
34+ preferredModelKeys: jsonb("preferred_model_keys").$type<string[]>().notNull().default([]),
35+ /** Default generation settings for new chats. */
36+ defaultSettings: jsonb("default_settings").$type<Record<string, unknown>>().notNull().default({}),
37+ /** Free-form markdown notes. */
38+ notes: text("notes"),
39+ archived: boolean("archived").notNull().default(false),
40+ sortOrder: integer("sort_order").notNull().default(0),
41+ createdAt: ts("created_at").notNull().default(now()),
42+ updatedAt: ts("updated_at").notNull().default(now()),
43+ },
44+ (t) => [index("projects_user_idx").on(t.userId)],
45+);
46+
47+/** Reusable files saved to a project (or to the user's global library when projectId is null). */
48+export const projectFiles = pgTable(
49+ "project_files",
50+ {
51+ id: text("id").primaryKey(),
52+ userId: text("user_id")
53+ .notNull()
54+ .references(() => users.id, { onDelete: "cascade" }),
55+ projectId: text("project_id").references(() => projects.id, { onDelete: "cascade" }),
56+ kind: text("kind").notNull(), // image | pdf | text | code | csv | json | document
57+ name: text("name").notNull(),
58+ mimeType: text("mime_type").notNull(),
59+ sizeBytes: integer("size_bytes").notNull(),
60+ /** Base64 payload (same storage strategy as message_attachments). */
61+ dataBase64: text("data_base64").notNull(),
62+ width: integer("width"),
63+ height: integer("height"),
64+ /** Heuristic token estimate computed at upload. */
65+ estimatedTokens: integer("estimated_tokens"),
66+ /** Optional user description used when injecting into prompts. */
67+ description: text("description"),
68+ createdAt: ts("created_at").notNull().default(now()),
69+ },
70+ (t) => [index("project_files_user_idx").on(t.userId), index("project_files_project_idx").on(t.projectId)],
71+);
72+
73+// ---------------------------------------------------------------------------
74+// Prompt library (templates with {{variables}})
75+// ---------------------------------------------------------------------------
76+export const prompts = pgTable(
77+ "prompts",
78+ {
79+ id: text("id").primaryKey(),
80+ userId: text("user_id")
81+ .notNull()
82+ .references(() => users.id, { onDelete: "cascade" }),
83+ projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }),
84+ /** system | user | template | structured */
85+ kind: text("kind").notNull().default("user"),
86+ name: text("name").notNull(),
87+ description: text("description"),
88+ /** Body with `{{variable}}` placeholders. */
89+ content: text("content").notNull(),
90+ /** Declared variables (parsed from content, editable defaults). */
91+ variables: jsonb("variables").$type<{ name: string; label?: string; default?: string; required?: boolean; options?: string[] }[]>().notNull().default([]),
92+ /** For `structured`: the JSON schema to request. */
93+ schema: jsonb("schema").$type<Record<string, unknown> | null>(),
94+ folder: text("folder"),
95+ tags: jsonb("tags").$type<string[]>().notNull().default([]),
96+ favorite: boolean("favorite").notNull().default(false),
97+ defaultModelKey: text("default_model_key"),
98+ uses: integer("uses").notNull().default(0),
99+ lastUsedAt: ts("last_used_at"),
100+ createdAt: ts("created_at").notNull().default(now()),
101+ updatedAt: ts("updated_at").notNull().default(now()),
102+ },
103+ (t) => [index("prompts_user_idx").on(t.userId), index("prompts_user_folder_idx").on(t.userId, t.folder)],
104+);
105+
106+// ---------------------------------------------------------------------------
107+// Model intelligence — user labels & parameter presets scoped to capability sets
108+// ---------------------------------------------------------------------------
109+export const userModelLabels = pgTable(
110+ "user_model_labels",
111+ {
112+ userId: text("user_id")
113+ .notNull()
114+ .references(() => users.id, { onDelete: "cascade" }),
115+ modelKey: text("model_key").notNull(),
116+ label: text("label").notNull(),
117+ updatedAt: ts("updated_at").notNull().default(now()),
118+ },
119+ (t) => [primaryKey({ columns: [t.userId, t.modelKey] })],
120+);
121+
122+// ---------------------------------------------------------------------------
123+// Custom OpenAI-compatible endpoints (Ollama, LM Studio, vLLM, llama.cpp, MLX…)
124+// ---------------------------------------------------------------------------
125+export const customEndpoints = pgTable(
126+ "custom_endpoints",
127+ {
128+ id: text("id").primaryKey(),
129+ userId: text("user_id")
130+ .notNull()
131+ .references(() => users.id, { onDelete: "cascade" }),
132+ name: text("name").notNull(),
133+ /** Base URL up to and including `/v1` (e.g. http://localhost:11434/v1). */
134+ baseUrl: text("base_url").notNull(),
135+ /** Optional API key, AES-256-GCM envelope like provider_connections.encrypted_key. */
136+ encryptedKey: text("encrypted_key"),
137+ keyHint: text("key_hint"),
138+ /** Extra headers (values encrypted as a single JSON envelope). */
139+ encryptedHeaders: text("encrypted_headers"),
140+ /** Relative path used for discovery, default `/models`. Empty = manual models only. */
141+ modelsPath: text("models_path").notNull().default("/models"),
142+ /** Manually declared models when discovery is unavailable. */
143+ manualModels: jsonb("manual_models").$type<{ id: string; displayName?: string; contextTokens?: number; vision?: boolean; tools?: boolean; reasoning?: boolean }[]>().notNull().default([]),
144+ status: text("status").notNull().default("unverified"), // unverified | valid | invalid | error
145+ lastValidatedAt: ts("last_validated_at"),
146+ lastValidationError: text("last_validation_error"),
147+ lastLatencyMs: integer("last_latency_ms"),
148+ modelsAvailable: integer("models_available"),
149+ createdAt: ts("created_at").notNull().default(now()),
150+ updatedAt: ts("updated_at").notNull().default(now()),
151+ },
152+ (t) => [index("custom_endpoints_user_idx").on(t.userId)],
153+);
154+
155+// ---------------------------------------------------------------------------
156+// Arena — votes per criterion (ratings on arena_responses stay for compatibility)
157+// ---------------------------------------------------------------------------
158+export const arenaVotes = pgTable(
159+ "arena_votes",
160+ {
161+ id: text("id").primaryKey(),
162+ userId: text("user_id")
163+ .notNull()
164+ .references(() => users.id, { onDelete: "cascade" }),
165+ sessionId: text("session_id").notNull(),
166+ responseId: text("response_id").notNull(),
167+ modelKey: text("model_key").notNull(),
168+ /** best | accurate | writing | coding | value | fastest | custom:<slug> */
169+ criterion: text("criterion").notNull(),
170+ /** Task category the prompt was classified into (coding | research | writing | reasoning | general). */
171+ category: text("category"),
172+ createdAt: ts("created_at").notNull().default(now()),
173+ },
174+ (t) => [uniqueIndex("arena_votes_session_criterion_uq").on(t.sessionId, t.criterion), index("arena_votes_user_model_idx").on(t.userId, t.modelKey)],
175+);
176+
177+// ---------------------------------------------------------------------------
178+// Public share links for Arena sessions
179+// ---------------------------------------------------------------------------
180+export const sharedArenaSessions = pgTable(
181+ "shared_arena_sessions",
182+ {
183+ id: text("id").primaryKey(),
184+ sessionId: text("session_id").notNull(),
185+ userId: text("user_id")
186+ .notNull()
187+ .references(() => users.id, { onDelete: "cascade" }),
188+ snapshot: jsonb("snapshot").$type<Record<string, unknown>>().notNull(),
189+ isPublic: boolean("is_public").notNull().default(true),
190+ viewCount: integer("view_count").notNull().default(0),
191+ createdAt: ts("created_at").notNull().default(now()),
192+ revokedAt: ts("revoked_at"),
193+ },
194+ (t) => [index("shared_arena_session_idx").on(t.sessionId)],
195+);
196+
197+export type Project = typeof projects.$inferSelect;
198+export type ProjectFile = typeof projectFiles.$inferSelect;
199+export type Prompt = typeof prompts.$inferSelect;
200+export type CustomEndpoint = typeof customEndpoints.$inferSelect;
201+export type ArenaVote = typeof arenaVotes.$inferSelect;
modified src/db/schema.ts +3 −0
@@ -274,6 +274,8 @@ export const conversations = pgTable(
274 title: text("title").notNull().default("New chat"), 274 title: text("title").notNull().default("New chat"),
275 titleSource: text("title_source").notNull().default("auto"), // auto | user 275 titleSource: text("title_source").notNull().default("auto"), // auto | user
276 folderId: text("folder_id").references(() => folders.id, { onDelete: "set null" }), 276 folderId: text("folder_id").references(() => folders.id, { onDelete: "set null" }),
277+ /** Workspace the conversation belongs to (see schema-workspace.ts `projects`). No FK: projects may be deleted independently. */
278+ projectId: text("project_id"),
277 pinned: boolean("pinned").notNull().default(false), 279 pinned: boolean("pinned").notNull().default(false),
278 archived: boolean("archived").notNull().default(false), 280 archived: boolean("archived").notNull().default(false),
279 /** Last model used (`provider/modelId`). */ 281 /** Last model used (`provider/modelId`). */
@@ -296,6 +298,7 @@ export const conversations = pgTable(
296 index("conversations_user_updated_idx").on(t.userId, t.updatedAt), 298 index("conversations_user_updated_idx").on(t.userId, t.updatedAt),
297 index("conversations_user_folder_idx").on(t.userId, t.folderId), 299 index("conversations_user_folder_idx").on(t.userId, t.folderId),
298 index("conversations_user_model_idx").on(t.userId, t.modelKey), 300 index("conversations_user_model_idx").on(t.userId, t.modelKey),
301+ index("conversations_user_project_idx").on(t.userId, t.projectId),
299 ], 302 ],
300 ); 303 );
301 304
added src/lib/ai/registry/user-models.ts +22 −0
@@ -0,0 +1,22 @@
1+import "server-only";
2+import { and, eq } from "drizzle-orm";
3+import { getDb, userModelLabels } from "@/db";
4+
5+/** User-defined labels ("My research model") keyed by model key. */
6+export async function modelLabels(userId: string): Promise<Record<string, string>> {
7+ const rows = await getDb().select({ modelKey: userModelLabels.modelKey, label: userModelLabels.label }).from(userModelLabels).where(eq(userModelLabels.userId, userId));
8+ return Object.fromEntries(rows.map((r) => [r.modelKey, r.label]));
9+}
10+
11+export async function setModelLabel(userId: string, modelKey: string, label: string | null): Promise<void> {
12+ const db = getDb();
13+ const clean = label?.trim().slice(0, 60) ?? "";
14+ if (!clean) {
15+ await db.delete(userModelLabels).where(and(eq(userModelLabels.userId, userId), eq(userModelLabels.modelKey, modelKey)));
16+ return;
17+ }
18+ await db
19+ .insert(userModelLabels)
20+ .values({ userId, modelKey, label: clean })
21+ .onConflictDoUpdate({ target: [userModelLabels.userId, userModelLabels.modelKey], set: { label: clean, updatedAt: new Date() } });
22+}
added src/lib/client/hooks.ts +295 −0
@@ -0,0 +1,295 @@
1+"use client";
2+import * as React from "react";
3+
4+/** Tailwind `md` breakpoint — below it the app renders its mobile shell. */
5+export const MOBILE_QUERY = "(max-width: 767.98px)";
6+export const TABLET_QUERY = "(min-width: 768px) and (max-width: 1023.98px)";
7+export const COARSE_QUERY = "(pointer: coarse)";
8+export const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
9+
10+function subscribe(query: string) {
11+ return (cb: () => void) => {
12+ const mq = window.matchMedia(query);
13+ mq.addEventListener("change", cb);
14+ return () => mq.removeEventListener("change", cb);
15+ };
16+}
17+
18+/** SSR-safe media query. Returns `fallback` on the server and on the first client render. */
19+export function useMediaQuery(query: string, fallback = false): boolean {
20+ return React.useSyncExternalStore(
21+ subscribe(query),
22+ () => window.matchMedia(query).matches,
23+ () => fallback,
24+ );
25+}
26+
27+export function useIsMobile(): boolean {
28+ return useMediaQuery(MOBILE_QUERY);
29+}
30+export function useIsTablet(): boolean {
31+ return useMediaQuery(TABLET_QUERY);
32+}
33+export function useIsCoarsePointer(): boolean {
34+ return useMediaQuery(COARSE_QUERY);
35+}
36+export function usePrefersReducedMotion(): boolean {
37+ return useMediaQuery(REDUCED_MOTION_QUERY);
38+}
39+
40+/** True after hydration — use to gate `resolvedTheme`, `window`, etc. */
41+export function useMounted(): boolean {
42+ return React.useSyncExternalStore(
43+ () => () => {},
44+ () => true,
45+ () => false,
46+ );
47+}
48+
49+/**
50+ * Tracks the on-screen keyboard through `visualViewport` and publishes the hidden
51+ * height as the CSS variable `--kb` on <html>. Layouts use `.h-app`
52+ * (`calc(100dvh - var(--kb))`) so the composer always stays above the keyboard.
53+ * Returns the current inset in px.
54+ */
55+export function useKeyboardInset(): number {
56+ const [inset, setInset] = React.useState(0);
57+ React.useEffect(() => {
58+ const vv = window.visualViewport;
59+ if (!vv) return;
60+ let raf = 0;
61+ const update = () => {
62+ cancelAnimationFrame(raf);
63+ raf = requestAnimationFrame(() => {
64+ // Difference between the layout viewport and the visual viewport = keyboard (iOS/Android).
65+ const hidden = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop));
66+ // Ignore tiny deltas from browser chrome animations.
67+ const kb = hidden > 60 ? hidden : 0;
68+ document.documentElement.style.setProperty("--kb", `${kb}px`);
69+ document.documentElement.toggleAttribute("data-keyboard", kb > 0);
70+ setInset(kb);
71+ // iOS scrolls the page when an input focuses; snap it back so fixed elements line up.
72+ if (kb > 0 && window.scrollY !== 0) window.scrollTo(0, 0);
73+ });
74+ };
75+ vv.addEventListener("resize", update);
76+ vv.addEventListener("scroll", update);
77+ update();
78+ return () => {
79+ vv.removeEventListener("resize", update);
80+ vv.removeEventListener("scroll", update);
81+ cancelAnimationFrame(raf);
82+ document.documentElement.style.setProperty("--kb", "0px");
83+ document.documentElement.removeAttribute("data-keyboard");
84+ };
85+ }, []);
86+ return inset;
87+}
88+
89+export interface LongPressOptions {
90+ /** ms before the press fires (default 420). */
91+ delay?: number;
92+ /** Movement tolerance in px before the press is cancelled (default 10). */
93+ tolerance?: number;
94+ onLongPress: (e: { x: number; y: number; target: EventTarget | null }) => void;
95+ /** Optional plain-click handler (fires only when no long press happened). */
96+ onClick?: (e: React.PointerEvent) => void;
97+ disabled?: boolean;
98+}
99+
100+/**
101+ * Long-press gesture for touch surfaces. Returns pointer handlers to spread on the element.
102+ * Also suppresses the native context menu so iOS doesn't show its own callout.
103+ */
104+export function useLongPress({ delay = 420, tolerance = 10, onLongPress, onClick, disabled }: LongPressOptions) {
105+ const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
106+ const start = React.useRef<{ x: number; y: number } | null>(null);
107+ const fired = React.useRef(false);
108+
109+ const clear = React.useCallback(() => {
110+ if (timer.current) clearTimeout(timer.current);
111+ timer.current = null;
112+ start.current = null;
113+ }, []);
114+
115+ React.useEffect(() => clear, [clear]);
116+
117+ if (disabled) return {};
118+ return {
119+ onPointerDown: (e: React.PointerEvent) => {
120+ if (e.pointerType === "mouse" && e.button !== 0) return;
121+ fired.current = false;
122+ start.current = { x: e.clientX, y: e.clientY };
123+ const target = e.target;
124+ timer.current = setTimeout(() => {
125+ fired.current = true;
126+ if (navigator.vibrate) navigator.vibrate(8);
127+ onLongPress({ x: start.current?.x ?? e.clientX, y: start.current?.y ?? e.clientY, target });
128+ clear();
129+ }, delay);
130+ },
131+ onPointerMove: (e: React.PointerEvent) => {
132+ if (!start.current) return;
133+ if (Math.abs(e.clientX - start.current.x) > tolerance || Math.abs(e.clientY - start.current.y) > tolerance) clear();
134+ },
135+ onPointerUp: (e: React.PointerEvent) => {
136+ const wasPending = Boolean(timer.current);
137+ clear();
138+ if (wasPending && !fired.current) onClick?.(e);
139+ },
140+ onPointerCancel: clear,
141+ onPointerLeave: clear,
142+ onContextMenu: (e: React.MouseEvent) => {
143+ // Touch long-press triggers contextmenu on Android; we already handle it.
144+ if (fired.current || timer.current) e.preventDefault();
145+ },
146+ };
147+}
148+
149+export interface SwipeOptions {
150+ onSwipeLeft?: () => void;
151+ onSwipeRight?: () => void;
152+ onSwipeDown?: () => void;
153+ onSwipeUp?: () => void;
154+ /** px of travel needed (default 56). */
155+ threshold?: number;
156+ /** Only start a horizontal swipe when the touch begins within `edge` px of the left edge. */
157+ edge?: number;
158+ disabled?: boolean;
159+ /** Ignore swipes whose start target (or ancestor) matches this selector, e.g. horizontally scrollable areas. */
160+ ignoreSelector?: string;
161+}
162+
163+/**
164+ * Attaches touch swipe recognition to a DOM element (via ref). Direction is locked after the
165+ * first 12px so vertical scrolling is never hijacked.
166+ */
167+export function useSwipe<T extends HTMLElement>(ref: React.RefObject<T | null>, { onSwipeLeft, onSwipeRight, onSwipeDown, onSwipeUp, threshold = 56, edge, disabled, ignoreSelector }: SwipeOptions) {
168+ React.useEffect(() => {
169+ const el = ref.current;
170+ if (!el || disabled) return;
171+ let sx = 0, sy = 0, dx = 0, dy = 0, active = false, axis: "x" | "y" | null = null;
172+ const onStart = (e: TouchEvent) => {
173+ if (e.touches.length !== 1) return;
174+ const t = e.touches[0];
175+ if (edge !== undefined && t.clientX > edge) return;
176+ if (ignoreSelector && (e.target as Element | null)?.closest?.(ignoreSelector)) return;
177+ sx = t.clientX; sy = t.clientY; dx = 0; dy = 0; active = true; axis = null;
178+ };
179+ const onMove = (e: TouchEvent) => {
180+ if (!active) return;
181+ const t = e.touches[0];
182+ dx = t.clientX - sx; dy = t.clientY - sy;
183+ if (!axis && (Math.abs(dx) > 12 || Math.abs(dy) > 12)) axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
184+ };
185+ const onEnd = () => {
186+ if (!active) return;
187+ active = false;
188+ if (axis === "x" && Math.abs(dx) >= threshold) (dx < 0 ? onSwipeLeft : onSwipeRight)?.();
189+ else if (axis === "y" && Math.abs(dy) >= threshold) (dy < 0 ? onSwipeUp : onSwipeDown)?.();
190+ };
191+ el.addEventListener("touchstart", onStart, { passive: true });
192+ el.addEventListener("touchmove", onMove, { passive: true });
193+ el.addEventListener("touchend", onEnd);
194+ el.addEventListener("touchcancel", onEnd);
195+ return () => {
196+ el.removeEventListener("touchstart", onStart);
197+ el.removeEventListener("touchmove", onMove);
198+ el.removeEventListener("touchend", onEnd);
199+ el.removeEventListener("touchcancel", onEnd);
200+ };
201+ }, [ref, onSwipeLeft, onSwipeRight, onSwipeDown, onSwipeUp, threshold, edge, disabled, ignoreSelector]);
202+}
203+
204+/** Persisted boolean/string/JSON state in localStorage (SSR-safe: initial render uses `initial`). */
205+export function useLocalStorage<T>(key: string, initial: T): [T, (v: T | ((prev: T) => T)) => void] {
206+ const [value, setValue] = React.useState<T>(initial);
207+ const loaded = React.useRef(false);
208+ React.useEffect(() => {
209+ try {
210+ const raw = window.localStorage.getItem(key);
211+ // eslint-disable-next-line react-hooks/set-state-in-effect
212+ if (raw !== null) setValue(JSON.parse(raw) as T);
213+ } catch {
214+ /* ignore */
215+ }
216+ loaded.current = true;
217+ }, [key]);
218+ const set = React.useCallback(
219+ (v: T | ((prev: T) => T)) => {
220+ setValue((prev) => {
221+ const next = typeof v === "function" ? (v as (p: T) => T)(prev) : v;
222+ try {
223+ window.localStorage.setItem(key, JSON.stringify(next));
224+ } catch {
225+ /* quota */
226+ }
227+ return next;
228+ });
229+ },
230+ [key],
231+ );
232+ return [value, set];
233+}
234+
235+/** Debounced value. */
236+export function useDebounced<T>(value: T, ms = 150): T {
237+ const [v, setV] = React.useState(value);
238+ React.useEffect(() => {
239+ const t = setTimeout(() => setV(value), ms);
240+ return () => clearTimeout(t);
241+ }, [value, ms]);
242+ return v;
243+}
244+
245+/** Copy-to-clipboard with a transient `copied` flag. */
246+export function useCopy(resetMs = 1400): [boolean, (text: string) => Promise<void>] {
247+ const [copied, setCopied] = React.useState(false);
248+ const copy = React.useCallback(
249+ async (text: string) => {
250+ try {
251+ await navigator.clipboard.writeText(text);
252+ setCopied(true);
253+ setTimeout(() => setCopied(false), resetMs);
254+ } catch {
255+ /* ignore */
256+ }
257+ },
258+ [resetMs],
259+ );
260+ return [copied, copy];
261+}
262+
263+/** Whether the document is displayed as an installed PWA. */
264+export function useStandalone(): boolean {
265+ return useMediaQuery("(display-mode: standalone)");
266+}
267+
268+/** Tracks the active index of a `.snap-row` carousel and lets you scroll to a panel. */
269+export function useSnapCarousel<T extends HTMLElement>(count: number) {
270+ const ref = React.useRef<T | null>(null);
271+ const [index, setIndex] = React.useState(0);
272+ React.useEffect(() => {
273+ const el = ref.current;
274+ if (!el) return;
275+ let raf = 0;
276+ const onScroll = () => {
277+ cancelAnimationFrame(raf);
278+ raf = requestAnimationFrame(() => {
279+ const w = el.clientWidth || 1;
280+ setIndex(Math.max(0, Math.min(count - 1, Math.round(el.scrollLeft / w))));
281+ });
282+ };
283+ el.addEventListener("scroll", onScroll, { passive: true });
284+ return () => {
285+ el.removeEventListener("scroll", onScroll);
286+ cancelAnimationFrame(raf);
287+ };
288+ }, [count]);
289+ const scrollTo = React.useCallback((i: number, smooth = true) => {
290+ const el = ref.current;
291+ if (!el) return;
292+ el.scrollTo({ left: i * el.clientWidth, behavior: smooth ? "smooth" : "auto" });
293+ }, []);
294+ return { ref, index, scrollTo };
295+}
added src/lib/client/router.ts +215 −0
@@ -0,0 +1,215 @@
1+import type { PolyModel, ProviderId } from "@/lib/client/types";
2+import { estimateTextTokens, estimateAttachmentTokens, estimateCost, type AttachmentLike } from "./tokens";
3+
4+/**
5+ * PolyLLM Smart Router — transparent, client-side model recommendation.
6+ *
7+ * Pure function over the user's usable models: no network, no hidden state. The UI always shows
8+ * the recommendation, the reasons and the estimated cost before anything is sent, and never
9+ * silently upgrades to an expensive model (see `budgetCap`).
10+ */
11+export type RouterMode = "balanced" | "quality" | "fastest" | "cheapest" | "coding" | "research";
12+
13+export const ROUTER_MODES: { value: RouterMode; label: string; description: string }[] = [
14+ { value: "balanced", label: "Balanced", description: "Good quality at a sensible price" },
15+ { value: "quality", label: "Best quality", description: "Strongest reasoning, cost secondary" },
16+ { value: "fastest", label: "Fastest", description: "Lowest latency tiers" },
17+ { value: "cheapest", label: "Lowest cost", description: "Cheapest model that fits the task" },
18+ { value: "coding", label: "Coding", description: "Code generation, review and debugging" },
19+ { value: "research", label: "Research", description: "Long context, reasoning, web search" },
20+];
21+
22+export type TaskType = "coding" | "research" | "writing" | "reasoning" | "analysis" | "vision" | "translation" | "chat";
23+
24+export interface PromptAnalysis {
25+ task: TaskType;
26+ inputTokens: number;
27+ needsVision: boolean;
28+ needsFiles: boolean;
29+ needsLongContext: boolean;
30+ needsReasoning: boolean;
31+ needsWeb: boolean;
32+ needsJson: boolean;
33+ signals: string[];
34+}
35+
36+const CODE_RE = /```|\bfunction\b|\bconst\b|\bimport\b|\bdef\b|\bclass\b|\breturn\b|=>|\bSELECT\b|\bnpm\b|\bpip\b|stack ?trace|exception|bug|refactor|typescript|python|rust|\bsql\b|regex|compile|debug/i;
37+const RESEARCH_RE = /research|sources?|cite|citation|literature|paper|study|studies|latest|news|today|current|2026|compare .* (and|vs)|pros and cons|state of the art|survey/i;
38+const REASON_RE = /prove|proof|derive|step[- ]by[- ]step|theorem|puzzle|riddle|optimi[sz]e|strategy|plan (a|the)|trade-?offs?|why does|analy[sz]e deeply|chain of thought|logic/i;
39+const WRITING_RE = /write|draft|essay|blog|email|letter|story|poem|rewrite|paraphrase|tone|copy(writing)?|headline|slogan|summar/i;
40+const ANALYSIS_RE = /csv|spreadsheet|dataset|data\b|table|statistics|trend|chart|forecast|financial|revenue|kpi|metrics/i;
41+const TRANSLATE_RE = /translate|traduis|traduction|in (french|spanish|german|italian|japanese|chinese|english)/i;
42+const JSON_RE = /\bjson\b|schema|structured output|as a list of objects|key[- ]value/i;
43+const WEB_RE = /search the web|look up|latest|news|today|this week|current price|weather|stock|who won|release date/i;
44+
45+export function analyzePrompt(text: string, attachments: AttachmentLike[] = [], opts: { historyTokens?: number; systemPrompt?: string | null; webSearch?: boolean; responseFormat?: boolean } = {}): PromptAnalysis {
46+ const signals: string[] = [];
47+ const t = text ?? "";
48+ const attTokens = attachments.reduce((n, a) => n + estimateAttachmentTokens(a), 0);
49+ const inputTokens = estimateTextTokens(t) + estimateTextTokens(opts.systemPrompt ?? "") + attTokens + (opts.historyTokens ?? 0);
50+ const needsVision = attachments.some((a) => a.kind === "image");
51+ const needsFiles = attachments.some((a) => a.kind === "pdf");
52+ const needsLongContext = inputTokens > 60_000;
53+ const needsWeb = Boolean(opts.webSearch) || WEB_RE.test(t);
54+ const needsJson = Boolean(opts.responseFormat) || JSON_RE.test(t);
55+ if (needsVision) signals.push("image attached");
56+ if (needsFiles) signals.push("PDF attached");
57+ if (needsLongContext) signals.push(`${Math.round(inputTokens / 1000)}K tokens of context`);
58+ if (needsWeb) signals.push("fresh information");
59+ if (needsJson) signals.push("structured output");
60+
61+ let task: TaskType = "chat";
62+ const codeScore = (t.match(CODE_RE) ?? []).length + (t.includes("```") ? 3 : 0);
63+ if (attachments.some((a) => a.kind === "code")) signals.push("code file");
64+ if (codeScore >= 2 || attachments.some((a) => a.kind === "code")) task = "coding";
65+ else if (TRANSLATE_RE.test(t)) task = "translation";
66+ else if (REASON_RE.test(t)) task = "reasoning";
67+ else if (RESEARCH_RE.test(t)) task = "research";
68+ else if (ANALYSIS_RE.test(t) || attachments.some((a) => a.kind === "csv" || a.kind === "json")) task = "analysis";
69+ else if (WRITING_RE.test(t)) task = "writing";
70+ else if (needsVision) task = "vision";
71+ const needsReasoning = task === "reasoning" || task === "coding" || task === "analysis" || (task === "research" && t.length > 400) || t.length > 1500;
72+ if (task !== "chat") signals.unshift(`${task} task`);
73+ if (needsReasoning && task !== "reasoning") signals.push("benefits from reasoning");
74+ return { task, inputTokens, needsVision, needsFiles, needsLongContext, needsReasoning, needsWeb, needsJson, signals };
75+}
76+
77+export interface RouteCandidate {
78+ model: PolyModel;
79+ score: number;
80+ estimatedUsd: number | null;
81+ reasons: string[];
82+}
83+
84+export interface RouteResult {
85+ analysis: PromptAnalysis;
86+ recommended: RouteCandidate | null;
87+ alternatives: RouteCandidate[];
88+ mode: RouterMode;
89+ /** True when the recommendation exceeds the cap and needs explicit confirmation. */
90+ needsConfirmation: boolean;
91+}
92+
93+const FAST_RE = /flash|mini|nano|haiku|fast|lite|non-reasoning|instant|turbo|small|ministral|8b|9b|gemma|gpt-oss-20b|llama-3\.1-8b/i;
94+const FRONTIER_RE = /opus|fable|gpt-5\.5|gpt-6|pro\b|grok-4(\.\d+)?(?!-fast)|large|k3|deepseek-v4-pro|magistral-medium/i;
95+const CODING_RE = /codestral|code|coder|devstral|gpt-5\.5|sonnet|opus|fable|k2\.7|deepseek/i;
96+const OPEN_RE = /llama|qwen|gemma|gpt-oss|mistral-small|mixtral|deepseek|kimi|k2|k3|glm|command/i;
97+
98+const PROVIDER_LATENCY_BONUS: Partial<Record<ProviderId, number>> = { cerebras: 3, xai: 0.5, gemini: 0.5, openrouter: -0.3 };
99+
100+/** Median USD per 1M output tokens across usable models — used as the "expensive" reference. */
101+function medianOut(models: PolyModel[]): number {
102+ const v = models.map((m) => m.pricing?.outputPerMillion).filter((x): x is number => typeof x === "number").sort((a, b) => a - b);
103+ return v.length ? v[Math.floor(v.length / 2)] : 5;
104+}
105+
106+export function routeModels(models: PolyModel[], analysis: PromptAnalysis, mode: RouterMode = "balanced", opts: { budgetCapUsd?: number; favorites?: Set<string>; expectedOutputTokens?: number } = {}): RouteResult {
107+ const expectedOut = opts.expectedOutputTokens ?? (analysis.task === "writing" || analysis.task === "coding" ? 1200 : 600);
108+ const usable = models.filter((m) => m.status !== "deprecated" && m.capabilities.text !== false);
109+ const median = medianOut(usable);
110+ const cap = opts.budgetCapUsd ?? 0.5;
111+
112+ const scored: RouteCandidate[] = usable
113+ .filter((m) => {
114+ if (analysis.needsVision && !m.capabilities.vision) return false;
115+ if (analysis.needsFiles && !m.capabilities.files) return false;
116+ const ctx = m.limits?.contextTokens ?? 128_000;
117+ if (analysis.inputTokens + expectedOut > ctx * 0.92) return false;
118+ return true;
119+ })
120+ .map((m) => {
121+ const reasons: string[] = [];
122+ let score = 0;
123+ const id = `${m.id} ${m.displayName}`;
124+ const fast = FAST_RE.test(id);
125+ const frontier = FRONTIER_RE.test(id);
126+ const outPrice = m.pricing?.outputPerMillion ?? median;
127+ const inPrice = m.pricing?.inputPerMillion ?? median / 4;
128+ const cost = estimateCost(m, analysis.inputTokens, expectedOut).usd;
129+ const cheapness = Math.max(0, 1 - outPrice / (median * 2)); // 0..1
130+ const ctx = m.limits?.contextTokens ?? 0;
131+
132+ // Capability fit
133+ if (analysis.needsReasoning && m.capabilities.reasoning) {
134+ score += 3;
135+ reasons.push("reasoning");
136+ } else if (analysis.needsReasoning) score -= 2;
137+ if (analysis.needsWeb && m.capabilities.webSearch) {
138+ score += 2.5;
139+ reasons.push("web search");
140+ } else if (analysis.needsWeb) score -= 1;
141+ if (analysis.needsJson && m.capabilities.structuredOutput) {
142+ score += 1.5;
143+ reasons.push("JSON schema");
144+ }
145+ if (analysis.needsVision) reasons.push("vision");
146+ if (analysis.needsLongContext && ctx >= 400_000) {
147+ score += 2.5;
148+ reasons.push(`${Math.round(ctx / 1000)}K context`);
149+ } else if (ctx >= 1_000_000 && (mode === "research" || analysis.task === "research")) {
150+ score += 1;
151+ reasons.push("1M context");
152+ }
153+ if ((analysis.task === "coding" || mode === "coding") && CODING_RE.test(id)) {
154+ score += 2;
155+ reasons.push("strong at code");
156+ }
157+ if (analysis.task === "translation" && fast) score += 1;
158+ if (analysis.task === "chat" && fast) score += 1.5;
159+
160+ // Tier
161+ if (frontier) {
162+ if (mode === "quality" || analysis.task === "reasoning" || analysis.task === "research") {
163+ score += 3;
164+ reasons.push("frontier quality");
165+ } else if (mode === "balanced" && analysis.needsReasoning) score += 1;
166+ else if (mode === "cheapest" || mode === "fastest") score -= 3;
167+ }
168+ if (fast) {
169+ if (mode === "fastest") {
170+ score += 3.5;
171+ reasons.push("fast tier");
172+ } else if (mode === "cheapest") score += 1.5;
173+ else if (mode === "quality") score -= 2;
174+ else if (mode === "balanced" && !analysis.needsReasoning) {
175+ score += 1;
176+ reasons.push("fast");
177+ }
178+ }
179+ score += (PROVIDER_LATENCY_BONUS[m.provider] ?? 0) * (mode === "fastest" ? 1 : 0.2);
180+
181+ // Cost
182+ const costWeight = mode === "cheapest" ? 5 : mode === "balanced" ? 2.2 : mode === "quality" ? 0.4 : mode === "fastest" ? 1 : 1.5;
183+ score += cheapness * costWeight;
184+ if (cheapness > 0.7) reasons.push(`cheap (${inPrice.toFixed(2)}/${outPrice.toFixed(2)} per 1M)`);
185+ if (cost !== null && cost > cap && mode !== "quality") score -= 2;
186+
187+ // Preference & hygiene
188+ if (opts.favorites?.has(m.key)) {
189+ score += 0.8;
190+ reasons.push("favorite");
191+ }
192+ if (m.status === "preview") score -= 0.3;
193+ if (m.provider === "openrouter") score -= 0.6; // prefer native connections when the same model exists
194+ if (OPEN_RE.test(id) && mode === "quality") score -= 0.5;
195+
196+ return { model: m, score, estimatedUsd: cost, reasons: dedupe(reasons).slice(0, 3) };
197+ })
198+ .sort((a, b) => b.score - a.score);
199+
200+ const recommended = scored[0] ?? null;
201+ const alternatives = scored.slice(1, 4);
202+ const needsConfirmation = Boolean(recommended?.estimatedUsd && recommended.estimatedUsd > cap);
203+ return { analysis, recommended, alternatives, mode, needsConfirmation };
204+}
205+
206+function dedupe<T>(arr: T[]): T[] {
207+ return [...new Set(arr)];
208+}
209+
210+/** Human explanation: "Strong reasoning + 1M context". */
211+export function explainRoute(c: RouteCandidate | null): string {
212+ if (!c) return "No compatible model is connected.";
213+ if (!c.reasons.length) return "Best overall fit for this prompt.";
214+ return c.reasons.map((r, i) => (i === 0 ? r.charAt(0).toUpperCase() + r.slice(1) : r)).join(" + ");
215+}
added src/lib/client/tokens.ts +90 −0
@@ -0,0 +1,90 @@
1+import type { PolyModel } from "@/lib/client/types";
2+
3+/**
4+ * Client-side token & cost estimation. Heuristic (≈ 4 chars/token for English prose, ~3.2 for code)
5+ * — precise enough for "≈ $0.08" hints; the server records exact provider usage afterwards.
6+ */
7+export function estimateTextTokens(text: string): number {
8+ if (!text) return 0;
9+ const codeish = /[{}();=<>[\]]/.test(text) && (text.match(/\n/g)?.length ?? 0) > 2;
10+ const perToken = codeish ? 3.2 : 4.0;
11+ // CJK scripts are ~1 token per character.
12+ const cjk = (text.match(/[぀-ヿ㐀-䶿一-鿿가-힯]/g) ?? []).length;
13+ return Math.ceil((text.length - cjk) / perToken + cjk);
14+}
15+
16+export interface AttachmentLike {
17+ kind: string;
18+ sizeBytes: number;
19+ width?: number | null;
20+ height?: number | null;
21+}
22+
23+/** Rough per-attachment token cost (images follow the common 512px-tile heuristic; text ≈ bytes / 4; PDFs ≈ 1.5K / page ≈ bytes/45). */
24+export function estimateAttachmentTokens(a: AttachmentLike): number {
25+ if (a.kind === "image") {
26+ const w = a.width ?? 1024;
27+ const h = a.height ?? 1024;
28+ const tiles = Math.ceil(Math.min(w, 2048) / 512) * Math.ceil(Math.min(h, 2048) / 512);
29+ return 85 + tiles * 170;
30+ }
31+ if (a.kind === "pdf") return Math.max(300, Math.ceil(a.sizeBytes / 45));
32+ return Math.ceil(a.sizeBytes / 4);
33+}
34+
35+export interface ContextEstimate {
36+ /** Tokens in the history that will be replayed. */
37+ history: number;
38+ /** Tokens in the draft + attachments + system prompt. */
39+ draft: number;
40+ total: number;
41+ contextTokens: number | null;
42+ /** 0..1 or null when the model has no known limit. */
43+ ratio: number | null;
44+ level: "ok" | "warn" | "critical" | "over";
45+}
46+
47+export function estimateContext(opts: { historyText: string[]; historyAttachments?: AttachmentLike[]; draft: string; attachments?: AttachmentLike[]; systemPrompt?: string | null; model?: PolyModel | null; expectedOutput?: number }): ContextEstimate {
48+ const history = opts.historyText.reduce((n, t) => n + estimateTextTokens(t), 0) + (opts.historyAttachments ?? []).reduce((n, a) => n + estimateAttachmentTokens(a), 0) + opts.historyText.length * 4;
49+ const draft = estimateTextTokens(opts.draft) + (opts.attachments ?? []).reduce((n, a) => n + estimateAttachmentTokens(a), 0) + estimateTextTokens(opts.systemPrompt ?? "");
50+ const total = history + draft;
51+ const contextTokens = opts.model?.limits?.contextTokens ?? null;
52+ const ratio = contextTokens ? (total + (opts.expectedOutput ?? 0)) / contextTokens : null;
53+ const level = ratio === null ? "ok" : ratio >= 1 ? "over" : ratio >= 0.95 ? "critical" : ratio >= 0.8 ? "warn" : "ok";
54+ return { history, draft, total, contextTokens, ratio, level };
55+}
56+
57+export interface CostEstimate {
58+ inputTokens: number;
59+ outputTokens: number;
60+ /** null when the model has no public pricing. */
61+ usd: number | null;
62+ inputUsd: number | null;
63+ outputUsd: number | null;
64+}
65+
66+/** Estimate a request cost for a model: input tokens known, output tokens assumed (default 600, or maxTokens when smaller). */
67+export function estimateCost(model: PolyModel | null | undefined, inputTokens: number, outputTokens = 600): CostEstimate {
68+ const p = model?.pricing;
69+ if (!p || (p.inputPerMillion === undefined && p.outputPerMillion === undefined)) return { inputTokens, outputTokens, usd: null, inputUsd: null, outputUsd: null };
70+ let inRate = p.inputPerMillion ?? 0;
71+ let outRate = p.outputPerMillion ?? 0;
72+ if (p.longContext && inputTokens > p.longContext.thresholdTokens) {
73+ inRate = p.longContext.inputPerMillion ?? inRate;
74+ outRate = p.longContext.outputPerMillion ?? outRate;
75+ }
76+ const inputUsd = (inputTokens / 1_000_000) * inRate;
77+ const outputUsd = (outputTokens / 1_000_000) * outRate;
78+ return { inputTokens, outputTokens, usd: inputUsd + outputUsd, inputUsd, outputUsd };
79+}
80+
81+/** Compact "~22K tokens · ≈ $0.08" label. */
82+export function formatEstimate(tokens: number, usd: number | null): string {
83+ const t = tokens >= 1_000_000 ? `${(tokens / 1_000_000).toFixed(1)}M` : tokens >= 1000 ? `${Math.round(tokens / 1000)}K` : String(tokens);
84+ if (usd === null) return `~${t} tokens`;
85+ const u = usd < 0.01 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`;
86+ return `~${t} tokens · ≈ ${u}`;
87+}
88+
89+/** Above this estimated cost the composer asks for confirmation before sending. */
90+export const COST_CONFIRM_THRESHOLD_USD = 1.0;
modified src/lib/client/types.ts +3 −0
@@ -5,11 +5,14 @@ export type { PublicMessage, StoredPart } from "@/lib/chat/service";
5 export type { PublicConnection } from "@/lib/providers/keys"; 5 export type { PublicConnection } from "@/lib/providers/keys";
6 export type { GenerationSettingsInput, ChatRequestInput } from "@/lib/chat/schemas"; 6 export type { GenerationSettingsInput, ChatRequestInput } from "@/lib/chat/schemas";
7 export type { ModelPreset, PromptPreset, Folder, UserPreferences, ArenaSession, ArenaResponse } from "@/db/schema"; 7 export type { ModelPreset, PromptPreset, Folder, UserPreferences, ArenaSession, ArenaResponse } from "@/db/schema";
8+export type { Project, ProjectFile, Prompt, CustomEndpoint, ArenaVote } from "@/db/schema-workspace";
8 9
9 export interface ModelsResponse { 10 export interface ModelsResponse {
10 models: import("@/lib/ai/core/types").PolyModel[]; 11 models: import("@/lib/ai/core/types").PolyModel[];
11 favorites: string[]; 12 favorites: string[];
12 recents: string[]; 13 recents: string[];
14+ /** User labels per model key. */
15+ labels: Record<string, string>;
13 connectedProviders: import("@/lib/ai/core/types").ProviderId[]; 16 connectedProviders: import("@/lib/ai/core/types").ProviderId[];
14 } 17 }
15 18
16 19
Menu

Theme