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
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "1.7.0"
__version__ = "1.8.0"
-33
View File
@@ -66,10 +66,6 @@ async def agents_page(request: Request, db: Db, user: AdminUser, saved: bool = F
# reply is allowed to set going on its own, and a nav entry for one
# card would be worse than the near-miss.
"subagents": settings_store.subagents(db),
# And a third group on the same page, for the same reason: a crowd is
# not an agent-chat feature either, but this is where somebody comes to
# find out what one turn is allowed to set going.
"crowd": settings_store.crowd(db),
"saved": saved,
},
)
@@ -115,35 +111,6 @@ async def save_subagents(
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/crowd")
async def save_crowd(
db: Db,
user: AdminUser,
enabled: bool = Form(False),
max_models: int = Form(4),
max_rounds: int = Form(2),
wall_seconds: int = Form(900),
collapse_agreement: bool = Form(False),
) -> Response:
"""Its own route, for the reason `save_subagents` gives above."""
settings_store.update(
db,
{
"enabled": enabled,
# Clamped here as well as on read. Every floor is one: a zero would be
# the feature switched off wearing the switch's clothes, and that is a
# thing to answer in one place.
"max_models": min(max(max_models, 1), 8),
"max_rounds": min(max(max_rounds, 1), 5),
"wall_seconds": min(max(wall_seconds, 60), 7200),
"collapse_agreement": collapse_agreement,
},
key=settings_store.CROWD,
)
log.info("crowd %s by %s", "enabled" if enabled else "disabled", user.email)
return RedirectResponse("/admin/agents?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("")
async def save_agents(
db: Db,
+68
View File
@@ -0,0 +1,68 @@
"""The crowd: several models answering one turn, in any chat.
Its own module because it is its own page, and it is its own page because as a card
on `/admin/agents` it read as an agent-chat feature. It is not one: a crowd works in
an ordinary conversation, and the owner reasonably concluded otherwise from where
the switch was sitting.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, Request, Response, status
from fastapi.responses import RedirectResponse
from lembas.api.deps import AdminUser, Db
from lembas.services import settings_store
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/crowd", tags=["admin-crowd"])
@router.get("")
async def crowd_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
"""Its own page, for the reason its template records: as a card on the Agents
screen it read as an agent-chat feature, which it is not."""
return render(
request,
"admin/crowd.html",
{"crowd": settings_store.crowd(db), "saved": saved},
)
@router.post("")
async def save_crowd(
db: Db,
user: AdminUser,
enabled: bool = Form(False),
max_models: int = Form(4),
max_rounds: int = Form(2),
wall_seconds: int = Form(900),
collapse_agreement: bool = Form(False),
) -> Response:
"""One group, one form, one route.
The bounds are clamped here as well as in `settings_store.crowd`, which is the
same belt-and-braces `save_subagents` in `admin_agents.py` uses: a value posted
past this route -- by an older page, or by hand -- still reads back sane.
"""
settings_store.update(
db,
{
"enabled": enabled,
# Every floor is one: a zero would be the feature switched off
# wearing the switch's clothes.
"max_models": min(max(max_models, 1), 8),
"max_rounds": min(max(max_rounds, 1), 5),
"wall_seconds": min(max(wall_seconds, 60), 7200),
"collapse_agreement": collapse_agreement,
},
key=settings_store.CROWD,
)
log.info("crowd %s by %s", "enabled" if enabled else "disabled", user.email)
return RedirectResponse("/admin/crowd?saved=1", status_code=status.HTTP_303_SEE_OTHER)
+48 -33
View File
@@ -296,6 +296,11 @@ async def start_chat(
scope_on: list[str] = Form(default=[]),
scope_skill_all: list[str] = Form(default=[]),
scope_skill_on: list[str] = Form(default=[]),
# Who else answers, as the crowd menu stood before the first word. There is no
# chat row yet to attach members to, so the choice rides along with the message
# -- the same mechanism the scope switches above use, and the reason the control
# lives inside the composer's form rather than in the topbar.
crowd_model_ids: list[str] = Form(default=[]),
) -> Response:
"""Create a chat from its first message.
@@ -329,6 +334,8 @@ async def start_chat(
skills_off=frozenset(scope_skill_all) - frozenset(scope_skill_on),
)
_apply_crowd(db, chat, user, crowd_model_ids)
_adopt_draft(db, user, draft_id, chat)
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
@@ -1510,6 +1517,45 @@ def _thread_context(db: DBSession, chat: Chat, user: User) -> dict:
}
def _apply_crowd(db: DBSession, chat: Chat, user: User, values: list[str]) -> None:
"""Replace a chat's crowd with the models named, in the order named.
One implementation for both the composer (where the choice rides along with
the first message) and the settings panel, because two would be two places to
forget a rule -- and there are three:
* **Checked against what this person can reach**, never against what exists.
A control checked only in the template is advisory, and a crafted request
walks past it. Same reasoning as the model branch in `update_chat`.
* **Never the chat's own model**, which would answer twice in a row.
* **Capped by `crowd.max_models`**, on the way in as well as on the way out.
The connection is stored beside the id because `Model` is unique on the pair,
and a model offered by two connections is two rows with different capabilities.
"""
from lembas.db.models import CrowdMember
settings = settings_store.crowd(db)
reachable = {
model.model_id: model for model in chat_service.available_models(db, user)
}
wanted: list[str] = []
for value in values:
value = str(value).strip()
if value and value in reachable and value != chat.model_id and value not in wanted:
wanted.append(value)
wanted = wanted[: int(settings["max_models"])]
chat.crowd = [
CrowdMember(
model_id=model_id,
connection_id=reachable[model_id].connection_id,
position=index,
)
for index, model_id in enumerate(wanted)
]
def _messages_after(db: DBSession, message: Message) -> list[Message]:
"""Everything later in this chat than one message.
@@ -2117,39 +2163,8 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
if "crowd_model_ids" in form:
# The same shape as the bases above: one field always sent, so clearing
# every box clears the crowd. Checked against what this person can reach
# rather than against what exists, or the picker is advisory and a crafted
# request walks past it -- the reasoning the model branch carries.
from lembas.db.models import CrowdMember
settings = settings_store.crowd(db)
reachable = {
model.model_id for model in chat_service.available_models(db, user)
}
wanted: list[str] = []
for value in form.getlist("crowd_model_ids"):
value = str(value).strip()
# Never the chat's own model: it would answer twice in a row, which is
# nobody's idea of a second opinion.
if value and value in reachable and value != chat.model_id and value not in wanted:
wanted.append(value)
wanted = wanted[: int(settings["max_models"])]
chat.crowd = [
CrowdMember(
model_id=model_id,
connection_id=next(
(
model.connection_id
for model in chat_service.available_models(db, user)
if model.model_id == model_id
),
None,
),
position=index,
)
for index, model_id in enumerate(wanted)
]
# every box clears the crowd.
_apply_crowd(db, chat, user, form.getlist("crowd_model_ids"))
submitted_params = {name: form[name] for name in _PARAM_RANGES if name in form}
if submitted_params:
+29 -12
View File
@@ -83,7 +83,9 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict:
else []
),
"attached_base_ids": [base.id for base in chat.knowledge_bases] if chat else [],
**_crowd_context(db, user, chat, models),
**_crowd_context(
db, user, chat, models, current.model_id if current is not None else ""
),
# What *this* model takes, not the three every model used to be assumed
# to take. The vocabulary is per model -- gpt-oss has no `xhigh` and
# Bonsai has no `high`, and sending the wrong one does not degrade, it
@@ -197,12 +199,17 @@ def _scope_context(db: DBSession, user: User, chat: Chat | None) -> dict:
}
def _crowd_context(db: DBSession, user: User, chat: Chat | None, models: list) -> dict:
def _crowd_context(
db: DBSession, user: User, chat: Chat | None, models: list, default_model_id: str = ""
) -> dict:
"""Who else could answer in this chat, and what that would cost.
Empty — and the panel then shows nothing rather than an empty control — when
the feature is off, when there is nobody else to add, or on the new-chat
screen, where there is no chat to attach anybody to yet.
Offered on the **new-chat screen as well**, where there is no chat row yet: the
choice rides along with the first message, the way the scope switches do. The
first version of this was per-chat only and therefore invisible to anybody
setting a conversation up — which is how the feature shipped switched on and
unreachable. Empty only when the feature is off or there is nobody else to add,
and then the control is absent rather than being an empty menu.
The cost is spelled out because it is the thing somebody will not have thought
about: a turn is `speakers x rounds x 2 - 1` replies, and on one local endpoint
@@ -211,21 +218,31 @@ def _crowd_context(db: DBSession, user: User, chat: Chat | None, models: list) -
from lembas.services import crowd as crowd_service
settings = settings_store.crowd(db)
if chat is None or not settings["enabled"]:
if not settings["enabled"]:
return {"crowd_available": [], "crowd_member_ids": [], "crowd_skipped": []}
others = [model for model in models if model.model_id != chat.model_id]
members = [
row.model_id
for row in sorted(chat.crowd, key=lambda row: (row.position, row.model_id))
]
# On the new-chat screen the "own" model is whichever one the picker is
# showing, so the list excludes it for the same reason it does in a chat:
# adding it would have it answer twice in a row.
own = chat.model_id if chat is not None else default_model_id
others = [model for model in models if model.model_id != own]
members = (
[
row.model_id
for row in sorted(chat.crowd, key=lambda row: (row.position, row.model_id))
]
if chat is not None
else []
)
reachable = {model.model_id for model in others}
speakers = 1 + len([model_id for model_id in members if model_id in reachable])
rounds = int(settings["max_rounds"])
return {
"crowd_available": others,
"crowd_member_ids": [model_id for model_id in members if model_id in reachable],
"crowd_skipped": crowd_service.unreachable_members(db, chat, user),
"crowd_skipped": (
crowd_service.unreachable_members(db, chat, user) if chat is not None else []
),
# One round is out and back: everybody answers, everybody but the last is
# asked whether they disagree, and the main model closes.
"crowd_replies": max(1, speakers * 2 - 1),
+2
View File
@@ -17,6 +17,7 @@ from lembas.api import (
admin_agents,
admin_audio,
admin_branding,
admin_crowd,
admin_extraction,
admin_images,
admin_models,
@@ -221,6 +222,7 @@ def create_app() -> FastAPI:
app.include_router(admin_suggestions.router)
app.include_router(admin_tools.router)
app.include_router(admin_agents.router)
app.include_router(admin_crowd.router)
app.include_router(push.router)
app.include_router(branding.router)
+8 -5
View File
@@ -1075,12 +1075,15 @@ MESSAGES.update(
"potom nemôže držať kolo otvorené celé poobedie."
),
"Fold away a short \"I agree\" on the way back": (
"Zbaliť krátke „súhlasím“ na cestě späť"
"Zbaliť krátke „súhlasím“ na ceste späť"
),
"Off by default. With it on, each chat's settings panel offers the other models; a chat with none ticked behaves exactly as it always has.": (
"Predvolene vypnuté. Po zapnutí panel nastavení každej konverzácie ponúka "
"ostatné modely; konverzácia bez zaškrtnutého modelu sa chová presne ako "
"vždy."
"Off by default. With it on, every chat's composer offers the other models; a chat with none ticked behaves exactly as it always has.": (
"Predvolene vypnuté. Po zapnutí ponúka pole na písanie v každej "
"konverzácii ostatné modely; konverzácia bez zaškrtnutého modelu sa chová "
"presne ako vždy."
),
"%(models)s models answer each turn, over up to %(rounds)s rounds.": (
"Na každý ťah odpovedá %(models)s modelov, a to najviac v %(rounds)s kolách."
),
"Check for due work every": "Kontrolovať splatnú prácu každých",
"How often it looks": "Ako často sa pozerá",
+3 -1
View File
@@ -260,7 +260,9 @@ a.tabs__tab { text-decoration: none; }
*/
.field-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
/* Both halves of the pair -- see `.grid--2` in app.css. */
min-width: 0;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr));
gap: var(--sp-3);
}
.field-row > .field { margin-bottom: var(--sp-4); }
+10 -2
View File
@@ -384,8 +384,16 @@ input.visually-hidden[type="checkbox"] {
/* Multi-column form layout, one definition. */
.grid { display: grid; gap: var(--sp-4); }
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
.grid--3 { grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); }
/* `min(100%, …)` on every auto-fit track and `min-width: 0` with it, for the
reason `.suggestions` in chat.css sets out at length. The pair is not
optional: `min(100%, …)` stops the track demanding more than the box, and
`min-width: 0` stops the *box* demanding more than its parent -- a grid or
flex item carries `min-width: auto`, which is a min-content floor, and a
floor beats `width`. A stylesheet cannot tell whether one of these grids has
been dropped into a flex parent today, so both go on every one of them.
`tests/test_narrow_grids.py` refuses a track that has only half the pair. */
.grid--2 { min-width: 0; grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); }
.grid--3 { min-width: 0; grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr)); }
/* --- Alerts --------------------------------------------------------------- */
.alert {
+21 -1
View File
@@ -266,7 +266,27 @@
*/
.suggestions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
/* 🚨 `min-width: 0` is what keeps this grid on the screen, and `width: 100%`
alone did not: it is a grid item of `.thread__intro`, so it carries
`min-width: auto`, which for a grid item means *a min-content floor* -- and
min-width beats width. Its min-content size is two cards side by side, so it
rendered 428px wide inside a 390px phone with `width: 100%` set and ignored.
That floor is also why writing the track as `minmax(min(100%, 13rem), 1fr)`
-- the tree's standing rule, and right -- made it *worse* on its own, 428px
to 455px: a percentage is indefinite while the floor is being measured, so
the track fell back to a card's max-content and raised the very number that
was overflowing. The two go together. With the floor removed, `width: 100%`
finally resolves against the 366px column, `min(100%, …)` hands the track
366px to clamp against, and `auto-fit` places one column.
It scrolled `.thread-scroll` rather than the page, which is why a pass
looking for a document that scrolls sideways never saw it: `overflow-y: auto`
makes the other axis scrollable too. Reported on a phone, found by asking
which *element* could scroll and then reading its computed `width` against
its parent's. */
min-width: 0;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
gap: var(--sp-3);
width: 100%;
max-width: 40rem;
@@ -50,6 +50,13 @@
{{ icon("sliders", "icon--sm") }}
<span class="nav-item__label">{{ t("Models") }}</span>
</a>
{# Its own entry rather than a card on Agents, where it started. Sitting
there made it read as an agent-chat feature -- which is what the owner
took it for, reasonably, since that is what the page is called. #}
<a class="nav-item {{ 'is-active' if section == 'crowd' }}" href="/admin/crowd">
{{ icon("users", "icon--sm") }}
<span class="nav-item__label">{{ t("A crowd") }}</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'audio' }}" href="/admin/audio">
{{ icon("speaker", "icon--sm") }}
<span class="nav-item__label">{{ t("Audio") }}</span>
@@ -395,76 +395,6 @@
button was pressed, which is what keeps each group's save handler writing one
key.
#}
{# A third settings group on this page, saved by its own form -- the reason the
Helpers card gives. A crowd is not an agent-chat feature either, but this is the
page somebody opens to find out what one turn may set going. #}
<form method="post" action="/admin/agents/crowd" class="form-grid">
<section class="card">
<h2 class="card__title">{{ t("A crowd") }}</h2>
<p class="field__hint">
A chat can have more than one model in it. The chat's own model answers, then
each of the others in turn; then the order runs <strong>{{ t("backwards") }}</strong>,
each one asked whether it disagrees with anything; and it ends back at the
first, which either closes or sends them round again.
</p>
<div class="alert">
{{ icon("warning", "icon--sm") }}
<span>
One turn costs <strong>{{ t("models × rounds × 2 − 1") }}</strong> replies — four
models over two rounds is fifteen — and on a single local endpoint every
change of speaker also loads a different model. Larger crowds of smaller
models, and sometimes of bigger ones, start going round in circles: that is
what the round limit is for, and it is a limit ordinary work will reach
rather than a runaway backstop.
</span>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true"
{{ 'checked' if crowd.enabled }}>
<span>{{ t("Let a chat have a crowd") }}</span>
</label>
<p class="field__hint">{{ t("Off by default. With it on, each chat's settings panel offers the other models; a chat with none ticked behaves exactly as it always has.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_max_models">{{ t("Most models besides the chat's own") }}</label>
<input class="input" id="crowd_max_models" name="max_models"
type="number" min="1" max="8" step="1" value="{{ crowd.max_models }}">
<p class="field__hint">{{ t("Four is already eight replies a turn at one round each. More voices past that tend to repeat each other rather than add anything.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_max_rounds">{{ t("Most rounds") }}</label>
<input class="input" id="crowd_max_rounds" name="max_rounds"
type="number" min="1" max="5" step="1" value="{{ crowd.max_rounds }}">
<p class="field__hint">{{ t("A round is out and back. Two gives the first model one chance to change its mind after hearing the objections, which is the point of the whole thing; three is where going in circles starts.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_wall_seconds">{{ t("Longest a turn may take") }}</label>
<input class="input" id="crowd_wall_seconds" name="wall_seconds"
type="number" min="60" max="7200" step="30" value="{{ crowd.wall_seconds }}">
<p class="field__hint">{{ t("Across every speaker, not each. A member whose endpoint has stalled cannot then hold the round open all afternoon.") }}</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="collapse_agreement" value="true"
{{ 'checked' if crowd.collapse_agreement }}>
<span>{{ t('Fold away a short "I agree" on the way back') }}</span>
</label>
<p class="field__hint">{{ t("The disagreements are what a crowd is for; a column of bubbles saying nothing is what makes somebody switch it off. The text is still there behind a disclosure.") }}</p>
</div>
<div class="btn-row">
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
</div>
</section>
</form>
<form method="post" action="/admin/agents/subagents" class="form-grid">
<section class="card">
<h2 class="card__title">{{ t("Helpers") }}</h2>
+87
View File
@@ -0,0 +1,87 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "crowd" %}
{% block title %}A crowd - {{ brand.name }}{% endblock %}
{% block heading %}A crowd{% endblock %}
{% block admin_content %}
{#
Its own page rather than a card on Agents, which is where it shipped in 1.6.0.
Sitting there made it read as an agent-chat feature -- the owner took it for one,
reasonably, because that is what the page is called -- and a crowd has nothing to
do with agent chats: it works in any conversation.
#}
<p class="admin-lede">{{ t("Several models answering one turn, in any chat. Not an agent-chat feature: it works in an ordinary conversation, and the control is in the composer beside the tool switches.") }}</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ t("Settings saved.") }}</span></div>
{% endif %}
<form method="post" action="/admin/crowd" class="form-grid">
<section class="card">
<p class="field__hint">
A chat can have more than one model in it. The chat's own model answers, then
each of the others in turn; then the order runs <strong>{{ t("backwards") }}</strong>,
each one asked whether it disagrees with anything; and it ends back at the
first, which either closes or sends them round again.
</p>
<div class="alert">
{{ icon("warning", "icon--sm") }}
<span>
One turn costs <strong>{{ t("models × rounds × 2 − 1") }}</strong> replies — four
models over two rounds is fifteen — and on a single local endpoint every
change of speaker also loads a different model. Larger crowds of smaller
models, and sometimes of bigger ones, start going round in circles: that is
what the round limit is for, and it is a limit ordinary work will reach
rather than a runaway backstop.
</span>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true"
{{ 'checked' if crowd.enabled }}>
<span>{{ t("Let a chat have a crowd") }}</span>
</label>
<p class="field__hint">{{ t("Off by default. With it on, every chat's composer offers the other models; a chat with none ticked behaves exactly as it always has.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_max_models">{{ t("Most models besides the chat's own") }}</label>
<input class="input" id="crowd_max_models" name="max_models"
type="number" min="1" max="8" step="1" value="{{ crowd.max_models }}">
<p class="field__hint">{{ t("Four is already eight replies a turn at one round each. More voices past that tend to repeat each other rather than add anything.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_max_rounds">{{ t("Most rounds") }}</label>
<input class="input" id="crowd_max_rounds" name="max_rounds"
type="number" min="1" max="5" step="1" value="{{ crowd.max_rounds }}">
<p class="field__hint">{{ t("A round is out and back. Two gives the first model one chance to change its mind after hearing the objections, which is the point of the whole thing; three is where going in circles starts.") }}</p>
</div>
<div class="field">
<label class="field__label" for="crowd_wall_seconds">{{ t("Longest a turn may take") }}</label>
<input class="input" id="crowd_wall_seconds" name="wall_seconds"
type="number" min="60" max="7200" step="30" value="{{ crowd.wall_seconds }}">
<p class="field__hint">{{ t("Across every speaker, not each. A member whose endpoint has stalled cannot then hold the round open all afternoon.") }}</p>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="collapse_agreement" value="true"
{{ 'checked' if crowd.collapse_agreement }}>
<span>{{ t('Fold away a short "I agree" on the way back') }}</span>
</label>
<p class="field__hint">{{ t("The disagreements are what a crowd is for; a column of bubbles saying nothing is what makes somebody switch it off. The text is still there behind a disclosure.") }}</p>
</div>
<div class="btn-row">
<button class="btn btn--primary" type="submit">{{ t("Save") }}</button>
</div>
</section>
</form>
{% endblock %}
@@ -293,6 +293,97 @@
</div>
</div>
{% endif %}
{#
Who else answers.
Beside the tool switches rather than buried in Chat settings, and
*inside this form* rather than in the topbar, for one reason each.
The first: somebody deciding who answers is making the same kind of
choice as somebody picking the model, and the first version of this
put it only in the Chat settings panel — behind the ⋯ menu, inside a
chat that already existed. The owner enabled the feature, went
looking, and could not find it. A control nobody can find is a
feature nobody has.
The second: on the new-chat screen there is no chat row to attach
anybody to, so the choice has to *ride along with the first message*
— which means being a field of this form. That is the same mechanism
the scope switches above use, with the same hidden-input trick,
because a browser submits only the ticked boxes and `start_chat`
needs to know which ones were not.
#}
{% if crowd_available %}
<div class="picker picker--up" data-picker>
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
aria-haspopup="menu" aria-expanded="false"
aria-label="{{ t('Crowd') }}" title="{{ t('Crowd') }}">
{{ icon("users") }}
{% if crowd_member_ids %}
<span class="composer__count">{{ crowd_member_ids|length + 1 }}</span>
{% endif %}
</button>
<div class="picker__menu picker__menu--scope" data-picker-menu role="menu"
hidden aria-label="{{ t('Crowd') }}">
<p class="picker__lede">
{{ t("Tick a model to have it answer after this one, then be asked whether it disagrees.") }}
</p>
<p class="picker__group">{{ t("Also answering") }}</p>
{% if chat %}
{# One hidden field for the whole list, always submitted, so
unticking the last box still says something -- an absent checkbox
carries no signal of its own. #}
<input type="hidden" name="crowd_model_ids" value="" form="crowd-form">
{% else %}
<input type="hidden" name="crowd_model_ids" value="">
{% endif %}
{% for model in crowd_available %}
<label class="picker__option picker__option--toggle">
{% if chat %}
{# An existing chat: written at once. The verb is on the checkbox
and not on `#crowd-form`, because htmx binds a trigger to the
annotated element and `change` bubbles through *ancestors* --
which a sibling form is not. `form=` scopes the values, and
only the values: without it the PATCH would carry the
composer's own `content` and `project_dir`, and `update_chat`
answers that with a 409. The same reasoning the agent mode
select below carries. #}
<input type="checkbox" name="crowd_model_ids" value="{{ model.model_id }}"
{{ 'checked' if model.model_id in crowd_member_ids }}
form="crowd-form"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
{% else %}
<input type="checkbox" name="crowd_model_ids" value="{{ model.model_id }}"
{{ 'checked' if model.model_id in crowd_member_ids }}>
{% endif %}
<span class="picker__option-body">
<span class="picker__option-name">{{ model.label }}</span>
{% if model.description %}
<span class="picker__option-note">{{ model.description }}</span>
{% endif %}
</span>
</label>
{% endfor %}
{% if crowd_member_ids %}
{# One sentence and not three, with the numbers as placeholders: a
translation puts the parts in its own order, and two of these
fragments are not sentences in any language. #}
<p class="picker__lede">
{{ t("%(models)s models answer each turn, over up to %(rounds)s rounds.",
models=crowd_member_ids|length + 1, rounds=crowd_rounds) }}
</p>
{% endif %}
{% if crowd_skipped %}
<p class="picker__lede">
{{ t("Skipped, because you cannot reach them any more:") }}
<s>{{ crowd_skipped|join(", ") }}</s>
</p>
{% endif %}
</div>
</div>
{% endif %}
</div>
{#
@@ -502,6 +593,12 @@
hx-patch and not hx-post: there is no POST for a chat, only PATCH, and
htmx shows nothing when a request 405s -- which is how these controls
spent the first half of their lives doing nothing. #}
{% if chat and crowd_available %}
{# Empty, and a sibling of the composer's form rather than inside it. See the
crowd checkboxes above, and `#agent-mode-form` below, for why both halves
of that sentence matter. #}
<form id="crowd-form"></form>
{% endif %}
{% if chat and chat.kind == "agent" %}
<form id="agent-mode-form"></form>
{% endif %}