diff --git a/CLAUDE.md b/CLAUDE.md index df55d8d..ddd68a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused lembas secret-key # generate LEMBAS_SECRET_KEY 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 ruff check . # lint (line length 100) 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 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 `.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 diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index 6d3f27f..3e1d6e1 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -911,7 +911,17 @@ scrollbar-width: 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, which is worse than the scrolling it was avoiding. */ @@ -922,14 +932,24 @@ 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. */ +/* The directory, on a new chat. Monospace because it is a path. + + 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 { - flex: 0 1 16rem; - min-width: 5rem; + flex: 0 1 auto; + min-width: 4rem; + max-width: 11rem; font-family: var(--font-mono); 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__hint { diff --git a/src/lembas/web/static/js/ui.js b/src/lembas/web/static/js/ui.js index 1c4f144..5a1d608 100644 --- a/src/lembas/web/static/js/ui.js +++ b/src/lembas/web/static/js/ui.js @@ -542,6 +542,15 @@ document.addEventListener("lembas:notify", function (event) { exactly what it means. */ (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) { var kind = root.querySelector("#chat-kind"); var extra = root.querySelector("[data-agent-extra]"); @@ -551,11 +560,20 @@ document.addEventListener("lembas:notify", function (event) { if (!kind || !extra) return; /* 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) { if (!dir) return; 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() { diff --git a/tests/test_agent_mode.py b/tests/test_agent_mode.py index fdb8b16..7fc5f77 100644 --- a/tests/test_agent_mode.py +++ b/tests/test_agent_mode.py @@ -316,3 +316,59 @@ def test_an_unknown_mode_on_the_row_refreshes_to_manual(client: TestClient, db, db.commit() agent_session.refresh(db, agent) 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