A directory chip that stopped eating the row

The project directory showed its whole path, which on anything real filled the
chip's 16rem basis and pushed the Manual/Edit/Auto/Plan select off the end of
the composer. It shows the directory's own name now, with the full path in the
tooltip -- the leading directories are the part nobody reads, since what you
check before sending is that you are in `myproject` rather than `myproject-old`.

The hidden field still submits the whole path. Shortening a label must never
shorten a value, and there is a test on the row rather than on the markup for
exactly that.

Three CSS rules hold the row together, and none of them is visible from the
markup. `.composer__agent` needed `min-width: 0`: a flex item will not shrink
below its content without it, so the group refused to give and the *last* child
was what fell off -- which is why the mode select was the thing being cut rather
than the path that was too long. `.composer__dir` is capped, being the only
child here whose content is unbounded; a connection name and a mode are both
short and known. And the mode select is `flex: none`, because it is read and
changed constantly and should never be the thing that scrolls out of reach.

`baseName` driven under node against ten paths, trailing slashes and `/`
included. The topbar's copy of the same path was already capped and truncating,
so it is left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-04 13:17:56 +02:00
parent 48a66a4037
commit c0d6056ec4
4 changed files with 117 additions and 8 deletions
+16 -1
View File
@@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused
lembas secret-key # generate LEMBAS_SECRET_KEY lembas secret-key # generate LEMBAS_SECRET_KEY
lembas create-admin # create or promote an admin lembas create-admin # create or promote an admin
pytest # 1417 tests, ~88s pytest # 1420 tests, ~88s
# PLAN.md tracks what is and is not built # PLAN.md tracks what is and is not built
ruff check . # lint (line length 100) ruff check . # lint (line length 100)
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons; python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
@@ -777,6 +777,21 @@ pins that each one has a row in `SHORTCUTS` — `/help` reads that list, so a ke
missing from it is a key nobody can discover, and that is the direction this missing from it is a key nobody can discover, and that is the direction this
actually rots. actually rots.
**The directory chip shows a name, not a path.** A real project path is long
enough that showing it whole filled the chip's 16rem basis and pushed the mode
select off the end of the row -- and the leading directories are the part nobody
reads: what you check before sending is that you are in `myproject` rather than
`myproject-old`. `setDir` shows `baseName(path)` and puts the whole thing in the
tooltip; the hidden field still submits the full path, because shortening a
label must never shorten a value, and there is a test on the row for that. Three
CSS rules hold the row together and none of them is visible from the markup:
`.composer__agent` needs `min-width: 0` or the group will not shrink below its
content and the *last* child is what falls off, `.composer__dir` is capped
because it is the only child here whose content is unbounded, and the mode
select is `flex: none` because it is the control being read and changed
constantly. The topbar's copy of the same path was already bounded and
truncating, and is left alone.
**The composer's toolbar is one row, always.** It used to wrap, and **The composer's toolbar is one row, always.** It used to wrap, and
`.composer__actions` is last in the DOM with `margin-left: auto` — so the moment `.composer__actions` is last in the DOM with `margin-left: auto` — so the moment
an agent chat added a connection, a directory and a mode, Send and the an agent chat added a connection, a directory and a mode, Send and the
+25 -5
View File
@@ -911,7 +911,17 @@
scrollbar-width: none; scrollbar-width: none;
} }
.composer__context::-webkit-scrollbar { display: none; } .composer__context::-webkit-scrollbar { display: none; }
.composer__agent { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: nowrap; } /* `min-width: 0` because a flex item will not shrink below its content without
it, and this one holds a connection name, a path and a mode. Without it the
whole group refused to shrink and the last child -- the mode select -- was
what fell off the end. */
.composer__agent {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: nowrap;
min-width: 0;
}
/* Floors, not fixed widths: a select narrower than this shows no text at all, /* Floors, not fixed widths: a select narrower than this shows no text at all,
which is worse than the scrolling it was avoiding. */ which is worse than the scrolling it was avoiding. */
@@ -922,14 +932,24 @@
bracketing the row. */ bracketing the row. */
.composer__btn { flex: none; border-radius: var(--radius-full); } .composer__btn { flex: none; border-radius: var(--radius-full); }
/* The directory, on a new chat. Monospace because it is a path, and it grows /* The directory, on a new chat. Monospace because it is a path.
to fit rather than being pinned to a width that truncates every real one. */
It shows the directory's own name rather than the whole path (see `setDir`),
so it needs a fraction of the room it used to and is capped rather than
allowed to grow: at `0 1 16rem` a real project path filled the basis, and the
mode select next to it was pushed off the end of the row. The cap is on this
one because it is the only child here whose content is unbounded -- a
connection name and a mode are both short and known. */
.composer__dir { .composer__dir {
flex: 0 1 16rem; flex: 0 1 auto;
min-width: 5rem; min-width: 4rem;
max-width: 11rem;
font-family: var(--font-mono); font-family: var(--font-mono);
font-weight: 400; font-weight: 400;
} }
/* The mode is a control that is read and changed constantly; it never shrinks
and never scrolls out of reach. It is the thing that was being cut. */
.composer__context select[name="agent_mode"] { flex: none; }
.composer__dir-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .composer__dir-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.composer__hint { .composer__hint {
+20 -2
View File
@@ -542,6 +542,15 @@ document.addEventListener("lembas:notify", function (event) {
exactly what it means. exactly what it means.
*/ */
(function () { (function () {
/* The last segment of a path, with trailing slashes ignored so `/srv/app/`
reads as `app` rather than as nothing. `/` is itself, since it has no name
of its own and "the root" is what somebody means by it. */
function baseName(path) {
var trimmed = String(path || "").replace(/\/+$/, "");
if (!trimmed) return "/";
return trimmed.slice(trimmed.lastIndexOf("/") + 1) || "/";
}
function wire(root) { function wire(root) {
var kind = root.querySelector("#chat-kind"); var kind = root.querySelector("#chat-kind");
var extra = root.querySelector("[data-agent-extra]"); var extra = root.querySelector("[data-agent-extra]");
@@ -551,11 +560,20 @@ document.addEventListener("lembas:notify", function (event) {
if (!kind || !extra) return; if (!kind || !extra) return;
/* The directory is a hidden field plus a button, so the two have to be set /* The directory is a hidden field plus a button, so the two have to be set
together or the button shows one path and the form submits another. */ together or the button shows one path and the form submits another.
The button shows the directory's own name; the full path goes in the
tooltip and, of course, in the field that is submitted. A real project
path is long enough that showing it whole made the button eat the row and
squeeze the mode select beside it, and the leading directories are the
part nobody is reading -- what you check before sending is that you are
in `myproject` rather than `myproject-old`. */
function setDir(value) { function setDir(value) {
if (!dir) return; if (!dir) return;
dir.value = value || ""; dir.value = value || "";
if (dirLabel) dirLabel.textContent = value || "the login directory"; if (dirLabel) dirLabel.textContent = value ? baseName(value) : "the login directory";
var button = dirLabel && dirLabel.closest("[data-dir-browse]");
if (button) button.title = value || "The connection's own login directory";
} }
function sync() { function sync() {
+56
View File
@@ -316,3 +316,59 @@ def test_an_unknown_mode_on_the_row_refreshes_to_manual(client: TestClient, db,
db.commit() db.commit()
agent_session.refresh(db, agent) agent_session.refresh(db, agent)
assert agent.mode == policy.MODE_MANUAL assert agent.mode == policy.MODE_MANUAL
# --- The directory chip on the new-chat screen ------------------------------------
def test_the_directory_is_a_hidden_field_carrying_the_whole_path(
client: TestClient, db, registered
):
"""The button shows the directory's own name so it stops eating the row, and
the mode select beside it stops being cut. What is *submitted* has to stay
the full path -- shortening a label must never shorten a value."""
_agent_chat(db) # gives us an enabled feature and a profile to pick
page = client.get("/chat").text
assert 'name="project_dir"' in page
assert "data-dir-value" in page
assert "data-dir-label" in page
# The hidden field is what the form posts; the button is decoration beside
# it. If the name ever moved onto the button, the label would become the
# value and the shortening would reach the server.
field = control_named(page, "project_dir")
assert field.get("type") == "hidden"
assert "data-dir-value" in field
def test_starting_a_chat_stores_the_full_path(client: TestClient, db, registered):
"""The behaviour behind the markup above, asserted on the row."""
from lembas.db.models import Chat as ChatRow
chat = _agent_chat(db)
deep = "/srv/projects/a-rather-long-project-name/services/worker"
client.post(
"/api/chats/start",
data={
"content": "Hello",
"kind": "agent",
"ssh_profile_id": chat.ssh_profile_id,
"project_dir": deep,
},
)
started = db.scalars(select(ChatRow).where(ChatRow.project_dir != "")).all()
assert deep in [c.project_dir for c in started]
def test_the_composer_row_cannot_be_pushed_apart_by_a_path():
"""Three rules that have to hold together, and none of which is visible from
the markup: the group may shrink, the directory is capped, and the mode
never shrinks. The mode select was what fell off the end of the row."""
from pathlib import Path
import lembas
css = (Path(lembas.__file__).parent / "web/static/css/chat.css").read_text(encoding="utf-8")
agent = css[css.index(".composer__agent {") : css.index(".composer__dir {")]
assert "min-width: 0" in agent, "the group cannot shrink below its content"
assert "max-width" in css[css.index(".composer__dir {") : css.index(".composer__dir-path")]
assert 'select[name="agent_mode"] { flex: none; }' in css