diff --git a/CLAUDE.md b/CLAUDE.md index b6732eb..f1371b2 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 # 1394 tests, ~85s +pytest # 1399 tests, ~87s # 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; @@ -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 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 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 *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, 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 diff --git a/src/lembas/api/preferences.py b/src/lembas/api/preferences.py index 38f392f..24c9ab8 100644 --- a/src/lembas/api/preferences.py +++ b/src/lembas/api/preferences.py @@ -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)}, ) diff --git a/src/lembas/web/static/css/admin.css b/src/lembas/web/static/css/admin.css index 014fcec..44bf28f 100644 --- a/src/lembas/web/static/css/admin.css +++ b/src/lembas/web/static/css/admin.css @@ -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 { diff --git a/src/lembas/web/static/js/ui.js b/src/lembas/web/static/js/ui.js index 0dc410e..1c4f144 100644 --- a/src/lembas/web/static/js/ui.js +++ b/src/lembas/web/static/js/ui.js @@ -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); } diff --git a/src/lembas/web/templates/folders/edit.html b/src/lembas/web/templates/folders/edit.html index 1cf356d..d0cec19 100644 --- a/src/lembas/web/templates/folders/edit.html +++ b/src/lembas/web/templates/folders/edit.html @@ -39,6 +39,14 @@ + {# + `.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. + #} +