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