SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%

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 13 days ago (Sep 11, 2026) parent 3d5b2c0

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