A crowd you can find, and a phone 65px too narrow

Two reports against 1.6.0 and 1.7.0, both correct.

The crowd worked end to end and was, in practice, not there: the picker was
behind the ⋯ menu of a chat that already existed, and the switch was a card on
the Agents page, which made it read as an agent-chat feature. The picker is now
a button in the composer toolbar on both screens that include it, and on the
new-chat screen the choice rides along with the first message, so a chat can
start as a crowd instead of having to be converted into one. The instance
switch has its own page.

The width bug was the suggestion cards, exactly as reported. `.suggestions`
rendered 455px inside a 366px column, and the tree's standing rule applied on
its own made it worse -- 428px to 455px. A grid item carries `min-width: auto`,
which is a min-content floor, and a floor beats `width: 100%`; the floor is
measured while the percentage is indefinite, so `min(100%, …)` alone sends the
track to a card's max-content. Both halves now go on all four auto-fit grids,
and a test refuses either alone.

It survived four releases of narrow-width checking because the harness never
rendered that screen: `TestClient(app)` runs no lifespan outside a `with` block,
so the startup-seeded cards were missing from every shot ever taken of it. And
its overflow check skipped anything inside a scroller -- right for a table in
its own scroller, blind to the scroller itself, which `overflow-y: auto` makes
scroll sideways too. Both fixed; it now names the box and the child to blame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 20:08:10 +00:00
co-authored by Claude Opus 5
parent a16510aba8
commit ab32c68a8f
18 changed files with 714 additions and 160 deletions
+81
View File
@@ -70,6 +70,87 @@ def _members(db, chat) -> list[str]:
]
# --- Reachable where somebody would look --------------------------------------
#
# The feature shipped in 1.6.0 switched on and unreachable: the only control was
# inside the Chat settings panel, behind the ⋯ menu, in a chat that already
# existed. The owner enabled it, went looking, and reported that there was nothing
# to find. A control nobody can find is a feature nobody has, so these assert the
# two places it has to be rather than the one place it was.
def test_the_composer_offers_the_crowd_in_a_chat(client, db):
"""Beside the tool switches, where the comparable decisions are."""
chat = _chat(db)
page = client.get(f"/chat/{chat.id}").text
assert 'name="crowd_model_ids"' in page
assert 'form="crowd-form"' in page
assert '<form id="crowd-form">' in page
def test_the_composer_offers_the_crowd_before_the_chat_exists(client, db):
"""On the new-chat screen there is no row to attach anybody to, so the choice
rides along with the first message — the mechanism the scope switches use."""
page = client.get("/chat").text
assert 'name="crowd_model_ids"' in page
# Riding along, so no sibling form and no PATCH: the composer's own POST
# carries it.
assert '<form id="crowd-form">' not in page
assert 'value="second-model"' in page
def test_starting_a_chat_with_a_crowd_keeps_it(client, db):
"""The end of that path: the first message creates the chat *and* its crowd."""
from sqlalchemy import select as sa_select
client.post(
"/api/chats/start",
data={
"content": "Who is right?",
"model_id": "main-model",
"crowd_model_ids": ["", "second-model", "third-model"],
},
follow_redirects=False,
)
db.expire_all()
chat = db.scalars(sa_select(Chat).order_by(Chat.created_at.desc())).first()
assert [row.model_id for row in sorted(chat.crowd, key=lambda r: r.position)] == [
"second-model",
"third-model",
]
def test_starting_a_chat_refuses_a_model_the_person_cannot_reach(client, db):
"""The same rule as the panel, in the same one function, so there is nowhere
for the two to disagree."""
from sqlalchemy import select as sa_select
group = Group(name="Wheel")
db.add(group)
restricted = db.scalar(select(Model).where(Model.model_id == "third-model"))
restricted.public = False
restricted.groups = [group]
user = _user(db)
user.role = "user"
db.commit()
client.post(
"/api/chats/start",
data={
"content": "Who is right?",
"model_id": "main-model",
"crowd_model_ids": ["second-model", "third-model"],
},
follow_redirects=False,
)
db.expire_all()
chat = db.scalars(sa_select(Chat).order_by(Chat.created_at.desc())).first()
assert [row.model_id for row in chat.crowd] == ["second-model"]
def test_the_composer_control_is_absent_while_the_feature_is_off(client, db):
settings_store.update(db, {"enabled": False}, key=settings_store.CROWD)
assert 'name="crowd_model_ids"' not in client.get("/chat").text
# --- Choosing -----------------------------------------------------------------
def test_the_panel_offers_the_other_models(client, db):
chat = _chat(db)
+85
View File
@@ -0,0 +1,85 @@
"""A grid that reflows can still overflow a phone, and twice it has.
`repeat(auto-fit, minmax(13rem, 1fr))` puts two 208px cards side by side on a
390px screen: `auto-fit` decides how many columns fit, and a track whose minimum
is a fixed length never gives that minimum up. The tree's standing rule is to
write `minmax(min(100%, 13rem), 1fr)` instead, so the *track* yields rather than
the viewport.
Half of the rule is not the rule. `.suggestions` had `width: 100%` and got the
`min(100%, …)` track, and still rendered 455px wide inside a 366px column -- it
is a grid item, so it carries `min-width: auto`, which for a grid item means a
min-content floor, and a floor beats `width`. Worse, the floor is measured while
the percentage is indefinite, so the track falls back to a card's max-content:
adding `min(100%, …)` on its own moved the overflow from 428px to 455px.
So both halves are asserted here, on every auto-fit grid in the stylesheets. A
stylesheet cannot see whether a given grid is a flex item on some page today or
becomes one next week, and `min-width: 0` costs nothing where it is not needed.
Found on a phone, on the one screen the screenshot harness had never actually
rendered -- `scripts/shoot.py` builds its client without a lifespan, so the
suggestion cards were absent from every shot ever taken of the new-chat screen.
That is fixed there; this file is the cheap half that runs in the suite.
"""
from __future__ import annotations
import re
from pathlib import Path
import lembas
CSS = Path(lembas.__file__).parent / "web/static/css"
# `[^{}]*` cannot cross a brace, so an `@media` prelude never matches and the
# rules nested inside it do.
RULE = re.compile(r"([^{}]*)\{([^{}]*)\}")
COMMENT = re.compile(r"/\*.*?\*/", re.S)
def _auto_grids() -> list[tuple[str, str, dict[str, str]]]:
found = []
for path in sorted(CSS.glob("*.css")):
text = COMMENT.sub("", path.read_text(encoding="utf-8"))
for prelude, body in RULE.findall(text):
if "auto-fit" not in body and "auto-fill" not in body:
continue
declarations = {
part.partition(":")[0].strip(): part.partition(":")[2].strip()
for part in body.split(";")
if ":" in part
}
found.append((path.name, prelude.strip(), declarations))
return found
def test_the_stylesheets_still_have_auto_fit_grids_to_check():
# Otherwise the two tests below pass by finding nothing, which is how a
# coverage test quietly stops covering anything.
assert len(_auto_grids()) >= 4
def test_every_auto_fit_track_can_give_up_its_minimum():
offenders = [
f"{name} {selector}"
for name, selector, declarations in _auto_grids()
for value in [declarations.get("grid-template-columns", "")]
if "minmax(" in value and "min(100%" not in value
]
assert not offenders, (
"an auto-fit track with a fixed minimum overflows a phone; write "
f"minmax(min(100%, X), 1fr): {offenders}"
)
def test_every_auto_fit_grid_drops_its_automatic_minimum_size():
offenders = [
f"{name} {selector}"
for name, selector, declarations in _auto_grids()
if declarations.get("min-width") != "0"
]
assert not offenders, (
"a grid item's `min-width: auto` is a min-content floor and beats "
f"`width`, so `min(100%, …)` alone does not save it: {offenders}"
)