SPB Git

spb/polyllm

Public

QA fixes: stale-session redirect loop, model picker virtualizer mount, 44px hit areas, arena presets overflow, mobile composer toolbar, hotkey on hidden picker; QA scripts

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

18 changed files +289 −27

modified qa/.e2e-session.json +4 −4
@@ -2,20 +2,20 @@
2 "cookies": [ 2 "cookies": [
3 { 3 {
4 "name": "polyllm.session_token", 4 "name": "polyllm.session_token",
5− "value": "phK8cA3C9MPwvAPdhy3XoVvFCa1IBoBP.VlG8WlzEnbIlUawskRzIaIhpdLak%2BdYQTP4w0UJjAn8%3D", 5+ "value": "ZGYXJUeEBk1wErULioJm4GRFpSQyf66t.bmCgkI83DzBd7kqQuTjPoSbkdTO9mBx0s%2B22rVYpeKo%3D",
6 "domain": "localhost", 6 "domain": "localhost",
7 "path": "/", 7 "path": "/",
8− "expires": 1791442457.664571, 8+ "expires": 1791705485.823704,
9 "httpOnly": true, 9 "httpOnly": true,
10 "secure": false, 10 "secure": false,
11 "sameSite": "Lax" 11 "sameSite": "Lax"
12 }, 12 },
13 { 13 {
14 "name": "polyllm.session_data", 14 "name": "polyllm.session_data",
15− "value": "eyJzZXNzaW9uIjp7InNlc3Npb24iOnsiZXhwaXJlc0F0IjoiMjAyNi0xMC0wOFQwNjo1NDoxNy42NjFaIiwidG9rZW4iOiJwaEs4Y0EzQzlNUHd2QVBkaHkzWG9WdkZDYTFJQm9CUCIsImNyZWF0ZWRBdCI6IjIwMjYtMDktMDhUMDY6NTQ6MTcuNjYxWiIsInVwZGF0ZWRBdCI6IjIwMjYtMDktMDhUMDY6NTQ6MTcuNjYxWiIsImlwQWRkcmVzcyI6IjAwMDA6MDAwMDowMDAwOjAwMDA6MDAwMDowMDAwOjAwMDA6MDAwMCIsInVzZXJBZ2VudCI6Ik1vemlsbGEvNS4wIChXaW5kb3dzIE5UIDEwLjA7IFdpbjY0OyB4NjQpIEFwcGxlV2ViS2l0LzUzNy4zNiAoS0hUTUwsIGxpa2UgR2Vja28pIENocm9tZS8xNTMuMC44MDEwLjEyIFNhZmFyaS81MzcuMzYiLCJ1c2VySWQiOiJMRlNiN21LWkZJanJVdFpGZzVPU2RFVmt4TFg2VWVvUCIsImlkIjoibzh5TjA4VnpYeXFTZHkwTjJMNU5HSlN1U2poajgyNWsifSwidXNlciI6eyJuYW1lIjoiRTJFIFRlc3RlciIsImVtYWlsIjoiZTJlKzE3ODg4NTA0NTUwNDBAcG9seWxsbS50ZXN0IiwiZW1haWxWZXJpZmllZCI6dHJ1ZSwiaW1hZ2UiOm51bGwsImNyZWF0ZWRBdCI6IjIwMjYtMDktMDhUMDY6NTQ6MTUuOTk5WiIsInVwZGF0ZWRBdCI6IjIwMjYtMDktMDhUMDY6NTQ6MTYuNzE2WiIsInJvbGUiOiJ1c2VyIiwib25ib2FyZGluZ0NvbXBsZXRlZEF0IjpudWxsLCJpZCI6IkxGU2I3bUtaRklqclV0WkZnNU9TZEVWa3hMWDZVZW9QIn0sInVwZGF0ZWRBdCI6MTc4ODg1MDQ1NzY2MywidmVyc2lvbiI6IjEifSwiZXhwaXJlc0F0IjoxNzg4ODUwNzU3NjYzLCJzaWduYXR1cmUiOiJNT2ZCUV95NnljMmlZZzh4T2ZhSzhkdlF5RjFGVTlqQ2xFVHlNeDRucnNVIn0", 15+ "value": "eyJzZXNzaW9uIjp7InNlc3Npb24iOnsiZXhwaXJlc0F0IjoiMjAyNi0xMC0xMVQwNzo1ODowNS44MTlaIiwidG9rZW4iOiJaR1lYSlVlRUJrMXdFclVMaW9KbTRHUkZwU1F5ZjY2dCIsImNyZWF0ZWRBdCI6IjIwMjYtMDktMTFUMDc6NTg6MDUuODE5WiIsInVwZGF0ZWRBdCI6IjIwMjYtMDktMTFUMDc6NTg6MDUuODE5WiIsImlwQWRkcmVzcyI6IjAwMDA6MDAwMDowMDAwOjAwMDA6MDAwMDowMDAwOjAwMDA6MDAwMCIsInVzZXJBZ2VudCI6Ik1vemlsbGEvNS4wIChNYWNpbnRvc2g7IEludGVsIE1hYyBPUyBYIDEwXzE1XzcpIEFwcGxlV2ViS2l0LzUzNy4zNiAoS0hUTUwsIGxpa2UgR2Vja28pIEhlYWRsZXNzQ2hyb21lLzE1My4wLjgwMTAuMTIgU2FmYXJpLzUzNy4zNiIsInVzZXJJZCI6IjVzY2tTR0FlSzIwS2VSNG40NW56ZVJHaTdiQm9VcjByIiwiaWQiOiJSRXdZelN4ZzhEZVpBcDdyNXlnbkZkYnVoUlFzZTBnayJ9LCJ1c2VyIjp7Im5hbWUiOiJRQSBNb2JpbGUiLCJlbWFpbCI6InFhLm1vYmlsZUBwb2x5bGxtLnRlc3QiLCJlbWFpbFZlcmlmaWVkIjp0cnVlLCJpbWFnZSI6bnVsbCwiY3JlYXRlZEF0IjoiMjAyNi0wOS0xMVQwNzo1NToyOS4wNjZaIiwidXBkYXRlZEF0IjoiMjAyNi0wOS0xMVQwNzo1NToyOS4wNjZaIiwicm9sZSI6InVzZXIiLCJvbmJvYXJkaW5nQ29tcGxldGVkQXQiOm51bGwsImlkIjoiNXNja1NHQWVLMjBLZVI0bjQ1bnplUkdpN2JCb1VyMHIifSwidXBkYXRlZEF0IjoxNzg5MTEzNDg1ODIyLCJ2ZXJzaW9uIjoiMSJ9LCJleHBpcmVzQXQiOjE3ODkxMTM3ODU4MjIsInNpZ25hdHVyZSI6IjRWWWdDcGV3ZXpfVUhTVE1zV2M4UmRuLWczME02YU5waHZucmNHSUNhakEifQ",
16 "domain": "localhost", 16 "domain": "localhost",
17 "path": "/", 17 "path": "/",
18− "expires": 1788850757.664602, 18+ "expires": 1789113785.823732,
19 "httpOnly": true, 19 "httpOnly": true,
20 "secure": false, 20 "secure": false,
21 "sameSite": "Lax" 21 "sameSite": "Lax"
added qa/create-qa-user.mjs +17 −0
@@ -0,0 +1,17 @@
1+import { chromium } from "@playwright/test";
2+import { execSync } from "node:child_process";
3+const EMAIL = process.env.QA_EMAIL ?? "qa.mobile@polyllm.test", PASSWORD = process.env.QA_PASSWORD ?? "Str0ng-Passw0rd-qa!";
4+const b = await chromium.launch(); const p = await b.newPage({ viewport: { width: 1200, height: 800 } });
5+await p.goto("http://localhost:3000/signup", { waitUntil: "networkidle" });
6+console.log("labels:", await p.locator("label").allTextContents());
7+const name = p.getByLabel(/name/i).first(); if (await name.count()) await name.fill("QA Mobile");
8+await p.getByLabel(/email/i).first().fill(EMAIL);
9+const pw = p.getByLabel(/^password$/i); await pw.first().fill(PASSWORD);
10+const confirm = p.getByLabel(/confirm/i); if (await confirm.count()) await confirm.first().fill(PASSWORD);
11+const terms = p.getByRole("checkbox"); if (await terms.count()) await terms.first().check().catch(() => {});
12+await p.getByRole("button", { name: /create|sign up/i }).first().click();
13+await p.waitForTimeout(4000);
14+console.log("after signup url:", p.url());
15+console.log("alerts:", await p.locator('[role=alert], .text-danger').allTextContents());
16+console.log(execSync(`psql postgres://localhost:5432/polyllm -Atc "update users set email_verified=true where email='${EMAIL}' returning id"`).toString().trim() || "no user row");
17+await b.close();
added qa/flows.mjs +193 −0
@@ -0,0 +1,193 @@
1+#!/usr/bin/env node
2+/**
3+ * Interactive mobile flows — drives the real app at a phone viewport and screenshots each state.
4+ * node qa/flows.mjs [--width=390] [--model=anthropic/claude-haiku-4-5-20251001] [--send]
5+ * Requires qa/.e2e-session.json (run responsive-qa.mjs once) and a connected provider for --send.
6+ */
7+import fs from "node:fs";
8+import path from "node:path";
9+import { chromium } from "@playwright/test";
10+
11+const args = Object.fromEntries(process.argv.slice(2).map((a) => a.replace(/^--/, "").split("=")).map(([k, v]) => [k, v ?? "1"]));
12+const BASE = args.base ?? "http://localhost:3000";
13+const W = Number(args.width ?? 390);
14+const H = W <= 375 ? 812 : W <= 390 ? 844 : W <= 393 ? 852 : 932;
15+const MODEL = args.model ?? "anthropic/claude-haiku-4-5-20251001";
16+const OUT = path.resolve(`qa/out/flows-${W}`);
17+fs.mkdirSync(OUT, { recursive: true });
18+
19+const browser = await chromium.launch();
20+const context = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, storageState: "qa/.e2e-session.json" });
21+const page = await context.newPage();
22+const errors = [];
23+page.on("pageerror", (e) => errors.push(e.message.slice(0, 200)));
24+page.on("console", (m) => m.type() === "error" && !/favicon|DevTools|hydrat/i.test(m.text()) && errors.push(m.text().slice(0, 200)));
25+
26+let n = 0;
27+const shot = async (name) => {
28+ n++;
29+ await page.waitForTimeout(350);
30+ await page.screenshot({ path: path.join(OUT, `${String(n).padStart(2, "0")}-${name}.png`) });
31+ const audit = await page.evaluate(() => {
32+ const vw = window.innerWidth;
33+ const past = [];
34+ for (const el of document.querySelectorAll("body *")) {
35+ const r = el.getBoundingClientRect();
36+ if (r.width > 0 && r.right > vw + 1 && r.left < vw && !el.closest(".snap-row, .marquee, [data-allow-overflow]")) past.push(el.tagName + ":" + (el.getAttribute("aria-label") || el.textContent?.trim().slice(0, 30)));
37+ }
38+ return { docOverflow: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth) - vw, past: past.slice(0, 6) };
39+ });
40+ console.log(`${String(n).padStart(2, "0")} ${name.padEnd(28)} ${audit.docOverflow > 1 ? `OVERFLOW +${audit.docOverflow}` : ""} ${audit.past.length ? "past edge: " + audit.past.join(" | ") : ""}`);
41+};
42+const step = async (name, fn) => {
43+ try {
44+ await fn();
45+ } catch (e) {
46+ console.log(` ! ${name}: ${e.message.split("\n")[0].slice(0, 160)}`);
47+ }
48+ await shot(name);
49+};
50+
51+// 1. Chat empty state
52+await page.goto(`${BASE}/app/chat`, { waitUntil: "networkidle" });
53+await shot("chat-empty");
54+
55+// 2. Drawer via hamburger
56+await step("drawer-open", async () => {
57+ await page.getByRole("button", { name: /open sidebar/i }).first().click();
58+ await page.waitForTimeout(400);
59+});
60+await step("drawer-closed", async () => {
61+ await page.keyboard.press("Escape");
62+});
63+
64+// 3. Model picker sheet
65+await step("model-picker", async () => {
66+ await page.getByRole("button", { name: /select model/i }).first().click();
67+ await page.waitForTimeout(500);
68+});
69+await step("model-picker-search", async () => {
70+ await page.getByLabel(/search models/i).fill("cheap vision");
71+ await page.waitForTimeout(400);
72+});
73+await step("model-picked", async () => {
74+ await page.getByLabel(/search models/i).fill("");
75+ const row = page.getByText(new RegExp(MODEL.split("/")[1].replace(/[-.]/g, "[-. ]?").replace(/claude-?/i, "claude"), "i")).first();
76+ await row.click({ timeout: 4000 }).catch(async () => {
77+ await page.getByText(/haiku/i).first().click();
78+ });
79+ await page.waitForTimeout(400);
80+});
81+
82+// 4. Composer: plus sheet, typing (keyboard), expand
83+await step("composer-plus-sheet", async () => {
84+ await page.getByRole("button", { name: /add attachment or option/i }).click();
85+ await page.waitForTimeout(400);
86+});
87+await step("composer-plus-closed", async () => {
88+ await page.keyboard.press("Escape");
89+});
90+await step("composer-typing", async () => {
91+ const ta = page.getByLabel("Message").first();
92+ await ta.click();
93+ await ta.fill("Reply with exactly the word: pong\n\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7");
94+ await page.waitForTimeout(300);
95+});
96+await step("composer-short", async () => {
97+ await page.getByLabel("Message").first().fill("Reply with exactly the word: pong");
98+});
99+
100+// 5. Send (real request) when --send
101+if (args.send) {
102+ await step("streaming", async () => {
103+ await page.getByRole("button", { name: /^send$/i }).click();
104+ await page.waitForTimeout(1500);
105+ });
106+ await step("answered", async () => {
107+ await page.waitForFunction(() => !document.querySelector('[aria-label="Stop generation"]'), null, { timeout: 60_000 });
108+ await page.waitForTimeout(600);
109+ });
110+ await step("message-longpress", async () => {
111+ const msg = page.locator("[id^='msg_'], [id^='m_'], [data-message]").last();
112+ const box = await msg.boundingBox();
113+ if (!box) throw new Error("no message box");
114+ const cdp = await context.newCDPSession(page);
115+ const x = box.x + box.width / 2, y = box.y + Math.min(box.height / 2, 60);
116+ await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
117+ await page.waitForTimeout(700);
118+ await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
119+ await page.waitForTimeout(500);
120+ });
121+ await step("after-longpress", async () => {
122+ await page.keyboard.press("Escape");
123+ });
124+ await step("more-actions", async () => {
125+ await page.getByRole("button", { name: /more actions/i }).first().click();
126+ await page.waitForTimeout(400);
127+ });
128+ await page.keyboard.press("Escape");
129+}
130+
131+// 6. Arena: pick two models
132+await step("arena-empty", async () => {
133+ await page.goto(`${BASE}/app/arena?models=${encodeURIComponent(MODEL)},anthropic/claude-sonnet-4-5-20250929`, { waitUntil: "networkidle" });
134+});
135+if (args.send) {
136+ await step("arena-run", async () => {
137+ await page.getByLabel("Message").first().fill("Reply with one short sentence about the sea.");
138+ await page.getByRole("button", { name: /^send$|^run$|compare/i }).first().click();
139+ await page.waitForTimeout(2500);
140+ });
141+ await step("arena-done", async () => {
142+ await page.waitForFunction(() => !document.querySelector('[aria-label="Stop generation"], [aria-label="Stop"]'), null, { timeout: 90_000 });
143+ await page.waitForTimeout(800);
144+ });
145+ await step("arena-swipe", async () => {
146+ const row = page.locator(".snap-row").first();
147+ await row.evaluate((el) => el.scrollTo({ left: el.clientWidth, behavior: "instant" }));
148+ await page.waitForTimeout(500);
149+ });
150+}
151+
152+// 7. Search sheet + palette
153+await step("search-sheet", async () => {
154+ await page.goto(`${BASE}/app/chat`, { waitUntil: "networkidle" });
155+ await page.getByRole("button", { name: /open sidebar/i }).first().click();
156+ await page.waitForTimeout(300);
157+ await page.getByRole("button", { name: /search conversations/i }).first().click();
158+ await page.waitForTimeout(500);
159+ await page.getByPlaceholder(/search chats/i).fill("pong");
160+ await page.waitForTimeout(700);
161+});
162+await step("palette", async () => {
163+ await page.keyboard.press("Escape");
164+ await page.waitForTimeout(300);
165+ await page.keyboard.press("Escape");
166+ await page.keyboard.press("Meta+k");
167+ await page.waitForTimeout(500);
168+});
169+
170+// 8. Settings / providers sheet
171+await step("providers", async () => {
172+ await page.keyboard.press("Escape");
173+ await page.goto(`${BASE}/app/settings/providers`, { waitUntil: "networkidle" });
174+});
175+await step("add-key-sheet", async () => {
176+ await page.getByRole("button", { name: /add key|connect/i }).first().click();
177+ await page.waitForTimeout(500);
178+});
179+
180+// 9. Dark mode home
181+await step("home-dark", async () => {
182+ await page.keyboard.press("Escape");
183+ await page.emulateMedia({ colorScheme: "dark" });
184+ await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
185+});
186+await step("home-dark-scrolled", async () => {
187+ await page.evaluate(() => window.scrollTo(0, 1800));
188+ await page.waitForTimeout(700);
189+});
190+
191+console.log(`\nerrors: ${errors.length}`);
192+for (const e of errors.slice(0, 8)) console.log(" ", e);
193+await browser.close();
added qa/login-debug.mjs +13 −0
@@ -0,0 +1,13 @@
1+import { chromium } from "@playwright/test";
2+const b = await chromium.launch(); const p = await b.newPage({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
3+p.on("console", (m) => m.type() === "error" && console.log("console:", m.text().slice(0, 200)));
4+await p.goto("http://localhost:3000/login", { waitUntil: "networkidle" });
5+await p.getByLabel(/email/i).fill(process.env.QA_EMAIL);
6+await p.getByLabel(/^password$/i).fill(process.env.QA_PASSWORD);
7+const [resp] = await Promise.all([p.waitForResponse((r) => r.url().includes("/api/auth/sign-in"), { timeout: 15000 }).catch(() => null), p.getByRole("button", { name: /sign in/i }).click()]);
8+console.log("sign-in status:", resp?.status(), (await resp?.text())?.slice(0, 300));
9+await p.waitForTimeout(5000);
10+console.log("url:", p.url());
11+console.log("alerts:", await p.locator('[role=alert], .text-danger').allTextContents());
12+await p.screenshot({ path: "qa/out/login-debug.png" });
13+await b.close();
added qa/picker-debug.mjs +18 −0
@@ -0,0 +1,18 @@
1+import { chromium } from "@playwright/test";
2+const b = await chromium.launch();
3+for (const mobile of [true, false]) {
4+ const ctx = await b.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1280, height: 800 }, isMobile: mobile, hasTouch: mobile, storageState: "qa/.e2e-session.json" });
5+ const p = await ctx.newPage();
6+ p.on("pageerror", (e) => console.log("pageerror:", e.message.slice(0, 300)));
7+ await p.goto("http://localhost:3000/app/chat", { waitUntil: "networkidle" });
8+ await p.getByRole("button", { name: /select model/i }).first().click();
9+ await p.waitForTimeout(1500);
10+ const info = await p.evaluate(() => {
11+ const inner = document.querySelector("#model-selector-list > div");
12+ return { items: document.querySelectorAll("[data-index]").length, totalH: inner?.style.height, innerChildren: inner?.children.length, scrollH: document.querySelector("#model-selector-list")?.scrollHeight, clientH: document.querySelector("#model-selector-list")?.clientHeight };
13+ });
14+ console.log(mobile ? "mobile" : "desktop", JSON.stringify(info));
15+ await p.screenshot({ path: `qa/out/picker-${mobile ? "m" : "d"}.png` });
16+ await ctx.close();
17+}
18+await b.close();
modified qa/responsive-qa.mjs +6 −3
@@ -81,13 +81,16 @@ async function ensureSession(context) {
81 const email = process.env.QA_EMAIL; 81 const email = process.env.QA_EMAIL;
82 const password = process.env.QA_PASSWORD; 82 const password = process.env.QA_PASSWORD;
83 const page = await context.newPage(); 83 const page = await context.newPage();
84− await page.goto(`${BASE}/app/chat`, { waitUntil: "domcontentloaded" }); 84+ await page.goto(`${BASE}/app/chat`, { waitUntil: "networkidle" });
85 if (/\/login/.test(page.url())) { 85 if (/\/login/.test(page.url())) {
86 if (!email || !password) throw new Error("Session expired: set QA_EMAIL and QA_PASSWORD to log in."); 86 if (!email || !password) throw new Error("Session expired: set QA_EMAIL and QA_PASSWORD to log in.");
87 await page.getByLabel(/email/i).fill(email); 87 await page.getByLabel(/email/i).fill(email);
88 await page.getByLabel(/^password$/i).fill(password); 88 await page.getByLabel(/^password$/i).fill(password);
89− await page.getByRole("button", { name: /sign in/i }).click(); 89+ await page.getByRole("button", { name: /sign in/i }).first().click();
90− await page.waitForURL(/\/app/, { timeout: 30_000 }); 90+ const t0 = Date.now();
91+ while (!/\/app/.test(page.url()) && Date.now() - t0 < 30_000) await page.waitForTimeout(500);
92+ if (!/\/app/.test(page.url())) throw new Error(`Login did not reach /app (url=${page.url()})`);
93+ await page.waitForTimeout(800);
91 await context.storageState({ path: stateFile }); 94 await context.storageState({ path: stateFile });
92 } 95 }
93 await page.close(); 96 await page.close();
modified src/app/app/onboarding/onboarding-flow.tsx +1 −1
@@ -580,7 +580,7 @@ function StepPrompt({ model, onPick }: { model?: PolyModel; onPick: (text: strin
580 </ul> 580 </ul>
581 <p className="text-[12.5px] text-fg-subtle"> 581 <p className="text-[12.5px] text-fg-subtle">
582 Or open an empty chat with the button below. Prefer comparing first?{" "} 582 Or open an empty chat with the button below. Prefer comparing first?{" "}
583− <Link href="/app/arena" className="text-accent underline-offset-4 hover:underline"> 583+ <Link href="/app/arena" className="tap py-2 text-accent underline-offset-4 hover:underline">
584 Go to the Arena 584 Go to the Arena
585 </Link> 585 </Link>
586 . 586 .
modified src/components/arena/arena-view.tsx +1 −1
@@ -742,7 +742,7 @@ export function ArenaView() {
742 </Tooltip> 742 </Tooltip>
743 </> 743 </>
744 } 744 }
745− placeholder={selected.length ? `Ask ${selected.length === 1 ? "this model" : `all ${selected.length} models`} the same thing…` : "Pick models, then write one prompt for all of them…"} 745+ placeholder={selected.length ? `Ask ${selected.length === 1 ? "this model" : `all ${selected.length} models`} the same thing…` : isMobile ? "Pick models, then ask…" : "Pick models, then write one prompt for all of them…"}
746 /> 746 />
747 <div className="flex min-h-4 flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-fg-muted"> 747 <div className="flex min-h-4 flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-fg-muted">
748 {runReason ? ( 748 {runReason ? (
modified src/components/arena/model-picker.tsx +1 −1
@@ -55,7 +55,7 @@ export function ArenaModelPicker({ selected, onChange, disabled, className }: {
55 {selected.length}/{MAX_MODELS} 55 {selected.length}/{MAX_MODELS}
56 </span> 56 </span>
57 <span className="hidden h-5 w-px shrink-0 bg-border sm:block" aria-hidden /> 57 <span className="hidden h-5 w-px shrink-0 bg-border sm:block" aria-hidden />
58− <div className="flex shrink-0 items-center gap-1" role="group" aria-label="Quick presets"> 58+ <div className="flex min-w-0 items-center gap-1 overflow-x-auto scrollbar-none" role="group" aria-label="Quick presets">
59 {PRESETS.map((p) => ( 59 {PRESETS.map((p) => (
60 <Tooltip key={p.id} content={p.hint}> 60 <Tooltip key={p.id} content={p.hint}>
61 <button type="button" onClick={() => applyPreset(p.id)} className="tap inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-[12.5px] font-medium text-fg-muted transition-colors hover:bg-bg-muted hover:text-fg"> 61 <button type="button" onClick={() => applyPreset(p.id)} className="tap inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-[12.5px] font-medium text-fg-muted transition-colors hover:bg-bg-muted hover:text-fg">
modified src/components/chat/composer.tsx +3 −1
@@ -317,7 +317,8 @@ export const Composer = React.forwardRef<ComposerHandle, Props>(function Compose
317 {/* + menu */} 317 {/* + menu */}
318 <input ref={fileRef} type="file" multiple hidden accept={fileAccept || undefined} onChange={(e) => { if (e.target.files) void upload(e.target.files); e.target.value = ""; }} /> 318 <input ref={fileRef} type="file" multiple hidden accept={fileAccept || undefined} onChange={(e) => { if (e.target.files) void upload(e.target.files); e.target.value = ""; }} />
319 <input ref={cameraRef} type="file" hidden accept="image/*" capture="environment" onChange={(e) => { if (e.target.files) void upload(e.target.files); e.target.value = ""; }} /> 319 <input ref={cameraRef} type="file" hidden accept="image/*" capture="environment" onChange={(e) => { if (e.target.files) void upload(e.target.files); e.target.value = ""; }} />
320− {leftSlot} 320+ {/* On phones the caller's extra controls move to a toolbar row below so the text field keeps its width. */}
321+ {!isMobile ? leftSlot : null}
321 <PlusMenu items={items} open={menuOpen} onOpenChange={setMenuOpen} disabled={disabled && !extraActions?.length} activeCount={activeCount} isMobile={isMobile} /> 322 <PlusMenu items={items} open={menuOpen} onOpenChange={setMenuOpen} disabled={disabled && !extraActions?.length} activeCount={activeCount} isMobile={isMobile} />
322 323
323 {/* textarea */} 324 {/* textarea */}
@@ -375,6 +376,7 @@ export const Composer = React.forwardRef<ComposerHandle, Props>(function Compose
375 </Button> 376 </Button>
376 )} 377 )}
377 </div> 378 </div>
379+ {isMobile && leftSlot ? <div className="-mt-1 flex items-center gap-1 overflow-x-auto px-2 pb-1.5 scrollbar-none">{leftSlot}</div> : null}
378 </div> 380 </div>
379 {bottomSlot ?? ( 381 {bottomSlot ?? (
380 <p className="mt-1.5 hidden items-center justify-center gap-1 text-center text-[11px] text-fg-subtle sm:flex"> 382 <p className="mt-1.5 hidden items-center justify-center gap-1 text-center text-[11px] text-fg-subtle sm:flex">
modified src/components/chat/model-launcher.tsx +1 −2
@@ -8,7 +8,6 @@ import { cn } from "@/lib/utils";
8 * Opens the shared `ModelSelector` picker from any control (menu item, error card, router card…) 8 * Opens the shared `ModelSelector` picker from any control (menu item, error card, router card…)
9 * while only relying on its public props: the real trigger is rendered visually hidden and clicked. 9 * while only relying on its public props: the real trigger is rendered visually hidden and clicked.
10 * 10 *
11− * TODO(integration: B) — when `ModelSelector` exposes an `open`/`onOpenChange` prop, drop the hidden trigger.
12 */ 11 */
13 export interface ModelPickerLauncherHandle { 12 export interface ModelPickerLauncherHandle {
14 open(): void; 13 open(): void;
@@ -23,7 +22,7 @@ export const ModelPickerLauncher = React.forwardRef<ModelPickerLauncherHandle, {
23 return ( 22 return (
24 <> 23 <>
25 <span ref={holder} className="hidden" aria-hidden> 24 <span ref={holder} className="hidden" aria-hidden>
26− <ModelSelector value={value} onChange={onChange} size="sm" /> 25+ <ModelSelector value={value} onChange={onChange} size="sm" hotkey={false} />
27 </span> 26 </span>
28 {hiddenTrigger ? null : ( 27 {hiddenTrigger ? null : (
29 <Button type="button" variant={variant} size={size} className={cn(className)} onClick={open}> 28 <Button type="button" variant={variant} size={size} className={cn(className)} onClick={open}>
modified src/components/chat/model-selector.tsx +13 −4
@@ -28,6 +28,8 @@ import { cn, formatTokens } from "@/lib/utils";
28 export const ROUTER_MODE_KEY = "polyllm:router-mode"; 28 export const ROUTER_MODE_KEY = "polyllm:router-mode";
29 29
30 export interface ModelSelectorProps { 30 export interface ModelSelectorProps {
31+ /** Register the global ⌘/ toggle (disable on hidden/secondary instances). */
32+ hotkey?: boolean;
31 value: string | null; 33 value: string | null;
32 onChange?: (key: string) => void; 34 onChange?: (key: string) => void;
33 className?: string; 35 className?: string;
@@ -54,7 +56,7 @@ type Item =
54 56
55 const ROW_H = 56; 57 const ROW_H = 56;
56 58
57−export function ModelSelector({ value, onChange, className, size = "md", allowDisconnected = false, multiple, selected, onToggle, buttonLabel, showAuto = false, trigger, draft }: ModelSelectorProps) { 59+export function ModelSelector({ value, onChange, className, size = "md", allowDisconnected = false, multiple, selected, onToggle, buttonLabel, showAuto = false, trigger, draft, hotkey = true }: ModelSelectorProps) {
58 const { models, modelsByKey, favorites, recents, labels, connectedProviders, toggleFavorite, setLabel, loadingModels } = useApp(); 60 const { models, modelsByKey, favorites, recents, labels, connectedProviders, toggleFavorite, setLabel, loadingModels } = useApp();
59 const isMobile = useIsMobile(); 61 const isMobile = useIsMobile();
60 const router = useRouter(); 62 const router = useRouter();
@@ -70,10 +72,17 @@ export function ModelSelector({ value, onChange, className, size = "md", allowDi
70 const [profileModel, setProfileModel] = React.useState<PolyModel | null>(null); 72 const [profileModel, setProfileModel] = React.useState<PolyModel | null>(null);
71 const inputRef = React.useRef<HTMLInputElement>(null); 73 const inputRef = React.useRef<HTMLInputElement>(null);
72 const scrollRef = React.useRef<HTMLDivElement>(null); 74 const scrollRef = React.useRef<HTMLDivElement>(null);
75+ // The list mounts only while the dialog is open: expose the element through state so the virtualizer re-subscribes.
76+ const [scrollEl, setScrollEl] = React.useState<HTMLDivElement | null>(null);
77+ const setScrollRef = React.useCallback((el: HTMLDivElement | null) => {
78+ scrollRef.current = el;
79+ setScrollEl(el);
80+ }, []);
73 const current = value && value !== AUTO_MODEL_KEY ? modelsByKey.get(value) : undefined; 81 const current = value && value !== AUTO_MODEL_KEY ? modelsByKey.get(value) : undefined;
74 const isAuto = value === AUTO_MODEL_KEY; 82 const isAuto = value === AUTO_MODEL_KEY;
75 83
76 React.useEffect(() => { 84 React.useEffect(() => {
85+ if (!hotkey) return;
77 const onKey = (e: KeyboardEvent) => { 86 const onKey = (e: KeyboardEvent) => {
78 if ((e.metaKey || e.ctrlKey) && e.key === "/") { 87 if ((e.metaKey || e.ctrlKey) && e.key === "/") {
79 e.preventDefault(); 88 e.preventDefault();
@@ -82,7 +91,7 @@ export function ModelSelector({ value, onChange, className, size = "md", allowDi
82 }; 91 };
83 window.addEventListener("keydown", onKey); 92 window.addEventListener("keydown", onKey);
84 return () => window.removeEventListener("keydown", onKey); 93 return () => window.removeEventListener("keydown", onKey);
85− }, []); 94+ }, [hotkey]);
86 95
87 React.useEffect(() => { 96 React.useEffect(() => {
88 if (open) { 97 if (open) {
@@ -139,7 +148,7 @@ export function ModelSelector({ value, onChange, className, size = "md", allowDi
139 148
140 const virtualizer = useVirtualizer({ 149 const virtualizer = useVirtualizer({
141 count: items.length, 150 count: items.length,
142− getScrollElement: () => scrollRef.current, 151+ getScrollElement: () => scrollEl,
143 estimateSize: (i) => { 152 estimateSize: (i) => {
144 const it = items[i]; 153 const it = items[i];
145 return it.type === "row" ? ROW_H : it.type === "auto" ? 168 : it.type === "provider" ? 32 : 40; 154 return it.type === "row" ? ROW_H : it.type === "auto" ? 168 : it.type === "provider" ? 32 : 40;
@@ -406,7 +415,7 @@ export function ModelSelector({ value, onChange, className, size = "md", allowDi
406 </div> 415 </div>
407 ) : ( 416 ) : (
408 <div 417 <div
409− ref={scrollRef} 418+ ref={setScrollRef}
410 id="model-selector-list" 419 id="model-selector-list"
411 role="listbox" 420 role="listbox"
412 aria-label="Models" 421 aria-label="Models"
modified src/components/marketing/header.tsx +2 −2
@@ -41,7 +41,7 @@ export function MarketingHeader() {
41 return ( 41 return (
42 <header className="glass sticky top-0 z-50 border-b border-border/80"> 42 <header className="glass sticky top-0 z-50 border-b border-border/80">
43 <div className="mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-4 px-5 sm:px-8"> 43 <div className="mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-4 px-5 sm:px-8">
44− <Link href="/" className="rounded-md" aria-label="PolyLLM home"> 44+ <Link href="/" className="tap rounded-md py-1" aria-label="PolyLLM home">
45 <Logo size={24} /> 45 <Logo size={24} />
46 </Link> 46 </Link>
47 47
@@ -70,7 +70,7 @@ export function MarketingHeader() {
70 <ThemeToggle /> 70 <ThemeToggle />
71 <button 71 <button
72 type="button" 72 type="button"
73− className="inline-flex size-8 items-center justify-center rounded-md text-fg-muted hover:bg-bg-muted hover:text-fg" 73+ className="tap inline-flex size-8 items-center justify-center rounded-md text-fg-muted hover:bg-bg-muted hover:text-fg"
74 aria-expanded={open} 74 aria-expanded={open}
75 aria-controls="mobile-nav" 75 aria-controls="mobile-nav"
76 aria-label={open ? "Close menu" : "Open menu"} 76 aria-label={open ? "Close menu" : "Open menu"}
modified src/components/marketing/theme-toggle.tsx +1 −1
@@ -20,7 +20,7 @@ export function ThemeToggle({ className }: { className?: string }) {
20 type="button" 20 type="button"
21 onClick={() => setTheme(isDark ? "light" : "dark")} 21 onClick={() => setTheme(isDark ? "light" : "dark")}
22 className={cn( 22 className={cn(
23− "inline-flex size-8 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-bg-muted hover:text-fg", 23+ "tap inline-flex size-8 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-bg-muted hover:text-fg",
24 className, 24 className,
25 )} 25 )}
26 aria-label={mounted ? (isDark ? "Switch to light theme" : "Switch to dark theme") : "Toggle theme"} 26 aria-label={mounted ? (isDark ? "Switch to light theme" : "Switch to dark theme") : "Toggle theme"}
modified src/components/ui/button.tsx +4 −4
@@ -20,12 +20,12 @@ const buttonVariants = cva(
20 link: "text-accent underline-offset-4 hover:underline h-auto p-0", 20 link: "text-accent underline-offset-4 hover:underline h-auto p-0",
21 }, 21 },
22 size: { 22 size: {
23− xs: "h-7 px-2 text-xs rounded-sm [&_svg]:size-3.5", 23+ xs: "tap h-7 px-2 text-xs rounded-sm [&_svg]:size-3.5",
24− sm: "h-8 px-2.5 text-[13px]", 24+ sm: "tap h-8 px-2.5 text-[13px]",
25 md: "h-9 px-3.5", 25 md: "h-9 px-3.5",
26 lg: "h-11 px-5 text-[15px] rounded-lg", 26 lg: "h-11 px-5 text-[15px] rounded-lg",
27− icon: "size-8 p-0", 27+ icon: "tap size-8 p-0",
28− "icon-sm": "size-7 p-0 rounded-sm [&_svg]:size-3.5", 28+ "icon-sm": "tap size-7 p-0 rounded-sm [&_svg]:size-3.5",
29 "icon-lg": "size-10 p-0 rounded-lg", 29 "icon-lg": "size-10 p-0 rounded-lg",
30 }, 30 },
31 }, 31 },
modified src/components/ui/segmented.tsx +2 −2
@@ -44,7 +44,7 @@ export function Segmented<T extends string>({ value, onChange, options, size = "
44 disabled={o.disabled} 44 disabled={o.disabled}
45 onClick={() => onChange(o.value)} 45 onClick={() => onChange(o.value)}
46 className={cn( 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", 47+ "tap 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, 48 item,
49 fill && "flex-1", 49 fill && "flex-1",
50 active ? "bg-bg-elevated text-fg shadow-xs" : "text-fg-muted hover:text-fg", 50 active ? "bg-bg-elevated text-fg shadow-xs" : "text-fg-muted hover:text-fg",
@@ -75,7 +75,7 @@ export function ChipRow<T extends string>({ value, onChange, options, className,
75 aria-checked={active} 75 aria-checked={active}
76 onClick={() => onChange(o.value)} 76 onClick={() => onChange(o.value)}
77 className={cn( 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", 78+ "tap 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", 79 active ? "border-fg bg-fg text-bg" : "border-border bg-bg-elevated text-fg-muted hover:border-border-strong hover:text-fg",
80 )} 80 )}
81 > 81 >
modified src/lib/session.ts +2 −1
@@ -11,7 +11,8 @@ export async function getSession() {
11 /** Server components / actions: redirect to login when not authenticated or unverified. */ 11 /** Server components / actions: redirect to login when not authenticated or unverified. */
12 export async function requireUser(): Promise<AuthUser> { 12 export async function requireUser(): Promise<AuthUser> {
13 const session = await getSession(); 13 const session = await getSession();
14− if (!session) redirect("/login"); 14+ // ?expired=1 lets the proxy clear a stale cookie instead of redirecting straight back to /app.
15+ if (!session) redirect("/login?expired=1");
15 if (!session.user.emailVerified) redirect("/verify-email"); 16 if (!session.user.emailVerified) redirect("/verify-email");
16 return session.user; 17 return session.user;
17 } 18 }
modified src/proxy.ts +7 −0
@@ -15,6 +15,13 @@ export function proxy(req: NextRequest) {
15 return NextResponse.redirect(url); 15 return NextResponse.redirect(url);
16 } 16 }
17 if ((pathname === "/login" || pathname === "/signup") && hasSession) { 17 if ((pathname === "/login" || pathname === "/signup") && hasSession) {
18+ // A stale/revoked session cookie: `requireUser()` sent the visitor here with ?expired=1.
19+ // Clear the cookies and show the login page instead of bouncing back to /app forever.
20+ if (req.nextUrl.searchParams.has("expired")) {
21+ const res = NextResponse.next();
22+ for (const c of req.cookies.getAll()) if (/polyllm.*session/.test(c.name)) res.cookies.delete(c.name);
23+ return res;
24+ }
18 const url = req.nextUrl.clone(); 25 const url = req.nextUrl.clone();
19 url.pathname = "/app/chat"; 26 url.pathname = "/app/chat";
20 url.search = ""; 27 url.search = "";
21 28
Menu

Theme