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:
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user