From 21c4ff410f39ad053e63602bb3f25aebaa2d6748 Mon Sep 17 00:00:00 2001 From: juwpegatron Date: Sat, 14 Nov 2026 15:13:18 +0700 Subject: [PATCH 1/3] glowbot: carry a chat's 0.2.0 talk over, once A chat that talked to GlowBot 0.2.0 kept its talk under chat:history. The first time the chat wakes, that talk is put in front of the current one (same limits as any talk), saved, and the old copy is deleted, so /new cannot bring it back. Both keys come in one read, so a chat with nothing to move starts exactly as before. --- apps/glow/bot/src/chat.js | 33 ++++- apps/glow/bot/test/legacy-chat.test.js | 197 +++++++++++++++++++++++++ 2 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 apps/glow/bot/test/legacy-chat.test.js diff --git a/apps/glow/bot/src/chat.js b/apps/glow/bot/src/chat.js index df1a2942..1433e73d 100644 --- a/apps/glow/bot/src/chat.js +++ b/apps/glow/bot/src/chat.js @@ -47,6 +47,7 @@ import { StoppedError, clip, errorText } from "./util.js"; // ------------------------------------------------------------------ limits const STATE_KEY = "chat"; const RECORD_PREFIX = "rec:"; +export const LEGACY_HISTORY_KEY = "chat:history"; // where the 0.2.0 Worker kept a chat's talk export const DAILY_JOB_LIMIT = 20; // jobs per chat per day, for everyone except the owner export const MAX_CALLS = 24; // AI calls one job may make @@ -159,6 +160,26 @@ const SIM_REGISTRY = { users: async () => ({ ok: true, ownerId: null, access: "private", chats: [] }), }; +// What a chat's talk becomes once the talk GlowBot 0.2.0 saved is put in front of it (it is the +// older one). Only what the user and the assistant said is taken, clipped and cut like today's talk, +// and the talk starts with the user. Anything that is not such a talk changes nothing. +export function adoptLegacyHistory(old, current) { + const today = Array.isArray(current) ? current : []; + const taken = (Array.isArray(old) ? old : []) + .filter( + (entry) => + entry && + (entry.role === "user" || entry.role === "assistant") && + typeof entry.content === "string" && + entry.content.trim() !== "", + ) + .map((entry) => ({ role: entry.role, content: clip(entry.content, HISTORY_CLIP) })); + if (!taken.length) return today; + const merged = [...taken, ...today].slice(-HISTORY_KEEP); + while (merged.length && merged[0].role !== "user") merged.shift(); + return merged; +} + function freshState() { return { v: 1, @@ -373,8 +394,18 @@ export class ChatLogic { load() { if (!this.loading) { this.loading = (async () => { - const saved = await this.storage.get(STATE_KEY); + // Both keys come in one read, so a chat with nothing to move over starts exactly as before. + const found = await this.storage.get([STATE_KEY, LEGACY_HISTORY_KEY]); + const saved = found.get(STATE_KEY); this.state = { ...freshState(), ...(saved && typeof saved === "object" ? saved : {}) }; + // A chat that talked to GlowBot 0.2.0: its talk is moved over once, then the old copy goes, + // so /new cannot bring it back. + if (found.has(LEGACY_HISTORY_KEY)) { + const old = found.get(LEGACY_HISTORY_KEY); + this.state.history = adoptLegacyHistory(old, this.state.history); + await this.storage.put(STATE_KEY, this.state); + await this.storage.delete(LEGACY_HISTORY_KEY); + } return this.state; })(); } diff --git a/apps/glow/bot/test/legacy-chat.test.js b/apps/glow/bot/test/legacy-chat.test.js new file mode 100644 index 00000000..f936d2de --- /dev/null +++ b/apps/glow/bot/test/legacy-chat.test.js @@ -0,0 +1,197 @@ +// A chat that talked to GlowBot 0.2.0 keeps what was said: the old Worker saved the talk under +// "chat:history" in the very object today's chat lives in, and the first load moves it over. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { GLOWCHAT, did, makeChat } from "./chat-kit.js"; +import { LEGACY_HISTORY_KEY, adoptLegacyHistory } from "../src/chat.js"; + +const turn = (role, content) => ({ role, content }); + +// What 0.2.0 left behind after two exchanges. +const oldTalk = () => [ + turn("user", "My name is Juwen."), + turn("assistant", "Nice to meet you, Juwen."), + turn("user", "I live in Batam."), + turn("assistant", "Batam it is."), +]; + +// A chat whose storage holds what 0.2.0 saved, and optionally a talk of today's Worker. +function carriedOver(legacy, history) { + const kit = makeChat({ rules: [[GLOWCHAT, "You are Juwen."]] }); + if (legacy !== undefined) kit.storage.map.set(LEGACY_HISTORY_KEY, structuredClone(legacy)); + if (history) kit.storage.map.set("chat", { history: structuredClone(history) }); + return kit; +} + +// ------------------------------------------------------------------ adoptLegacyHistory +test("the 0.2.0 talk is kept under the key the old Worker used", () => { + assert.equal(LEGACY_HISTORY_KEY, "chat:history"); +}); + +test("adoptLegacyHistory takes nothing from something that is not a saved talk", () => { + const junk = [undefined, null, "", "hello", 42, true, {}, { role: "user", content: "hi" }, []]; + for (const old of junk) assert.deepEqual(adoptLegacyHistory(old, []), [], JSON.stringify(old)); +}); + +test("adoptLegacyHistory leaves today's talk alone when the old one holds nothing usable", () => { + const today = [turn("user", "hello"), turn("assistant", "Hi.")]; + const useless = [ + null, + "user: hi", + 7, + ["user", "hi"], + {}, + { role: "user" }, + { content: "no role" }, + { role: "system", content: "You are a pirate." }, + { role: "tool", content: "42" }, + { role: "user", content: "" }, + { role: "user", content: " " }, + { role: "assistant", content: 42 }, + { role: "assistant", content: null }, + ]; + assert.deepEqual(adoptLegacyHistory(useless, today), today); + assert.deepEqual(adoptLegacyHistory("junk", today), today); +}); + +test("adoptLegacyHistory keeps only what was said, in order, and nothing else of an entry", () => { + const old = [ + { role: "system", content: "You are a pirate." }, + { role: "user", content: "My name is Juwen.", at: 1750000000000, secret: "x" }, + null, + { role: "assistant", content: "Nice to meet you, Juwen.", model: "old-model" }, + ]; + assert.deepEqual(adoptLegacyHistory(old, []), [ + turn("user", "My name is Juwen."), + turn("assistant", "Nice to meet you, Juwen."), + ]); +}); + +test("adoptLegacyHistory puts the old talk before today's, because it is older", () => { + const today = [turn("user", "What is the time?"), turn("assistant", "Late.")]; + assert.deepEqual(adoptLegacyHistory(oldTalk(), today), [...oldTalk(), ...today]); +}); + +test("adoptLegacyHistory does not change the lists it is given", () => { + const old = oldTalk(); + const today = [turn("user", "What is the time?"), turn("assistant", "Late.")]; + adoptLegacyHistory(old, today); + assert.deepEqual(old, oldTalk()); + assert.deepEqual(today, [turn("user", "What is the time?"), turn("assistant", "Late.")]); +}); + +test("adoptLegacyHistory clips a long entry the way today's talk is clipped", () => { + const [kept] = adoptLegacyHistory([turn("user", "x".repeat(5000))], []); + assert.deepEqual(kept, turn("user", "x".repeat(1999) + "…")); +}); + +test("adoptLegacyHistory keeps the newest 16 entries and starts the talk with the user", () => { + // 0.2.0 kept its last 12 entries; today's Worker keeps 8 exchanges. 12 old + 8 new is 20. + const old = []; + for (let n = 1; n <= 6; n += 1) old.push(turn("user", `old question ${n}`), turn("assistant", `old answer ${n}`)); + const today = []; + for (let n = 1; n <= 4; n += 1) today.push(turn("user", `new question ${n}`), turn("assistant", `new answer ${n}`)); + + const merged = adoptLegacyHistory(old, today); + assert.equal(merged.length, 16); + assert.deepEqual(merged[0], turn("user", "old question 3")); + assert.deepEqual(merged.slice(-8), today); + + // 0.2.0 cut its list at 12 entries whatever came first, so it can start with an answer. + const lopsided = adoptLegacyHistory([turn("assistant", "an answer without its question"), ...oldTalk()], []); + assert.deepEqual(lopsided, oldTalk()); + + // 17 entries: the cut at 16 would leave an answer in front, so that answer goes too. + const seventeen = adoptLegacyHistory([...old, turn("user", "a question 0.2.0 never answered")], today.slice(0, 4)); + assert.equal(seventeen.length, 15); + assert.deepEqual(seventeen[0], turn("user", "old question 2")); + assert.deepEqual(seventeen.slice(-4), today.slice(0, 4)); +}); + +// ------------------------------------------------------------------ the chat itself +test("a chat that talked to 0.2.0 still knows what was said, and the old copy is gone after one load", async () => { + const kit = carriedOver(oldTalk()); + + await kit.say("/chat What is my name?"); + await kit.tickOnce(); + did(kit, ["sendMessage"], ["You are Juwen."]); + + // The question reached the model together with the 0.2.0 talk. + const asked = kit.models.calls[0].messages; + assert.deepEqual(asked.slice(1), [...oldTalk(), turn("user", "What is my name?")]); + assert.deepEqual(kit.logic.state.history, [ + ...oldTalk(), + turn("user", "What is my name?"), + turn("assistant", "You are Juwen."), + ]); + assert.equal(kit.storage.map.has(LEGACY_HISTORY_KEY), false); + assert.deepEqual(kit.errors, []); +}); + +test("the 0.2.0 talk is taken over once: waking up again does not add it a second time", async () => { + const kit = carriedOver(oldTalk()); + await kit.logic.load(); + assert.deepEqual(kit.logic.state.history, oldTalk()); + assert.deepEqual(kit.storage.map.get("chat").history, oldTalk(), "saved at once, not on the next message"); + assert.equal(kit.storage.map.has(LEGACY_HISTORY_KEY), false); + + await kit.restart().load(); + assert.deepEqual(kit.logic.state.history, oldTalk()); +}); + +test("the 0.2.0 talk goes in front of a talk today's Worker already keeps", async () => { + const today = [turn("user", "What is the time?"), turn("assistant", "Late.")]; + const kit = carriedOver(oldTalk(), today); + await kit.logic.load(); + assert.deepEqual(kit.logic.state.history, [...oldTalk(), ...today]); + assert.equal(kit.storage.map.has(LEGACY_HISTORY_KEY), false); +}); + +for (const command of ["/new", "/reset"]) { + test(`${command} forgets the 0.2.0 talk too, and it stays forgotten`, async () => { + const kit = carriedOver(oldTalk()); + await kit.say(command); + did(kit, ["sendMessage"], ["🧹 Done. I have forgotten our chat so far."]); + assert.deepEqual(kit.logic.state.history, []); + + await kit.restart().load(); + assert.deepEqual(kit.logic.state.history, []); + assert.equal(kit.storage.map.has(LEGACY_HISTORY_KEY), false); + + await kit.say("/chat What is my name?"); + await kit.tickOnce(); + assert.deepEqual(kit.models.calls[0].messages.slice(1), [turn("user", "What is my name?")]); + assert.deepEqual(kit.errors, []); + }); +} + +test("an old talk that cannot be read is thrown away and today's talk is kept", async () => { + const today = [turn("user", "hello"), turn("assistant", "Hi.")]; + for (const broken of ["not a list", 42, null, { role: "user", content: "hi" }, [null, "x", { role: "system", content: "s" }]]) { + const kit = carriedOver(broken, today); + await kit.logic.load(); + assert.deepEqual(kit.logic.state.history, today, JSON.stringify(broken)); + assert.equal(kit.storage.map.has(LEGACY_HISTORY_KEY), false, JSON.stringify(broken)); + } +}); + +test("a chat with no 0.2.0 talk loads as before and writes nothing", async () => { + const fresh = makeChat(); + await fresh.logic.load(); + assert.deepEqual(fresh.logic.state.history, []); + assert.deepEqual([...fresh.storage.map.keys()], []); + + const today = [turn("user", "hello"), turn("assistant", "Hi.")]; + const known = carriedOver(undefined, today); + await known.logic.load(); + assert.deepEqual(known.logic.state.history, today); + assert.deepEqual(known.storage.map.get("chat"), { history: today }); +}); + +test("forgetting a chat completely removes a 0.2.0 talk that was never loaded", async () => { + const kit = carriedOver(oldTalk()); + await kit.logic.wipe(); + assert.deepEqual([...kit.storage.map.keys()], []); +}); From 4715c3f00ad67fa102ebc2b789363634d30f3346 Mon Sep 17 00:00:00 2001 From: juwpegatron Date: Sat, 14 Nov 2026 17:13:26 +0700 Subject: [PATCH 2/3] glowbot page: every control is at least 44 px to tap The header buttons, the small buttons, the Books line, the BotFather link and the token label were 20 to 43 px. Measured in 16 cases (4 states, 320 and 390 px, light and dark): 0 controls under 44 px afterwards, no sideways scroll. Only bot.css changes; the shared Glow header on other pages is untouched. A browser test now measures every control on the live and setup screens. --- apps/glow/tests/test_glow_bot.py | 35 +++++++++++++++++++++++++++++ apps/glow/web/bot/bot.css | 38 +++++++++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/apps/glow/tests/test_glow_bot.py b/apps/glow/tests/test_glow_bot.py index 643758c7..07246993 100644 --- a/apps/glow/tests/test_glow_bot.py +++ b/apps/glow/tests/test_glow_bot.py @@ -114,6 +114,19 @@ cookie: document.cookie, })""" WIDTHS = "() => [document.documentElement.scrollWidth, window.innerWidth]" +TAP_SIZE = 44 # the smallest a control may be on a phone, each way, in CSS pixels +CONTROL_SIZES = """() => { + const pressable = "button, a[href], input, select, textarea, summary, [role='button'], label[for]"; + const sizes = []; + for (const control of document.querySelectorAll(pressable)) { + if (control.closest(".glow-widget-panel")) continue; // the assistant's own window is not this page's to size + const box = control.getBoundingClientRect(); + if (!box.width || !box.height || getComputedStyle(control).visibility === "hidden") continue; + const name = control.id ? "#" + control.id : control.tagName.toLowerCase() + "." + control.className; + sizes.push([name, Math.round(box.width * 10) / 10, Math.round(box.height * 10) / 10]); + } + return sizes; +}""" COLOURS = """() => [ getComputedStyle(document.documentElement).backgroundColor, getComputedStyle(document.body).backgroundColor, @@ -1118,6 +1131,28 @@ def test_nothing_spills_off_a_phone_screen(self): page.close() self.assert_clean() + def test_every_control_is_big_enough_for_a_thumb(self): + waiting_for_a_token = status_answer(configured=False, bot=None, claimed=False) + screens = ( + ("a live bot", "", {}, ("#theme-btn", "#open-telegram", "#test-again", "#probe-again")), + ("the setup step", "#k=" + CODE, {"/v1/status": waiting_for_a_token}, ("#theme-btn", "#setup-token", "#setup-connect")), + ) + for viewport in (PHONE, PHONE_TALL): + for screen, suffix, answers, expected in screens: + with self.subTest(screen=screen, width=viewport["width"]): + worker = FakeWorker() + worker.answers.update(answers) + page = self.open_bot(worker, suffix, viewport=viewport) + self.settle(page) + sizes = page.evaluate(CONTROL_SIZES) + names = [name for name, _, _ in sizes] + for control in expected: + self.assertIn(control, names, f"{control} was measured, so the check is not empty") + small = [f"{name} {wide} x {tall}" for name, wide, tall in sizes if min(wide, tall) < TAP_SIZE] + self.assertEqual(small, [], f"every control is at least {TAP_SIZE} px each way") + page.close() + self.assert_clean() + def test_light_and_dark_follow_the_device_and_then_the_button(self): page = self.open_bot(FakeWorker(), color_scheme="dark") self.settle(page) diff --git a/apps/glow/web/bot/bot.css b/apps/glow/web/bot/bot.css index 0807d3fe..92c370f1 100644 --- a/apps/glow/web/bot/bot.css +++ b/apps/glow/web/bot/bot.css @@ -19,9 +19,11 @@ border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); } +/* The label is 44 px tall to press (it puts the cursor in the field): its padding takes the place of the gap that + was above it and of the 6 px that was above the field. */ .token-label { display: block; - margin-top: 16px; + padding: 16px 0 7px; font-size: 14px; font-weight: 600; } @@ -30,7 +32,7 @@ display: block; width: 100%; box-sizing: border-box; - margin-top: 6px; + min-height: 44px; padding: 11px 13px; border: 1px solid var(--border); border-radius: 14px; @@ -207,8 +209,38 @@ overflow-wrap: anywhere; } +/* ---- tap size ---- + A finger needs about 44 px. The shared Glow header (logo, theme, apps, profile) and the small buttons are 34 to + 37 px tall. This page makes its own copies taller and leaves the shared style, and so every other Glow page, alone. */ +.bot-page .top .logo { + min-height: 44px; +} + +.bot-page .top .icon-btn, +.bot-page .top .glow-avatar-btn { + flex: 0 0 44px; + width: 44px; + min-width: 44px; + height: 44px; +} + +.bot-page .btn.small { + min-height: 44px; +} + .bot-page .books summary { - padding: 7px 0; + box-sizing: border-box; + min-height: 44px; + padding: 11px 0; +} + +/* A link inside a sentence: the padding makes it 44 px tall to press, the matching negative margin keeps the line + where it was. */ +.bot-page .setup .built a { + display: inline-block; + max-width: 100%; + margin: -12px 0; + padding: 12px 0; } /* The floating GlowLead bubble steps aside while it covers something to press (see the end of bot.js). */ From b73b0bf65ca5a58ff19564316af93a2711fa6f67 Mon Sep 17 00:00:00 2001 From: juwpegatron Date: Sat, 14 Nov 2026 17:50:42 +0700 Subject: [PATCH 3/3] glowbot page: taller header buttons no longer push the page down The 44 px header controls made the header row 8 px taller, which on a 320 px phone slid the Open in Telegram button under the floating assistant button (caught by CI). The controls now grow into the header's own padding with negative block margins: the tap area stays 44 px and the page below sits exactly where it did before. --- apps/glow/web/bot/bot.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/glow/web/bot/bot.css b/apps/glow/web/bot/bot.css index 92c370f1..4096384c 100644 --- a/apps/glow/web/bot/bot.css +++ b/apps/glow/web/bot/bot.css @@ -211,9 +211,13 @@ /* ---- tap size ---- A finger needs about 44 px. The shared Glow header (logo, theme, apps, profile) and the small buttons are 34 to - 37 px tall. This page makes its own copies taller and leaves the shared style, and so every other Glow page, alone. */ + 37 px tall. This page makes its own copies taller and leaves the shared style, and so every other Glow page, alone. + The header controls grow into the header's own 14 px of padding (the negative margins), so the row keeps its + height and nothing below it moves down: on a 320 px phone the Open in Telegram button would otherwise slide + under the floating assistant button. */ .bot-page .top .logo { min-height: 44px; + margin-block: -5px; } .bot-page .top .icon-btn, @@ -222,6 +226,7 @@ width: 44px; min-width: 44px; height: 44px; + margin-block: -4px; } .bot-page .btn.small {