Three things that said one thing and did another

All three shipped in the last two commits, and all three are the same kind of
mistake: an interface that looks right and is not.

The folder settings page could not be scrolled. `.main` is a flex column with
`min-height: 0`, so a `.page` dropped straight into it overflows the viewport
with nothing to scroll -- Save and Back end up below the bottom of the window,
reachable by zooming out or by dragging the prompt textarea up out of the way.
Every other page of this shape already wraps its content in `.admin-scroll`;
this one did not. The two class names that scroll are one rule in admin.css
precisely so this is a wrapper somebody forgot rather than a value they got
wrong, and now it is noted.

The project directory was a text box, on the one screen that asks for an
absolute path on another machine. It is the same button-and-hidden-field the
new-chat screen uses, wired by `[data-dir-field]` in ui.js -- scoped to that
attribute so this and the composer's own handler cannot both answer one click
and open two dialogs. The composer keeps its own because it does more: it
follows the selected profile's default directory until somebody picks their
own, which only means something while a chat is being created. With no
connection chosen it says so rather than opening onto nothing, and Clear is
always there, because browsing somewhere and changing your mind before saving
needs a way back to "no opinion" as much as clearing a saved one does.

And "New chat" did not follow the Chat/Agent switch. The button sits above the
scroll area rather than inside the tree the switch swaps, so it went on saying
"New chat" over a list of agent chats. It moves to its own partial and arrives
out of band, the way the chat title already does. Renaming it to something
neutral would have hidden the bug rather than fixed it, and would have cost the
`?kind=agent` preselection the label is there to explain.

