The composer decides what a chat is, and the topbar stops trying

The mode select in the topbar posted with hx-post against a route that only
answers PATCH, so every change returned 405 and the mode never moved. htmx
shows nothing when a request fails, so the control looked like it worked: the
select stayed where you put it and the server ignored you. It has never worked.

Two more of the same kind. A mode could not be chosen at all until the chat
existed, so reaching Plan meant sending something in Manual first and letting
the model answer under the wrong rules. And the project directory box was real
and submitted, but unlabelled and squeezed to a few characters by the select
beside it, so it read as broken -- which is how it was reported.

So the kind, the connection, the directory and the mode move out of the strip
above the text and into one toolbar row beneath it, where attach and send
already are. The directory becomes a button that opens a browser over SFTP,
because a path is something you would rather find than spell. `scan_dir` is new
beside `list_dir`: a picker has to tell a directory from a file before it can
draw the row, and `list_dir` backs a tool whose contract is a list of names and
must not change under a model mid-conversation.

Browsing is a person clicking, not a model calling, so it does not pass through
policy.py -- the same argument the terminal panel rests on. It does mean Manual
mode has a second exception now.

Also: .chip was two components with one name, and the attachment card won, so
the Chat/Agent pills silently wore its padding. --radius-md was used twice and
declared nowhere, so both fell back to 0. .btn.is-active has been set by
syncToggles since the terminal landed and styled by nothing. Enter-to-send
ignored isComposing, so committing an IME candidate sent the message. The
terminal had five colours of a sixteen-colour palette, with fallbacks from a
palette that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-02 16:44:57 +02:00
parent 621e95d2e3
commit 803d808723
22 changed files with 1449 additions and 276 deletions
+128 -45
View File
@@ -669,43 +669,96 @@
}
.composer__inner { max-width: var(--thread-max-width); margin: 0 auto; }
/* A column: chips on top, then the control row. The chips are inside the form
so their hidden file_ids inputs are submitted with the message. */
/*
A column: chips, then the text across the full width, then one toolbar row.
The text gets its own row rather than sharing one with the buttons, which is
what lets the toolbar hold more than two controls without the input shrinking
to nothing. The chips stay inside the form so their hidden file_ids inputs are
submitted with the message.
*/
.composer__form {
display: flex;
flex-direction: column;
gap: var(--sp-2);
gap: var(--sp-1);
padding: var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--surface);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.composer__row {
display: flex;
gap: var(--sp-1);
align-items: flex-end;
}
/* Attach and send are the same size and sit on the same baseline as the last
line of the textarea, so the control row reads as one object. */
.composer__btn { flex: none; align-self: flex-end; border-radius: var(--radius-full); }
.composer__form:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
box-shadow: var(--ring);
}
.composer__input {
flex: 1;
width: 100%;
min-width: 0;
border: 0;
background: none;
resize: none;
padding: 0.5rem var(--sp-2);
padding: var(--sp-2) var(--sp-2) var(--sp-1);
font-size: var(--text-base);
line-height: var(--leading-normal);
max-height: 20rem;
color: var(--ink);
}
.composer__input:focus { outline: none; }
/* Everything that acts on the message, on one line under it. It wraps rather
than scrolls: on a narrow window the context controls drop to their own row
and attach/send stay where the thumb expects them. */
.composer__toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
}
.composer__tools { display: flex; align-items: center; gap: var(--sp-1); flex: none; }
.composer__actions { display: flex; align-items: center; gap: var(--sp-1); margin-left: auto; }
.composer__context {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
min-width: 0;
}
.composer__agent { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
/* Round, and the same size as each other: attach and send read as one pair
bracketing the row. */
.composer__btn { flex: none; border-radius: var(--radius-full); }
/* The directory, on a new chat. Monospace because it is a path, and it grows
to fit rather than being pinned to a width that truncates every real one. */
.composer__dir { max-width: 16rem; font-family: var(--font-mono); font-weight: 400; }
.composer__dir-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The connection and directory of a chat already under way. Not a control:
update_chat refuses to change either, so showing them as one is honest. */
.composer__where {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
min-width: 0;
max-width: 20rem;
padding: 0 var(--sp-2);
height: var(--control-h-sm);
border-radius: var(--radius-full);
background: var(--surface-hover);
color: var(--ink-muted);
font-size: var(--text-xs);
white-space: nowrap;
}
.composer__where-dir {
font-family: var(--font-mono);
color: var(--ink-faint);
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.composer__hint {
margin: var(--sp-2) 0 0;
font-size: var(--text-xs);
@@ -746,7 +799,12 @@
text-decoration: none;
}
/* --- Attachment chips (composer) ------------------------------------------ */
/* --- Attachment chips (composer) ------------------------------------------
Named `attach-chip` and not `chip`, which is the radio pill above. The two
were both called `.chip` and both set padding, border and max-width, so the
later block won and the Chat/Agent pills silently inherited the padding of
an attachment card. Two components with one name is a collision, not a
family. */
.composer { position: relative; }
.composer__attachments {
@@ -757,7 +815,7 @@
}
.composer__attachments:empty { display: none; }
.chip {
.attach-chip {
display: flex;
align-items: center;
gap: var(--sp-2);
@@ -768,10 +826,10 @@
max-width: 20rem;
font-size: var(--text-sm);
}
.chip--error { border-color: var(--danger); background: var(--danger-soft); }
.chip--error .chip__icon { color: var(--danger); }
.attach-chip--error { border-color: var(--danger); background: var(--danger-soft); }
.attach-chip--error .attach-chip__icon { color: var(--danger); }
.chip__thumb {
.attach-chip__thumb {
display: block;
width: 2.25rem;
height: 2.25rem;
@@ -779,17 +837,17 @@
object-fit: cover;
flex: none;
}
.chip__icon { color: var(--ink-muted); flex: none; display: flex; }
.chip__body { min-width: 0; flex: 1; display: flex; flex-direction: column; }
.chip__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip__meta { font-size: var(--text-xs); color: var(--ink-faint); }
.chip__warning { font-size: var(--text-xs); color: var(--danger); }
.attach-chip__icon { color: var(--ink-muted); flex: none; display: flex; }
.attach-chip__body { min-width: 0; flex: 1; display: flex; flex-direction: column; }
.attach-chip__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.attach-chip__meta { font-size: var(--text-xs); color: var(--ink-faint); }
.attach-chip__warning { font-size: var(--text-xs); color: var(--danger); }
/* --- Drag and drop -------------------------------------------------------- */
.dropzone-overlay {
position: absolute;
inset: 0;
z-index: 5;
z-index: var(--z-raised);
display: none;
flex-direction: column;
align-items: center;
@@ -894,33 +952,58 @@
}
.unread-dot[hidden] { display: none; }
/* --- Agent chats ----------------------------------------------------------- */
/* In the header, not the settings panel: the mode is the difference between
being interrupted and not, and it is looked at constantly. */
.agent-bar { display: flex; align-items: center; gap: var(--sp-2); }
.agent-bar__where {
/* --- Agent chats -----------------------------------------------------------
`.select--sm` and `.input--sm` are defined once, in app.css, at
var(--control-h-sm). A second definition here made every small control on a
chat page 0.15rem taller than the same control anywhere else -- which is
exactly the drift --control-h exists to prevent.
*/
/*
Chat or Agent: one control with two halves, not two buttons that happen to be
adjacent. Radios underneath, because the choice is permanent and mutually
exclusive and should read as a fork -- and because a radio group is what a
screen reader already knows how to announce.
*/
.segmented {
display: inline-flex;
flex: none;
padding: 2px;
border: 1px solid var(--border);
border-radius: var(--radius-full);
background: var(--bg-sunken);
}
.segmented__option { position: relative; display: inline-flex; }
.segmented__option input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
margin: 0;
}
.segmented__option span {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
color: var(--ink-muted);
height: calc(var(--control-h-sm) - 2px);
padding: 0 var(--sp-3);
border-radius: var(--radius-full);
color: var(--ink-faint);
font-size: var(--text-xs);
font-weight: 500;
white-space: nowrap;
max-width: 14rem;
overflow: hidden;
text-overflow: ellipsis;
transition: background var(--transition-fast), color var(--transition-fast);
}
/* Chat or Agent, on the new-chat composer. */
.composer__kind {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
padding: 0 var(--sp-2) var(--sp-2);
.segmented__option input:hover + span { color: var(--ink-muted); }
.segmented__option input:checked + span {
background: var(--surface-raised);
color: var(--ink);
box-shadow: var(--shadow-sm);
}
.segmented__option input:focus-visible + span {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.composer__kind-agent { display: flex; gap: var(--sp-2); flex: 1 1 18rem; min-width: 0; }
.composer__kind-agent .input { flex: 1; min-width: 0; }
.select--sm, .input--sm { height: calc(var(--control-h) - 0.35rem); font-size: var(--text-xs); }
/* --- A plan, and the way to carry it out ----------------------------------- */
.plan {