Ask several questions on one card
One `ask_user` call can now carry several questions, and they come back in a single submit. Asking one at a time cost a round trip and an interruption each, and by the third you had forgotten the first. Each question becomes an item with its own key; several items share a call index, because they belong to one call and one tool turn has to answer them all. Each answer is quoted beside the question it belongs to -- with four on a card, a bare list would leave the model matching them up by position and sometimes getting it wrong. Options are radios rather than submit buttons, so picking one does not send the form while two other questions are still blank. What you type beats what you picked: someone who writes in the box after clicking an option meant the writing. `_questions_in` also reads the shapes a small model actually sends -- a bare `question` string, a list of plain strings, one object where a list belonged. Getting that wrong costs a whole round trip and shows a card saying nothing. Two test fixes, both mine. `test_posting_a_message_stores_both_turns` raced the background generation it started: against a connection that refuses instantly the reply sometimes won, writing the error and marking the row complete before the assertions could read it. And the generation registry is module-global, so a test that started a reply left an entry -- and a Task belonging to a closed event loop -- for the rest of the session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -411,8 +411,53 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.interaction__item { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.interaction__title { margin: 0; color: var(--ink); font-weight: 500; }
|
||||
.interaction__form { display: flex; flex-direction: column; gap: var(--sp-4); }
|
||||
/* One block per question. Several go on one card and submit together. */
|
||||
.interaction__question {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.interaction__question + .interaction__question {
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.interaction__title { margin: 0; padding: 0; color: var(--ink); font-weight: 500; }
|
||||
.interaction__options { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
||||
.interaction__note { color: var(--ink-faint); font-size: var(--text-xs); }
|
||||
|
||||
/* An option. A radio, so picking one unpicks the last, but shaped like the
|
||||
button it reads as. */
|
||||
.chip { position: relative; display: inline-flex; }
|
||||
.chip input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
.chip span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 var(--sp-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.chip input:hover + span { background: var(--surface-hover); color: var(--ink); }
|
||||
.chip input:checked + span {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-active);
|
||||
color: var(--ink);
|
||||
}
|
||||
.chip input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.interaction__detail {
|
||||
margin: 0;
|
||||
padding: var(--sp-3);
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
{#
|
||||
The reply has stopped and is waiting for you.
|
||||
|
||||
Two shapes, one mechanism: a question the model asked, and (later) a command
|
||||
Two shapes, one mechanism: questions the model asked, and (later) a command
|
||||
waiting to be allowed. Everything shown here is model output and is escaped
|
||||
accordingly -- the question text, the options on the buttons and the command
|
||||
accordingly -- the questions, the options on the buttons and the command
|
||||
itself all came from a model that may have been reading somebody else's file
|
||||
a moment ago.
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
asking. A question that looks like it came from the application is a question
|
||||
people answer with things they would not tell a chatbot.
|
||||
|
||||
Several questions go on ONE card and come back in ONE submit. Asking them one
|
||||
at a time would cost a round trip and an interruption each, and answering the
|
||||
third would mean having forgotten the first.
|
||||
|
||||
hx-swap="none" because the SSE stream clears this card the moment the answer
|
||||
lands; swapping a response in here would fight it.
|
||||
#}
|
||||
@@ -21,47 +25,71 @@
|
||||
<span>The model is asking you</span>
|
||||
</p>
|
||||
|
||||
{% for item in ask.items %}
|
||||
<div class="interaction__item">
|
||||
<p class="interaction__title">{{ item.title }}</p>
|
||||
{% if item.detail %}
|
||||
<pre class="interaction__detail">{{ item.detail }}</pre>
|
||||
{% endif %}
|
||||
{% if item.reason %}
|
||||
<p class="interaction__reason">{{ item.reason }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<form class="interaction__actions"
|
||||
<form class="interaction__form"
|
||||
hx-post="/api/chats/{{ chat_id }}/interaction/{{ ask.id }}"
|
||||
hx-swap="none">
|
||||
|
||||
{% if ask.kind == "question" %}
|
||||
{% for option in ask.options %}
|
||||
<button class="btn" type="submit" name="choice" value="{{ option }}">{{ option }}</button>
|
||||
{% for item in ask.items %}
|
||||
<fieldset class="interaction__question">
|
||||
<legend class="interaction__title">{{ item.title }}</legend>
|
||||
|
||||
{% if item.options %}
|
||||
<div class="interaction__options">
|
||||
{% for option in item.options %}
|
||||
<label class="chip">
|
||||
<input type="radio" name="choice.{{ item.key }}" value="{{ option }}">
|
||||
<span>{{ option }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.allow_free_text %}
|
||||
{# Never type="password". A model talked into asking for a credential
|
||||
must not be handed a field that looks built for one, and a chat
|
||||
transcript is not a place to keep secrets. #}
|
||||
<input class="input" type="text" name="text.{{ item.key }}" autocomplete="off"
|
||||
placeholder="{{ 'Or write your own answer…' if item.options else 'Your answer…' }}">
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
{% if ask.allow_free_text %}
|
||||
{# Never type="password". A model talked into asking for a credential
|
||||
must not be handed a field that looks built for one, and a transcript
|
||||
is not a place to put secrets. #}
|
||||
<div class="interaction__write">
|
||||
<input class="input" type="text" name="text" autocomplete="off"
|
||||
placeholder="Or write your own answer…">
|
||||
|
||||
<div class="interaction__actions">
|
||||
<button class="btn btn--primary" type="submit">
|
||||
{{ icon("send", "icon--sm") }} Answer
|
||||
{{ icon("send", "icon--sm") }}
|
||||
{{ "Answer" if ask.items | length == 1 else "Send answers" }}
|
||||
</button>
|
||||
<span class="interaction__note">
|
||||
{%- if ask.items | length > 1 %}All of them at once. {% endif -%}
|
||||
Leave any blank to skip it.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{% for item in ask.items %}
|
||||
<div class="interaction__question">
|
||||
<p class="interaction__title">{{ item.title }}</p>
|
||||
{% if item.detail %}
|
||||
<pre class="interaction__detail">{{ item.detail }}</pre>
|
||||
{% endif %}
|
||||
{% if item.reason %}
|
||||
<p class="interaction__reason">{{ item.reason }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="interaction__actions">
|
||||
<button class="btn btn--primary" type="submit" name="verdict" value="allow">
|
||||
{{ icon("check", "icon--sm") }} Allow
|
||||
</button>
|
||||
<button class="btn" type="submit" name="verdict" value="allow_always">
|
||||
Always allow this
|
||||
</button>
|
||||
<button class="btn btn--danger" type="submit" name="verdict" value="deny">
|
||||
{{ icon("x", "icon--sm") }} Don't
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<button class="btn btn--primary" type="submit" name="choice" value="allow">
|
||||
{{ icon("check", "icon--sm") }} Allow
|
||||
</button>
|
||||
<button class="btn" type="submit" name="choice" value="allow_always">
|
||||
Always allow this
|
||||
</button>
|
||||
<button class="btn btn--danger" type="submit" name="choice" value="deny">
|
||||
{{ icon("x", "icon--sm") }} Don't
|
||||
</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user