The tests that existed asserted a page load, which re-renders the button
anyway -- which is exactly why nobody saw it. The new ones assert the fragment.
The directory field was driven under a DOM stub first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-04 10:40:01 +02:00
parent 5766446b84
commit 20040f53a8
9 changed files with 241 additions and 31 deletions
+4 -1
View File
@@ -108,7 +108,10 @@ async def set_sidebar_kind(
return templates.TemplateResponse(
request,
"partials/_sidebar_tree.html",
{"chat": None, "user": user, **sidebar_context(db, user)},
# `oob` brings the New chat button along out of band. It sits above the
# scroll area rather than inside the tree, so a swap of the tree alone
# left it saying "New chat" while agent chats were listed underneath.
{"chat": None, "user": user, "oob": True, **sidebar_context(db, user)},
)
+14
View File
@@ -17,6 +17,20 @@
padding: var(--sp-6) var(--sp-5) var(--sp-12);
}
/* A directory chosen rather than typed. The path is the only part allowed to
shrink, and it truncates -- an absolute path on somebody else's machine is
long enough to push the Clear button off the card otherwise. */
.dir-row { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.dir-row > .btn { min-width: 0; }
.dir-row__path {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.page-header { margin-bottom: var(--sp-6); }
.admin-lede,
.page-header__lede {
+44
View File
@@ -598,6 +598,50 @@ document.addEventListener("lembas:notify", function (event) {
setDir(profileDefault());
}
/* The same directory picker, on a form that is not the composer.
Scoped to `[data-dir-field]`, which the composer's own markup does not
carry -- otherwise this and `wire()` above would both answer one click and
open two dialogs. The composer keeps its own handler because it has more to
do: it follows the selected profile's default directory until somebody
chooses their own, which only makes sense while a chat is being created.
A path is something you would rather find than spell, and the text box this
replaced was the one control on the folder page that asked somebody to
remember an absolute path on another machine. */
document.addEventListener("click", function (event) {
var field = event.target.closest("[data-dir-field]");
if (!field) return;
var value = field.querySelector("[data-dir-value]");
var label = field.querySelector("[data-dir-label]");
if (!value) return;
function show(path) {
value.value = path || "";
if (label) label.textContent = path || "the connection's own default";
}
if (event.target.closest("[data-dir-clear]")) {
event.preventDefault();
show("");
return;
}
if (!event.target.closest("[data-dir-browse]")) return;
event.preventDefault();
/* The connection this folder points at. Browsing needs one, and saying so
beats a dialog that opens onto nothing. */
var form = field.closest("form");
var picker = form && form.querySelector('select[name="ssh_profile_id"]');
var profileId = picker ? picker.value : "";
if (!profileId) {
window.lembas.notify("Choose a connection first — there is nothing to browse without one.");
return;
}
window.lembas.chooseDirectory(profileId, value.value, show);
});
function scan() {
document.querySelectorAll("[data-agent-picker]").forEach(function (el) {
if (!el.dataset.wired) { el.dataset.wired = "1"; wire(el); }
+36 -5
View File
@@ -39,6 +39,14 @@
</a>
</header>
{#
`.admin-scroll` is what scrolls, not `.page`. `.main` is a flex column
with `min-height: 0`, so a `.page` dropped straight into it overflows the
viewport with nothing to scroll -- Save and Back end up somewhere below
the bottom of the window, reachable only by zooming out. Every other page
of this shape already wraps its content this way; this one did not.
#}
<div class="admin-scroll">
<div class="page">
<form hx-patch="/api/folders/{{ folder.id }}" hx-swap="none">
<div class="card">
@@ -128,11 +136,33 @@
</select>
</div>
<div class="field">
<label class="field__label" for="folder-dir">Project directory</label>
<input class="input input--mono" type="text" id="folder-dir" name="project_dir"
maxlength="1000" value="{{ folder.project_dir }}"
placeholder="The connection's own default.">
{# A button and a hidden field, not a text box -- the same shape the
new-chat screen uses, and for the same reason it gives there: a
path is something you would rather find than spell. `data-dir-field`
is what wires it; see ui.js. #}
<div class="field" data-dir-field>
<span class="field__label">Project directory</span>
<input type="hidden" name="project_dir"
value="{{ folder.project_dir }}" data-dir-value>
<div class="dir-row">
<button class="btn" type="button" data-dir-browse
aria-label="Choose the project directory">
{{ icon("folder", "icon--sm") }}
<span class="dir-row__path" data-dir-label>
{{ folder.project_dir or "the connection's own default" }}
</span>
</button>
{# Always here, not only when something is stored: browsing to a
directory and then changing your mind before saving needs a way
back to "no opinion" as much as clearing a saved one does. #}
<button class="btn btn--icon" type="button" data-dir-clear
aria-label="Clear the project directory" title="No opinion">
{{ icon("x", "icon--sm") }}
</button>
</div>
<p class="field__hint">
What a relative path is measured from in chats started here.
</p>
</div>
{% if agent_modes %}
@@ -158,6 +188,7 @@
</div>
</form>
</div>
</div>
</main>
</div>
{% endblock %}
@@ -0,0 +1,40 @@
{% from "_macros.html" import icon %}
{#
New chat, and New folder.
Its own partial because "New chat" changes with the Chat/Agent switch and this
row sits *outside* the tree the switch swaps -- above the scroll area, so it
does not scroll away. It arrives out of band instead, the move the `done`
frame already makes for the chat title.
It was not a partial to begin with, and the button went on saying "New chat"
after the switch moved to Agents while the tree beneath it showed agent chats.
Whether it worked was not the question: it said one thing and did another,
which is exactly the shape of failure the switch itself was arranged to avoid.
#}
{% if can.get("chat.create") or can.get("folder.manage") %}
<div class="sidebar__actions" id="sidebar-actions"
{% if oob %}hx-swap-oob="true"{% endif %}>
{% if can.get("chat.create") %}
{# Carries the side the switch is on, so this opens the new-chat screen
already on the right fork. #}
<a class="btn btn--primary btn--grow"
href="/chat{{ '?kind=agent' if sidebar_kind == 'agent' else '' }}">
{{ icon("plus", "icon--sm") }}
{{ "New agent chat" if sidebar_kind == "agent" else "New chat" }}
</a>
{% endif %}
{% if can.get("folder.manage") %}
{# Asks for the name rather than making "New folder" and leaving somebody to
find the rename. `data-prompt` writes the answer into hx-vals before the
request goes out; see ui.js for why this is not htmx's own hx-prompt. #}
<button class="btn btn--icon" hx-post="/api/folders" hx-swap="none"
data-prompt="What should this folder be called?"
data-prompt-title="New folder" data-prompt-field="name"
data-prompt-label="Create"
aria-label="New folder" title="New folder">
{{ icon("folder") }}
</button>
{% endif %}
</div>
{% endif %}
@@ -46,6 +46,19 @@
</div>
{% endif %}
{#
"New chat" becomes "New agent chat" with the switch, and it lives above the
scroll area rather than inside this fragment -- so it comes along out of
band. Only when this is the fragment response: on a full page load the row
is already rendered in its own place, and a second copy marked
`hx-swap-oob` would be a stray element sitting in the tree.
#}
{% if oob %}
{% with oob = true %}
{% include "partials/_sidebar_actions.html" %}
{% endwith %}
{% endif %}
{% if folders %}
<div class="nav-group">
<div class="nav-group__label">Folders</div>
+1 -25
View File
@@ -11,31 +11,7 @@
{{ brand(uid="side") }}
</div>
{% if can.get("chat.create") or can.get("folder.manage") %}
<div class="sidebar__actions">
{% if can.get("chat.create") %}
{# Carries the side the switch is on, so "New chat" on the Agent side opens
the new-chat screen already set to an agent chat. #}
<a class="btn btn--primary btn--grow"
href="/chat{{ '?kind=agent' if sidebar_kind == 'agent' else '' }}">
{{ icon("plus", "icon--sm") }}
{{ "New agent chat" if sidebar_kind == "agent" else "New chat" }}
</a>
{% endif %}
{% if can.get("folder.manage") %}
{# Asks for the name rather than making "New folder" and leaving somebody to
find the rename. `data-prompt` writes the answer into hx-vals before the
request goes out; see ui.js for why this is not htmx's own hx-prompt. #}
<button class="btn btn--icon" hx-post="/api/folders" hx-swap="none"
data-prompt="What should this folder be called?"
data-prompt-title="New folder" data-prompt-field="name"
data-prompt-label="Create"
aria-label="New folder" title="New folder">
{{ icon("folder") }}
</button>
{% endif %}
</div>
{% endif %}
{% include "partials/_sidebar_actions.html" %}
{# Every 10s, refresh the unread dots and announce anything that finished
while this page was showing something else. Out-of-band spans only, so the