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:
@@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused
|
|||||||
lembas secret-key # generate LEMBAS_SECRET_KEY
|
lembas secret-key # generate LEMBAS_SECRET_KEY
|
||||||
lembas create-admin # create or promote an admin
|
lembas create-admin # create or promote an admin
|
||||||
|
|
||||||
pytest # 1394 tests, ~85s
|
pytest # 1399 tests, ~87s
|
||||||
# PLAN.md tracks what is and is not built
|
# PLAN.md tracks what is and is not built
|
||||||
ruff check . # lint (line length 100)
|
ruff check . # lint (line length 100)
|
||||||
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
|
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
|
||||||
@@ -838,11 +838,31 @@ folder of contracts into the window would cost the context on every request
|
|||||||
forever to answer one question. It therefore needs an existing chat, so it is
|
forever to answer one question. It therefore needs an existing chat, so it is
|
||||||
absent on the new-chat screen — the same reason project files are.
|
absent on the new-chat screen — the same reason project files are.
|
||||||
|
|
||||||
|
**A page that uses `.page` needs `.admin-scroll` around it.** `.main` is a flex
|
||||||
|
column with `min-height: 0`, so content dropped straight into it overflows the
|
||||||
|
viewport with nothing to scroll — Save ends up below the bottom of the window,
|
||||||
|
reachable only by zooming out. `settings.html` gets this from `.tabs__body` and
|
||||||
|
the admin pages from `.admin-scroll`; the folder settings page shipped without
|
||||||
|
either. The two class names are one rule in `admin.css` for that reason.
|
||||||
|
|
||||||
|
**A path is chosen, not typed.** `[data-dir-field]` in `ui.js` is the directory
|
||||||
|
picker on a form that is not the composer, scoped to that attribute so it 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.
|
||||||
|
|
||||||
**The sidebar shows one kind at a time.** `Chat.kind` distinguishes an agent
|
**The sidebar shows one kind at a time.** `Chat.kind` distinguishes an agent
|
||||||
chat everywhere except the one place a person looked. The switch is stored on
|
chat everywhere except the one place a person looked. The switch is stored on
|
||||||
the account, and three things about it are not the obvious version. It lives
|
the account, and three things about it are not the obvious version. It lives
|
||||||
*inside* the fragment it swaps, or the two buttons would go on showing the side
|
*inside* the fragment it swaps, or the two buttons would go on showing the side
|
||||||
you had just left. `Folder.shown_in` hides a folder the filter emptied and keeps
|
you had just left — and "New chat", which sits *above* the scroll area rather
|
||||||
|
than in the tree, comes along out of band
|
||||||
|
(`partials/_sidebar_actions.html`, rendered with `oob` only by the fragment
|
||||||
|
route). That one shipped broken: the button went on saying "New chat" over a
|
||||||
|
list of agent chats. Whether it *worked* was never the question — it said one
|
||||||
|
thing and did another, which is the shape of failure the switch itself was
|
||||||
|
arranged to avoid. `Folder.shown_in` hides a folder the filter emptied and keeps
|
||||||
one that was empty to begin with — the second is a container somebody just made,
|
one that was empty to begin with — the second is a container somebody just made,
|
||||||
and hiding it means it can never be found again, let alone filed into. And with
|
and hiding it means it can never be found again, let alone filed into. And with
|
||||||
agent chats switched off there is no switch and no filtering at all, rather than
|
agent chats switched off there is no switch and no filtering at all, rather than
|
||||||
|
|||||||
@@ -108,7 +108,10 @@ async def set_sidebar_kind(
|
|||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"partials/_sidebar_tree.html",
|
"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)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,20 @@
|
|||||||
padding: var(--sp-6) var(--sp-5) var(--sp-12);
|
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); }
|
.page-header { margin-bottom: var(--sp-6); }
|
||||||
.admin-lede,
|
.admin-lede,
|
||||||
.page-header__lede {
|
.page-header__lede {
|
||||||
|
|||||||
@@ -598,6 +598,50 @@ document.addEventListener("lembas:notify", function (event) {
|
|||||||
setDir(profileDefault());
|
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() {
|
function scan() {
|
||||||
document.querySelectorAll("[data-agent-picker]").forEach(function (el) {
|
document.querySelectorAll("[data-agent-picker]").forEach(function (el) {
|
||||||
if (!el.dataset.wired) { el.dataset.wired = "1"; wire(el); }
|
if (!el.dataset.wired) { el.dataset.wired = "1"; wire(el); }
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
</a>
|
</a>
|
||||||
</header>
|
</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">
|
<div class="page">
|
||||||
<form hx-patch="/api/folders/{{ folder.id }}" hx-swap="none">
|
<form hx-patch="/api/folders/{{ folder.id }}" hx-swap="none">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -128,11 +136,33 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
{# A button and a hidden field, not a text box -- the same shape the
|
||||||
<label class="field__label" for="folder-dir">Project directory</label>
|
new-chat screen uses, and for the same reason it gives there: a
|
||||||
<input class="input input--mono" type="text" id="folder-dir" name="project_dir"
|
path is something you would rather find than spell. `data-dir-field`
|
||||||
maxlength="1000" value="{{ folder.project_dir }}"
|
is what wires it; see ui.js. #}
|
||||||
placeholder="The connection's own default.">
|
<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>
|
</div>
|
||||||
|
|
||||||
{% if agent_modes %}
|
{% if agent_modes %}
|
||||||
@@ -158,6 +188,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if folders %}
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
<div class="nav-group__label">Folders</div>
|
<div class="nav-group__label">Folders</div>
|
||||||
|
|||||||
@@ -11,31 +11,7 @@
|
|||||||
{{ brand(uid="side") }}
|
{{ brand(uid="side") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if can.get("chat.create") or can.get("folder.manage") %}
|
{% include "partials/_sidebar_actions.html" %}
|
||||||
<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 %}
|
|
||||||
|
|
||||||
{# Every 10s, refresh the unread dots and announce anything that finished
|
{# 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
|
while this page was showing something else. Out-of-band spans only, so the
|
||||||
|
|||||||
@@ -290,6 +290,58 @@ def test_the_settings_page_renders_what_is_stored(client: TestClient, db, regist
|
|||||||
assert f'hx-patch="/api/folders/{folder.id}"' in page
|
assert f'hx-patch="/api/folders/{folder.id}"' in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_settings_page_can_be_scrolled(client: TestClient, db, registered):
|
||||||
|
"""`.main` is a flex column with `min-height: 0`, so a `.page` dropped
|
||||||
|
straight into it overflows the viewport with nothing to scroll and Save ends
|
||||||
|
up below the bottom of the window. Every other page of this shape wraps its
|
||||||
|
content in the scrolling container; this one did not."""
|
||||||
|
folder = _folder(db, "Errands")
|
||||||
|
page = client.get(f"/folders/{folder.id}").text
|
||||||
|
assert 'class="admin-scroll"' in page
|
||||||
|
assert page.index('class="admin-scroll"') < page.index('class="page"')
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_project_directory_is_chosen_rather_than_typed(
|
||||||
|
client: TestClient, db, registered
|
||||||
|
):
|
||||||
|
"""The same control the new-chat screen uses, and for the reason it gives
|
||||||
|
there: a path on another machine is something you would rather find than
|
||||||
|
spell."""
|
||||||
|
_add_connection(db)
|
||||||
|
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
|
||||||
|
db.add(
|
||||||
|
SshProfile(
|
||||||
|
owner_id=db.scalars(select(User).order_by(User.created_at)).first().id,
|
||||||
|
name="Box",
|
||||||
|
host="example.test",
|
||||||
|
username="root",
|
||||||
|
host_key="ssh-ed25519 AAAA",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
folder = _folder(db, "Errands", project_dir="/srv/project")
|
||||||
|
|
||||||
|
page = client.get(f"/folders/{folder.id}").text
|
||||||
|
assert "data-dir-field" in page
|
||||||
|
assert "data-dir-browse" in page
|
||||||
|
# The hidden field is what the form actually submits, so it must carry the
|
||||||
|
# stored value -- the button beside it only shows it.
|
||||||
|
assert 'name="project_dir"' in page
|
||||||
|
assert 'value="/srv/project"' in page
|
||||||
|
assert 'type="hidden"' in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_directory_can_still_be_cleared(client: TestClient, db, registered):
|
||||||
|
"""A hidden input always submits, so an empty one means "no opinion" rather
|
||||||
|
than "leave it alone" -- which is the whole reason `update_folder` reads the
|
||||||
|
raw form."""
|
||||||
|
_add_connection(db)
|
||||||
|
folder = _folder(db, "Errands", project_dir="/srv/project")
|
||||||
|
client.patch(f"/api/folders/{folder.id}", data={"project_dir": ""})
|
||||||
|
db.refresh(folder)
|
||||||
|
assert folder.project_dir == ""
|
||||||
|
|
||||||
|
|
||||||
def test_the_settings_page_refuses_someone_elses_folder(client: TestClient, db, registered):
|
def test_the_settings_page_refuses_someone_elses_folder(client: TestClient, db, registered):
|
||||||
other = User(name="Sam", email="sam@shire.test", password_hash="x")
|
other = User(name="Sam", email="sam@shire.test", password_hash="x")
|
||||||
db.add(other)
|
db.add(other)
|
||||||
|
|||||||
@@ -77,6 +77,43 @@ def test_an_unknown_kind_is_refused_rather_than_stored(client: TestClient, db, r
|
|||||||
assert "sidebar_kind" not in (user.settings_json or {})
|
assert "sidebar_kind" not in (user.settings_json or {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_new_chat_button_follows_the_switch(client: TestClient, db, registered):
|
||||||
|
"""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 -- saying one thing and doing another, which is the shape of
|
||||||
|
failure the switch itself was arranged to avoid.
|
||||||
|
|
||||||
|
Asserted on the *fragment*, not on a page load: a page load re-renders the
|
||||||
|
button anyway, which is exactly why this went unnoticed.
|
||||||
|
"""
|
||||||
|
_add_connection(db)
|
||||||
|
_enable_agents(db)
|
||||||
|
|
||||||
|
response = client.post("/api/preferences/sidebar-kind", data={"kind": "agent"})
|
||||||
|
assert 'id="sidebar-actions"' in response.text
|
||||||
|
assert 'hx-swap-oob="true"' in response.text
|
||||||
|
assert "New agent chat" in response.text
|
||||||
|
assert 'href="/chat?kind=agent"' in response.text
|
||||||
|
|
||||||
|
response = client.post("/api/preferences/sidebar-kind", data={"kind": "chat"})
|
||||||
|
assert "New agent chat" not in response.text
|
||||||
|
assert 'href="/chat"' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_page_load_carries_no_stray_out_of_band_row(
|
||||||
|
client: TestClient, db, registered
|
||||||
|
):
|
||||||
|
"""`hx-swap-oob` on a full page load would be a duplicate element sitting in
|
||||||
|
the tree, waiting to be swapped by the next unrelated request."""
|
||||||
|
_add_connection(db)
|
||||||
|
_enable_agents(db)
|
||||||
|
|
||||||
|
page = client.get("/chat").text
|
||||||
|
assert page.count('id="sidebar-actions"') == 1
|
||||||
|
assert 'id="sidebar-actions"\n hx-swap-oob' not in page
|
||||||
|
assert "hx-swap-oob" not in page.split('id="sidebar-tree"')[1][:2000]
|
||||||
|
|
||||||
|
|
||||||
def test_the_switch_survives_a_page_load(client: TestClient, db, registered):
|
def test_the_switch_survives_a_page_load(client: TestClient, db, registered):
|
||||||
_add_connection(db)
|
_add_connection(db)
|
||||||
_enable_agents(db)
|
_enable_agents(db)
|
||||||
|
|||||||
Reference in New Issue
Block a user