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 9db4e03795
commit b8c9e9a4aa
3 changed files with 101 additions and 7 deletions
+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