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>
18 changed files +289 −27
modified
qa/.e2e-session.json
+4 −4
@@ -2,20 +2,20 @@ | ||
| 2 | 2 | "cookies": [ |
| 3 | 3 | { |
| 4 | 4 | "name": "polyllm.session_token", |
| 5 | − "value": "phK8cA3C9MPwvAPdhy3XoVvFCa1IBoBP.VlG8WlzEnbIlUawskRzIaIhpdLak%2BdYQTP4w0UJjAn8%3D", | |
| 5 | + "value": "ZGYXJUeEBk1wErULioJm4GRFpSQyf66t.bmCgkI83DzBd7kqQuTjPoSbkdTO9mBx0s%2B22rVYpeKo%3D", | |
| 6 | 6 | "domain": "localhost", |
| 7 | 7 | "path": "/", |
| 8 | − "expires": 1791442457.664571, | |
| 8 | + "expires": 1791705485.823704, | |
| 9 | 9 | "httpOnly": true, |
| 10 | 10 | "secure": false, |
| 11 | 11 | "sameSite": "Lax" |
| 12 | 12 | }, |
| 13 | 13 | { |
| 14 | 14 | "name": "polyllm.session_data", |
| 15 | − "value": "eyJzZXNzaW9uIjp7InNlc3Npb24iOnsiZXhwaXJlc0F0IjoiMjAyNi0xMC0wOFQwNjo1NDoxNy42NjFaIiwidG9rZW4iOiJwaEs4Y0EzQzlNUHd2QVBkaHkzWG9WdkZDYTFJQm9CUCIsImNyZWF0ZWRBdCI6IjIwMjYtMDktMDhUMDY6NTQ6MTcuNjYxWiIsInVwZGF0ZWRBdCI6IjIwMjYtMDktMDhUMDY6NTQ6MTcuNjYxWiIsImlwQWRkcmVzcyI6IjAwMDA6MDAwMDowMDAwOjAwMDA6MDAwMDowMDAwOjAwMDA6MDAwMCIsInVzZXJBZ2VudCI6Ik1vemlsbGEvNS4wIChXaW5kb3dzIE5UIDEwLjA7IFdpbjY0OyB4NjQpIEFwcGxlV2ViS2l0LzUzNy4zNiAoS0hUTUwsIGxpa2UgR2Vja28pIENocm9tZS8xNTMuMC44MDEwLjEyIFNhZmFyaS81MzcuMzYiLCJ1c2VySWQiOiJMRlNiN21LWkZJanJVdFpGZzVPU2RFVmt4TFg2VWVvUCIsImlkIjoibzh5TjA4VnpYeXFTZHkwTjJMNU5HSlN1U2poajgyNWsifSwidXNlciI6eyJuYW1lIjoiRTJFIFRlc3RlciIsImVtYWlsIjoiZTJlKzE3ODg4NTA0NTUwNDBAcG9seWxsbS50ZXN0IiwiZW1haWxWZXJpZmllZCI6dHJ1ZSwiaW1hZ2UiOm51bGwsImNyZWF0ZWRBdCI6IjIwMjYtMDktMDhUMDY6NTQ6MTUuOTk5WiIsInVwZGF0ZWRBdCI6IjIwMjYtMDktMDhUMDY6NTQ6MTYuNzE2WiIsInJvbGUiOiJ1c2VyIiwib25ib2FyZGluZ0NvbXBsZXRlZEF0IjpudWxsLCJpZCI6IkxGU2I3bUtaRklqclV0WkZnNU9TZEVWa3hMWDZVZW9QIn0sInVwZGF0ZWRBdCI6MTc4ODg1MDQ1NzY2MywidmVyc2lvbiI6IjEifSwiZXhwaXJlc0F0IjoxNzg4ODUwNzU3NjYzLCJzaWduYXR1cmUiOiJNT2ZCUV95NnljMmlZZzh4T2ZhSzhkdlF5RjFGVTlqQ2xFVHlNeDRucnNVIn0", | |
| 15 | + "value": "eyJzZXNzaW9uIjp7InNlc3Npb24iOnsiZXhwaXJlc0F0IjoiMjAyNi0xMC0xMVQwNzo1ODowNS44MTlaIiwidG9rZW4iOiJaR1lYSlVlRUJrMXdFclVMaW9KbTRHUkZwU1F5ZjY2dCIsImNyZWF0ZWRBdCI6IjIwMjYtMDktMTFUMDc6NTg6MDUuODE5WiIsInVwZGF0ZWRBdCI6IjIwMjYtMDktMTFUMDc6NTg6MDUuODE5WiIsImlwQWRkcmVzcyI6IjAwMDA6MDAwMDowMDAwOjAwMDA6MDAwMDowMDAwOjAwMDA6MDAwMCIsInVzZXJBZ2VudCI6Ik1vemlsbGEvNS4wIChNYWNpbnRvc2g7IEludGVsIE1hYyBPUyBYIDEwXzE1XzcpIEFwcGxlV2ViS2l0LzUzNy4zNiAoS0hUTUwsIGxpa2UgR2Vja28pIEhlYWRsZXNzQ2hyb21lLzE1My4wLjgwMTAuMTIgU2FmYXJpLzUzNy4zNiIsInVzZXJJZCI6IjVzY2tTR0FlSzIwS2VSNG40NW56ZVJHaTdiQm9VcjByIiwiaWQiOiJSRXdZelN4ZzhEZVpBcDdyNXlnbkZkYnVoUlFzZTBnayJ9LCJ1c2VyIjp7Im5hbWUiOiJRQSBNb2JpbGUiLCJlbWFpbCI6InFhLm1vYmlsZUBwb2x5bGxtLnRlc3QiLCJlbWFpbFZlcmlmaWVkIjp0cnVlLCJpbWFnZSI6bnVsbCwiY3JlYXRlZEF0IjoiMjAyNi0wOS0xMVQwNzo1NToyOS4wNjZaIiwidXBkYXRlZEF0IjoiMjAyNi0wOS0xMVQwNzo1NToyOS4wNjZaIiwicm9sZSI6InVzZXIiLCJvbmJvYXJkaW5nQ29tcGxldGVkQXQiOm51bGwsImlkIjoiNXNja1NHQWVLMjBLZVI0bjQ1bnplUkdpN2JCb1VyMHIifSwidXBkYXRlZEF0IjoxNzg5MTEzNDg1ODIyLCJ2ZXJzaW9uIjoiMSJ9LCJleHBpcmVzQXQiOjE3ODkxMTM3ODU4MjIsInNpZ25hdHVyZSI6IjRWWWdDcGV3ZXpfVUhTVE1zV2M4UmRuLWczME02YU5waHZucmNHSUNhakEifQ", | |
| 16 | 16 | "domain": "localhost", |
| 17 | 17 | "path": "/", |
| 18 | − "expires": 1788850757.664602, | |
| 18 | + "expires": 1789113785.823732, | |
| 19 | 19 | "httpOnly": true, |
| 20 | 20 | "secure": false, |
| 21 | 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 | 81 | const email = process.env.QA_EMAIL; |
| 82 | 82 | const password = process.env.QA_PASSWORD; |
| 83 | 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 | 85 | if (/\/login/.test(page.url())) { |
| 86 | 86 | if (!email || !password) throw new Error("Session expired: set QA_EMAIL and QA_PASSWORD to log in."); |
| 87 | 87 | await page.getByLabel(/email/i).fill(email); |
| 88 | 88 | await page.getByLabel(/^password$/i).fill(password); |
| 89 | − await page.getByRole("button", { name: /sign in/i }).click(); | |
| 90 | − await page.waitForURL(/\/app/, { timeout: 30_000 }); | |
| 89 | + await page.getByRole("button", { name: /sign in/i }).first().click(); | |
| 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 | 94 | await context.storageState({ path: stateFile }); |
| 92 | 95 | } |
| 93 | 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 | 580 | </ul> |
| 581 | 581 | <p className="text-[12.5px] text-fg-subtle"> |
| 582 | 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 | 584 | Go to the Arena |
| 585 | 585 | </Link> |
| 586 | 586 | . |
modified
src/components/arena/arena-view.tsx
+1 −1
@@ -742,7 +742,7 @@ export function ArenaView() { | ||
| 742 | 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 | 747 | <div className="flex min-h-4 flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-fg-muted"> |
| 748 | 748 | {runReason ? ( |
modified
src/components/arena/model-picker.tsx
+1 −1
@@ -55,7 +55,7 @@ export function ArenaModelPicker({ selected, onChange, disabled, className }: { | ||
| 55 | 55 | {selected.length}/{MAX_MODELS} |
| 56 | 56 | </span> |
| 57 | 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 | 59 | {PRESETS.map((p) => ( |
| 60 | 60 | <Tooltip key={p.id} content={p.hint}> |
| 61 | 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 | 317 | {/* + menu */} |
| 318 | 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 | 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 | 322 | <PlusMenu items={items} open={menuOpen} onOpenChange={setMenuOpen} disabled={disabled && !extraActions?.length} activeCount={activeCount} isMobile={isMobile} /> |
| 322 | 323 | |
| 323 | 324 | {/* textarea */} |
@@ -375,6 +376,7 @@ export const Composer = React.forwardRef<ComposerHandle, Props>(function Compose | ||
| 375 | 376 | </Button> |
| 376 | 377 | )} |
| 377 | 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 | 380 | </div> |
| 379 | 381 | {bottomSlot ?? ( |
| 380 | 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 | 8 | * Opens the shared `ModelSelector` picker from any control (menu item, error card, router card…) |
| 9 | 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 | 12 | export interface ModelPickerLauncherHandle { |
| 14 | 13 | open(): void; |
@@ -23,7 +22,7 @@ export const ModelPickerLauncher = React.forwardRef<ModelPickerLauncherHandle, { | ||
| 23 | 22 | return ( |
| 24 | 23 | <> |
| 25 | 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 | 26 | </span> |
| 28 | 27 | {hiddenTrigger ? null : ( |
| 29 | 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 | 28 | export const ROUTER_MODE_KEY = "polyllm:router-mode"; |
| 29 | 29 | |
| 30 | 30 | export interface ModelSelectorProps { |
| 31 | + /** Register the global ⌘/ toggle (disable on hidden/secondary instances). */ | |
| 32 | + hotkey?: boolean; | |
| 31 | 33 | value: string | null; |
| 32 | 34 | onChange?: (key: string) => void; |
| 33 | 35 | className?: string; |
@@ -54,7 +56,7 @@ type Item = | ||
| 54 | 56 | |
| 55 | 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 | 60 | const { models, modelsByKey, favorites, recents, labels, connectedProviders, toggleFavorite, setLabel, loadingModels } = useApp(); |
| 59 | 61 | const isMobile = useIsMobile(); |
| 60 | 62 | const router = useRouter(); |
@@ -70,10 +72,17 @@ export function ModelSelector({ value, onChange, className, size = "md", allowDi | ||
| 70 | 72 | const [profileModel, setProfileModel] = React.useState<PolyModel | null>(null); |
| 71 | 73 | const inputRef = React.useRef<HTMLInputElement>(null); |
| 72 | 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 | 81 | const current = value && value !== AUTO_MODEL_KEY ? modelsByKey.get(value) : undefined; |
| 74 | 82 | const isAuto = value === AUTO_MODEL_KEY; |
| 75 | 83 | |
| 76 | 84 | React.useEffect(() => { |
| 85 | + if (!hotkey) return; | |
| 77 | 86 | const onKey = (e: KeyboardEvent) => { |
| 78 | 87 | if ((e.metaKey || e.ctrlKey) && e.key === "/") { |
| 79 | 88 | e.preventDefault(); |
@@ -82,7 +91,7 @@ export function ModelSelector({ value, onChange, className, size = "md", allowDi | ||
| 82 | 91 | }; |
| 83 | 92 | window.addEventListener("keydown", onKey); |
| 84 | 93 | return () => window.removeEventListener("keydown", onKey); |
| 85 | − }, []); | |
| 94 | + }, [hotkey]); | |
| 86 | 95 | |
| 87 | 96 | React.useEffect(() => { |
| 88 | 97 | if (open) { |
@@ -139,7 +148,7 @@ export function ModelSelector({ value, onChange, className, size = "md", allowDi | ||
| 139 | 148 | |
| 140 | 149 | const virtualizer = useVirtualizer({ |
| 141 | 150 | count: items.length, |
| 142 | − getScrollElement: () => scrollRef.current, | |
| 151 | + getScrollElement: () => scrollEl, | |
| 143 | 152 | estimateSize: (i) => { |
| 144 | 153 | const it = items[i]; |
| 145 | 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 | 415 | </div> |
| 407 | 416 | ) : ( |
| 408 | 417 | <div |
| 409 | − ref={scrollRef} | |
| 418 | + ref={setScrollRef} | |
| 410 | 419 | id="model-selector-list" |
| 411 | 420 | role="listbox" |
| 412 | 421 | aria-label="Models" |
modified
src/components/marketing/header.tsx
+2 −2
@@ -41,7 +41,7 @@ export function MarketingHeader() { | ||
| 41 | 41 | return ( |
| 42 | 42 | <header className="glass sticky top-0 z-50 border-b border-border/80"> |
| 43 | 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 | 45 | <Logo size={24} /> |
| 46 | 46 | </Link> |
| 47 | 47 | |
@@ -70,7 +70,7 @@ export function MarketingHeader() { | ||
| 70 | 70 | <ThemeToggle /> |
| 71 | 71 | <button |
| 72 | 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 | 74 | aria-expanded={open} |
| 75 | 75 | aria-controls="mobile-nav" |
| 76 | 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 | 20 | type="button" |
| 21 | 21 | onClick={() => setTheme(isDark ? "light" : "dark")} |
| 22 | 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 | 24 | className, |
| 25 | 25 | )} |
| 26 | 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 | 20 | link: "text-accent underline-offset-4 hover:underline h-auto p-0", |
| 21 | 21 | }, |
| 22 | 22 | size: { |
| 23 | − xs: "h-7 px-2 text-xs rounded-sm [&_svg]:size-3.5", | |
| 24 | − sm: "h-8 px-2.5 text-[13px]", | |
| 23 | + xs: "tap h-7 px-2 text-xs rounded-sm [&_svg]:size-3.5", | |
| 24 | + sm: "tap h-8 px-2.5 text-[13px]", | |
| 25 | 25 | md: "h-9 px-3.5", |
| 26 | 26 | lg: "h-11 px-5 text-[15px] rounded-lg", |
| 27 | − icon: "size-8 p-0", | |
| 28 | − "icon-sm": "size-7 p-0 rounded-sm [&_svg]:size-3.5", | |
| 27 | + icon: "tap size-8 p-0", | |
| 28 | + "icon-sm": "tap size-7 p-0 rounded-sm [&_svg]:size-3.5", | |
| 29 | 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 | 44 | disabled={o.disabled} |
| 45 | 45 | onClick={() => onChange(o.value)} |
| 46 | 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 | 48 | item, |
| 49 | 49 | fill && "flex-1", |
| 50 | 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 | 75 | aria-checked={active} |
| 76 | 76 | onClick={() => onChange(o.value)} |
| 77 | 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 | 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 | 11 | /** Server components / actions: redirect to login when not authenticated or unverified. */ |
| 12 | 12 | export async function requireUser(): Promise<AuthUser> { |
| 13 | 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 | 16 | if (!session.user.emailVerified) redirect("/verify-email"); |
| 16 | 17 | return session.user; |
| 17 | 18 | } |
modified
src/proxy.ts
+7 −0
@@ -15,6 +15,13 @@ export function proxy(req: NextRequest) { | ||
| 15 | 15 | return NextResponse.redirect(url); |
| 16 | 16 | } |
| 17 | 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 | 25 | const url = req.nextUrl.clone(); |
| 19 | 26 | url.pathname = "/app/chat"; |
| 20 | 27 | url.search = ""; |
| 21 | 28 | |