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
@@ -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 %}