diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py index 7cb8534..076d707 100644 --- a/src/lembas/__init__.py +++ b/src/lembas/__init__.py @@ -1,3 +1,3 @@ """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" -__version__ = "0.8.0" +__version__ = "0.8.1" diff --git a/src/lembas/api/agents.py b/src/lembas/api/agents.py index f67072e..1f49485 100644 --- a/src/lembas/api/agents.py +++ b/src/lembas/api/agents.py @@ -199,7 +199,12 @@ async def profile_page( @router.get("/api/agents/{profile_id}/browse") async def browse_profile( - request: Request, db: Db, user: RequiredUser, profile_id: str, path: str = "" + request: Request, + db: Db, + user: RequiredUser, + profile_id: str, + path: str = "", + pick: str = "dir", ): """One directory on the far side, as a fragment the picker swaps in. @@ -242,6 +247,11 @@ async def browse_profile( "parent": _parent_of(here), "entries": entries, "error": error, + # Whether a file is a choice or only something to look at. The + # directory picker wants the folder you are standing in; Canvas + # wants the file you click. One listing, because a second copy is a + # second place for the path arithmetic to be got subtly differently. + "pick": "file" if pick == "file" else "dir", }, ) diff --git a/src/lembas/api/canvas.py b/src/lembas/api/canvas.py index 3562de6..2c67661 100644 --- a/src/lembas/api/canvas.py +++ b/src/lembas/api/canvas.py @@ -116,6 +116,12 @@ async def _panel( "conflict": conflict, "mine": mine, "canvas_agent": canvas_service.agent_ready(db, user, chat) is not None, + # What the "Open a file" dialog browses. The endpoint it calls is + # hung off the profile rather than the chat, so the button has to + # carry the profile -- and the directory it should start in, or it + # opens at the account's home and every path is a walk from there. + "agent_profile_id": chat.ssh_profile_id or "", + "agent_dir": chat.project_dir or "", }, ) diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index 1b3d401..192ef5f 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -151,6 +151,8 @@ def _new_chat( project_dir: str = "", agent_mode: str = "", reasoning_effort: str = "", + scope_off: frozenset[str] = frozenset(), + skills_off: frozenset[str] = frozenset(), ) -> Chat: """Create a chat row, resolving which model it should use. @@ -241,6 +243,24 @@ def _new_chat( chat.params_json = {k: v for k, v in chat.params_json.items() if k != "reasoning_effort"} elif wanted_effort in chat_service.EFFORTS: chat.params_json = {**chat.params_json, "reasoning_effort": wanted_effort} + + # What the scope menu was set to before the first word. Only the *off* ones + # are written, because absent means on and one representation of "on" is + # what makes "why is this off?" have a single answer. + # + # This narrows and can never widen: `resolve_tools` applies `scope_json` + # after the capability, permission and instance gates, so a crafted request + # naming a gate that was never offered switches off something that was not + # on -- which is exactly nothing. That is why these need no validation + # against the offered set here. + scoped = dict.fromkeys(scope_off, False) + scoped_skills = dict.fromkeys(skills_off, False) + if scoped or scoped_skills: + chat.scope_json = { + **(chat.scope_json or {}), + **({"families": scoped} if scoped else {}), + **({"skills": scoped_skills} if scoped_skills else {}), + } db.add(chat) db.commit() return chat @@ -261,6 +281,15 @@ async def start_chat( agent_mode: str = Form(""), reasoning_effort: str = Form(""), draft_id: str = Form(""), + # The scope menu, as it stood before the first word. `scope_all` names every + # gate the menu drew and is always submitted; `scope_on` names only the + # ticked ones, because that is all a browser sends. The difference is what + # was switched off -- see the note in `chat/_composer.html` for why the + # control is not simply inverted. + scope_all: list[str] = Form(default=[]), + scope_on: list[str] = Form(default=[]), + scope_skill_all: list[str] = Form(default=[]), + scope_skill_on: list[str] = Form(default=[]), ) -> Response: """Create a chat from its first message. @@ -284,6 +313,8 @@ async def start_chat( project_dir=project_dir, agent_mode=agent_mode, reasoning_effort=reasoning_effort, + scope_off=frozenset(scope_all) - frozenset(scope_on), + skills_off=frozenset(scope_skill_all) - frozenset(scope_skill_on), ) _adopt_draft(db, user, draft_id, chat) diff --git a/src/lembas/api/pages.py b/src/lembas/api/pages.py index e25dd7b..69461ac 100644 --- a/src/lembas/api/pages.py +++ b/src/lembas/api/pages.py @@ -85,19 +85,49 @@ def _chat_context(db: DBSession, user: User, chat: Chat | None) -> dict: def _scope_context(db: DBSession, user: User, chat: Chat | None) -> dict: """What this chat may use, for the menu that narrows it. - Only for an existing chat: there is no row to write to before one exists, - and a menu whose choices went nowhere would be worse than no menu. The - families listed are the ones actually offered *right now*, so the menu never - shows a switch for something the model, the reader's permissions or the - instance has already ruled out -- turning that on would do nothing, since - `resolve_tools` applies this after the gates. + The families listed are the ones actually offered *right now*, so the menu + never shows a switch for something the model, the reader's permissions or + the instance has already ruled out -- turning that on would do nothing, + since `resolve_tools` applies this after the gates. + + **It works before the chat exists**, and that is not a nicety. The whole + point of narrowing is to decide what a conversation may reach, and the first + turn is the one where it matters most: the harness puts a tool's guidance in + front of the model the moment the tool is offered, so by the time a chat + existed to switch anything off, the model had already been told how to keep + notes and been given the tools to do it. Switching it off afterwards does + not un-send that turn. + + It used to say there was no row to write to. There is not -- so the + prospective menu writes nothing: its switches are plain checkboxes submitted + with the first message, and `start_chat` turns them into `scope_json` on the + row it is about to create. `scope_allow` stays empty because nothing can + have been allowed yet. + + The stand-in `Chat` is `agent/draft.py:as_chat`'s trick again: `resolve_tools` + reads the kind, the model and the scope off a chat and never queries or + writes it, so a row that is constructed and never added satisfies it + unchanged. `scope_json` is set explicitly because it is a *column* default, + applied at flush, and this one is never flushed. """ from lembas.services import tool_labels from lembas.services import tools as tools_service from lembas.services.library import skills as skills_service - if chat is None: - return {"scope_families": [], "scope_skills": [], "scope_allow": []} + prospective = chat is None + if prospective: + model_id = "" + chosen = chat_service.default_model(db, user) + if chosen is not None: + model_id = chosen[0] + if not model_id: + return {"scope_families": [], "scope_skills": [], "scope_allow": []} + # An ordinary chat, deliberately, even though the kind can still be + # switched on this screen: an agent chat's tools depend on a connection + # that is not settled until the chat is created, so offering them here + # would be a switch for something that may not be offered. Everything a + # plain chat can reach is switchable, which is the part that matters. + chat = Chat(user_id=user.id, kind=KIND_CHAT, model_id=model_id, scope_json={}) off = tools_service.scoped_off(chat) skills_off = tools_service.scoped_skills_off(chat) @@ -142,6 +172,11 @@ def _scope_context(db: DBSession, user: User, chat: Chat | None) -> dict: "scope_families": families, "scope_skills": skills, "scope_allow": list(tools_service.scoped_allow(chat)), + # Which of the two menus to draw: switches that POST at once, or + # switches that ride along with the first message. The template asks + # this rather than `chat is None`, so the reason is named where the + # difference is. + "scope_prospective": prospective, } diff --git a/src/lembas/services/prompts.py b/src/lembas/services/prompts.py index fd3479f..4bada37 100644 --- a/src/lembas/services/prompts.py +++ b/src/lembas/services/prompts.py @@ -1651,9 +1651,13 @@ BUILTIN: tuple[Fragment, ...] = ( ' "start": an ISO timestamp for the first (or only) run.\n' ' "every": one of {"minutes": n}, {"hours": n}, {"days": n}, ' '{"weeks": n} — a plain timer.\n' - ' "at": {"weekdays": [0-6, Monday is 0], "days": [1-31], ' - '"months": [1-12], "times": ["HH:MM"]} — a calendar. Leave a list out ' - "to mean every one of them.\n" + ' "at": {"weekdays": [...], "days": [1-31], "months": [1-12], ' + '"times": ["HH:MM"]} — a calendar. Leave a list out to mean every one ' + "of them.\n" + " Weekdays are numbered Monday=0, Tuesday=1, Wednesday=2, " + "Thursday=3, Friday=4, Saturday=5, Sunday=6. Count them off rather " + "than guessing: naming the wrong day is the one mistake here that " + "still looks like a working schedule.\n" ' "count": how many times in total, if they said a number.\n' ' "until": an ISO timestamp to stop after, if they gave one.\n' "\n" diff --git a/src/lembas/services/schedule/compile.py b/src/lembas/services/schedule/compile.py index 568c5f5..2cfeb6c 100644 --- a/src/lembas/services/schedule/compile.py +++ b/src/lembas/services/schedule/compile.py @@ -135,20 +135,37 @@ async def compile_request( "max_tokens": MAX_TOKENS, "temperature": 0.2, } - try: - raw = await complete(endpoint, body) - except LLMError as exc: - log.info("schedule compile failed: %s", exc) - return Compiled( - instruction=plain, - title=plain[:80], - reason="The model could not be reached, so fill this in yourself.", - ) - # A model that thinks inline puts its reasoning in `content`, which is the - # field `complete` hands back verbatim -- the same trap auto-titling hit. - answered, _ = strip_reasoning(raw) - payload = _payload(answered) + # Asked twice before giving up, and only when the *reply* was unusable. + # Measured against a 4B model on this machine: the prompt itself is sound -- + # ten realistic requests compiled ten times over, twice -- but roughly one + # call in six came back empty or truncated, which a local runner swapping + # models under the request will do. One retry costs a second on a screen + # somebody is already waiting at, and turns "fill this in yourself" from + # something seen regularly into something seen rarely. + # + # Deliberately not retried on an LLMError: an endpoint that refused the + # connection will refuse it again, and the reader is better served by the + # form than by waiting twice for the same answer. + payload: dict = {} + for attempt in range(2): + try: + raw = await complete(endpoint, body) + except LLMError as exc: + log.info("schedule compile failed: %s", exc) + return Compiled( + instruction=plain, + title=plain[:80], + reason="The model could not be reached, so fill this in yourself.", + ) + # A model that thinks inline puts its reasoning in `content`, which is + # the field `complete` hands back verbatim -- the trap auto-titling hit. + answered, _ = strip_reasoning(raw) + payload = _payload(answered) + if payload: + break + log.info("schedule compile produced no JSON (attempt %s)", attempt + 1) + if not payload: return Compiled( instruction=plain, diff --git a/src/lembas/services/schedule/rule.py b/src/lembas/services/schedule/rule.py index a29e1cf..8bba154 100644 --- a/src/lembas/services/schedule/rule.py +++ b/src/lembas/services/schedule/rule.py @@ -452,6 +452,55 @@ def _duration(delta: timedelta) -> str: return f"{minutes} minute{'s' if minutes != 1 else ''}" +def _weekday_phrase(days: list[int]) -> str: + """Weekdays as somebody would say them, or "" for no constraint. + + Monday-to-Friday collapses because that is what a person means and what a + model writes when they say "every weekday" -- and five names in a row is the + commonest thing this function produces otherwise. All seven is no constraint + at all, and saying so is how "every day" comes out of a rule that named them. + """ + chosen = set(days or []) + if not chosen or chosen == set(WEEKDAYS): + return "" + if chosen == {0, 1, 2, 3, 4}: + return "weekday" + return _join([_DAY_NAMES[day] for day in sorted(chosen)]) + + +def _calendar_phrase(at: dict) -> str: + """How often a calendar rule comes round, in words that parse. + + Worth the length. This is what the setup screen echoes back before anything + is saved, what the list page shows beside each schedule, and what the model + is told about its own chat -- so it is the reader's only view of a decision + taken while they were not looking. It used to build a phrase by joining + fragments, which read "Every the 1st at 09:00" for the single commonest + monthly schedule there is, and "Every of January" for a month with no day. + A row nobody can parse is one nobody checks. + """ + weekdays = _weekday_phrase(at.get("weekdays") or []) + days = at.get("days") or [] + months = at.get("months") or [] + month_names = _join([_MONTH_NAMES[month - 1] for month in months]) + + if days: + # A day of the month is the subject; the month, if any, qualifies it. + where = month_names or "each month" + lead = f"On the {_join([_ordinal(day) for day in days])} of {where}" + # Both set is an AND and is rare. Said plainly rather than smoothed into + # something that reads like an OR. + return f"{lead}, if it is a {weekdays}" if weekdays else lead + + if weekdays == "weekday": + lead = "Every weekday" + elif weekdays: + lead = f"Every {weekdays}" + else: + lead = "Every day" + return f"{lead} in {month_names}" if month_names else lead + + def describe(rule: dict, *, zone: tzinfo) -> str: """One line saying what this rule does, in the reader's own zone. @@ -471,17 +520,7 @@ def describe(rule: dict, *, zone: tzinfo) -> str: parts: list[str] = [] if at: - times = _join(list(at.get("times") or [])) - when = [] - if at.get("weekdays"): - when.append(_join([_DAY_NAMES[day] for day in at["weekdays"]])) - if at.get("days"): - when.append(f"the {_join([_ordinal(day) for day in at['days']])}") - if at.get("months"): - when.append(f"of {_join([_MONTH_NAMES[month - 1] for month in at['months']])}") - parts.append( - f"Every {' '.join(when)} at {times}" if when else f"Every day at {times}" - ) + parts.append(f"{_calendar_phrase(at)} at {_join(list(at.get('times') or []))}") # A stride over a calendar is a qualifier rather than a rewording: # "Every Monday at 15:00, skipping to every 14 days" is clumsy but true, # and inventing "every other Monday" for it would be a phrase that stops diff --git a/src/lembas/web/static/css/admin.css b/src/lembas/web/static/css/admin.css index b26510a..bab1f55 100644 --- a/src/lembas/web/static/css/admin.css +++ b/src/lembas/web/static/css/admin.css @@ -517,3 +517,13 @@ a.tabs__tab { text-decoration: none; } .schedule-repeat [data-repeat] { display: none; } .schedule-repeat:has(input[value="every"]:checked) [data-repeat="every"] { display: block; } .schedule-repeat:has(input[value="calendar"]:checked) [data-repeat="calendar"] { display: block; } + +/* + A row of buttons above a list. `.btn-row` carries no bottom margin -- it is + used inside forms where `.field` provides the rhythm -- so "Add a workflow" + sat flush against the first row of the list it adds to, and the two read as + one control. Stated as an adjacency rather than a margin on the button row, + because the row is right to have none everywhere else it appears. +*/ +.btn-row + .model-rows, +.btn-row + .model-list { margin-top: var(--sp-4); } diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css index 722cffe..cdf4952 100644 --- a/src/lembas/web/static/css/app.css +++ b/src/lembas/web/static/css/app.css @@ -428,6 +428,11 @@ button, input, textarea, select { display: flex; flex-direction: column; gap: var(--sp-1); + /* Meets the composer's top border on the other side of the sidebar edge. + See `--footer-height`; `justify-content` keeps the rows at the bottom when + the reader's permissions leave fewer of them than the token allows for. */ + min-height: var(--footer-height); + justify-content: flex-end; } .sidebar__tools { display: flex; align-items: center; gap: var(--sp-1); } @@ -684,13 +689,25 @@ body.is-resizing .canvas__body { pointer-events: none; } /* One row, always. It scrolls sideways rather than wrapping -- the same rule the composer's toolbar is built around, and for the same reason: a strip that wraps to three lines takes the file with it. */ +/* + A tab strip read as an editor's rather than as a row of pills. + + Three things do that and none of them is decoration. The strip has a sunken + background so the tabs sit *on* something; the tabs square off at the bottom + and meet it; and the active one takes the body's own background with its + bottom border removed, so it joins the file below rather than floating above + it. Without that last part a strip of equally-shaded pills says which tab is + selected only by a slight tint, which is exactly the thing nobody can see in + a theme they did not choose. +*/ .canvas__tabs { display: flex; flex: none; - gap: var(--sp-1); - padding: var(--sp-1) var(--sp-2); + gap: 1px; + padding: var(--sp-1) var(--sp-2) 0; overflow-x: auto; scrollbar-width: thin; + background: var(--bg-sunken); border-bottom: 1px solid var(--border); } .canvas__tab { @@ -698,12 +715,22 @@ body.is-resizing .canvas__body { pointer-events: none; } align-items: center; flex: none; max-width: 14rem; - border-radius: var(--radius-sm); + /* Square at the bottom: a tab is attached to what it opens. */ + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + border: 1px solid transparent; + border-bottom: 0; + /* The strip's own bottom border is 1px; this covers it for the active tab + without moving anything, so the row does not shift by a pixel on switch. */ + margin-bottom: -1px; + padding-bottom: 1px; background: transparent; - transition: background var(--transition-fast); + transition: background var(--transition-fast), color var(--transition-fast); } .canvas__tab:hover { background: var(--surface); } -.canvas__tab.is-active { background: var(--surface-raised); } +.canvas__tab.is-active { + background: var(--bg); + border-color: var(--border); +} .canvas__tab-open { display: inline-flex; align-items: center; @@ -754,13 +781,6 @@ body.is-resizing .canvas__body { pointer-events: none; } padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); } -.canvas__open-form { display: flex; gap: var(--sp-2); min-width: 0; flex: 1; } -.canvas__path { - min-width: 0; - flex: 1; - height: var(--control-h-sm); - font-size: var(--text-xs); -} .canvas__body { flex: 1; diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index 5c49cce..a3e30e5 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -920,6 +920,14 @@ padding: var(--sp-3) var(--sp-5) var(--sp-4); border-top: 1px solid var(--border); background: var(--bg); + /* Lifted to meet the sidebar footer's top border, so the two read as one + line across the shell rather than as one that has been broken at the + sidebar's edge. See `--footer-height`. A column ending at `flex-end` so the + extra height opens above the box and the input stays where the hand is. */ + min-height: var(--footer-height); + display: flex; + flex-direction: column; + justify-content: flex-end; } /* position: relative anchors the `@` and `/` menu to the box. */ .composer__inner { diff --git a/src/lembas/web/static/css/tokens.css b/src/lembas/web/static/css/tokens.css index 13790d4..daf5698 100644 --- a/src/lembas/web/static/css/tokens.css +++ b/src/lembas/web/static/css/tokens.css @@ -82,6 +82,23 @@ --thread-max-width: 48rem; --header-height: 3.5rem; + /* What `--header-height` does at the top of the shell, this does at the + bottom. The sidebar's footer and the composer sit either side of the same + vertical line, and both were content-sized -- so the two top borders met + the sidebar's edge at different heights and read as one line that had been + broken. Neither could be made to match the other by accident: the footer's + height depends on which entries the reader's permissions allow, and the + composer's on how much they have typed. + + A calc of the pieces the footer is actually built from -- four rows at + `--control-h`, the gaps between them, and its own padding -- so it stays + true if those tokens move. Applied as a `min-height` to both: it holds the + footer at full height even for somebody who sees fewer entries, and lifts + the composer to meet it. A composer that grows past this as somebody types + is expected; the input is getting bigger, and nothing is pretending the + sidebar should follow it. */ + --footer-height: calc(4 * var(--control-h) + 3 * var(--sp-1) + 2 * var(--sp-2)); + /* The terminal's own type. xterm holds this as a number rather than reading it from CSS, so terminal.js parses it back out -- it must stay a plain pixel value. */ diff --git a/src/lembas/web/static/js/app.js b/src/lembas/web/static/js/app.js index 8c0129f..9cd6722 100644 --- a/src/lembas/web/static/js/app.js +++ b/src/lembas/web/static/js/app.js @@ -423,6 +423,101 @@ load(current || ""); } + /* --- Choosing a file on the far side ------------------------------------- + The same walk as chooseDirectory, finishing on a file rather than on a + button. Canvas used to ask for a typed path, which is the one thing in this + application that expected somebody to remember an absolute path on another + machine -- the same complaint the folder page's directory box answered. + + A separate function rather than a flag on the one above, because almost + everything differs: what a click does, what finishes it, whether there is a + "use this" button at all, and what the dialog is called. What they share is + the listing, and that is shared where it matters -- one fragment on the + server, asked for with `pick=file`. */ + function chooseFile(profileId, current, onPick) { + var dialog = document.createElement("dialog"); + dialog.className = "dialog dialog--wide"; + dialog.innerHTML = + '
' + + '

Open a file

' + + '

Pick a file to open in the canvas. Folders walk ' + + "deeper; you can also type a path and press Enter.

" + + '
' + + '' + + '
' + + '' + + "
"; + document.body.appendChild(dialog); + + var results = dialog.querySelector(".dialog__results"); + var typed = dialog.querySelector("input"); + var here = current || ""; + + function load(path) { + results.setAttribute("aria-busy", "true"); + fetch( + "/api/agents/" + encodeURIComponent(profileId) + + "/browse?pick=file&path=" + encodeURIComponent(path || ""), + { credentials: "same-origin" } + ) + .then(function (response) { return response.text(); }) + .then(function (html) { + results.innerHTML = html; + var box = results.querySelector("#dir-results"); + if (box) { here = box.dataset.here || path || ""; typed.value = here; } + results.removeAttribute("aria-busy"); + }) + .catch(function () { + results.textContent = "Could not reach that machine."; + results.removeAttribute("aria-busy"); + }); + } + + function finish(chosen) { + if (chosen !== undefined) onPick(chosen); + dialog.close(); + setTimeout(function () { dialog.remove(); }, 200); + } + + results.addEventListener("click", function (event) { + /* A file finishes; a folder is a step. Checked in that order because a + row is one or the other and the file case is what this dialog is for. */ + var file = event.target.closest("[data-file-open]"); + if (file) { finish(file.dataset.fileOpen); return; } + var row = event.target.closest("[data-dir-open]"); + if (row) load(row.dataset.dirOpen); + }); + + /* Enter opens what was typed if it looks like a file, and walks into it + otherwise. There is no way to tell from here which it is, so the server + decides: a path that lists is a directory and the listing comes back; one + that does not is taken as a file. Cheaper than a second endpoint asking + "what is this", and wrong only for a directory that cannot be read -- + which the canvas then reports in its own words. */ + typed.addEventListener("keydown", function (event) { + if (event.key !== "Enter") return; + event.preventDefault(); + var value = typed.value.trim(); + if (value && value !== here) { finish(value); return; } + load(value); + }); + + dialog.querySelector("[data-file-cancel]").addEventListener("click", function () { + finish(); + }); + dialog.addEventListener("cancel", function (event) { + event.preventDefault(); + finish(); + }); + dialog.addEventListener("click", function (event) { + if (event.target === dialog) finish(); + }); + + dialog.showModal(); + load(current || ""); + } + document.addEventListener("click", function (event) { var choice = event.target.closest("[data-attach]"); if (!choice) return; @@ -680,6 +775,7 @@ autosize: autosize, uploadFiles: uploadFiles, chooseDirectory: chooseDirectory, + chooseFile: chooseFile, promptInstall: promptInstall }; diff --git a/src/lembas/web/static/js/ui.js b/src/lembas/web/static/js/ui.js index 5fed26c..7fae217 100644 --- a/src/lembas/web/static/js/ui.js +++ b/src/lembas/web/static/js/ui.js @@ -684,3 +684,71 @@ document.addEventListener("lembas:notify", function (event) { document.body && scan(); document.addEventListener("htmx:afterSettle", scan); })(); + +/* + Tabs remember their scroll position, and that made short panels look empty. + + A tab is a radio and a panel is shown by CSS, so switching one changes nothing + about `.tabs__body` -- which is the element that scrolls. Read half way down + the long Tools panel on /admin/prompts, click Context, and the container keeps + a scrollTop the new panel is not tall enough to fill: the browser clamps it to + that panel's bottom, and what lands on screen is the end of it above a screen + of nothing. It reads as a page that failed to load, and the way out is to + scroll up before scrolling down. + + Nothing in CSS can reset a scroll position, so this is the smallest amount of + JavaScript that fixes it: on a tab change, put the body it belongs to back at + the top. Delegated and keyed on the class rather than on any one page, because + every tabbed screen here has the same container and the same problem. +*/ +(function () { + document.addEventListener("change", function (event) { + var radio = event.target; + if (!radio || radio.type !== "radio") return; + var bar = radio.closest && radio.closest(".tabs__bar"); + if (!bar) return; + /* The body is the bar's sibling, which is also what the panel-matching + selectors in admin.css rely on -- so if this ever stops finding it, those + will have stopped working too. */ + var body = bar.parentElement && bar.parentElement.querySelector(".tabs__body"); + if (body) body.scrollTop = 0; + }); +})(); + +/* + Opening a file into the canvas, by looking rather than by spelling. + + The button cannot carry an `hx-post` because the path is not known until the + dialog closes -- so this posts it once it is, through htmx's own `ajax` so the + response lands in the panel exactly as every other canvas action's does. Doing + it with `fetch` would mean parsing and swapping the fragment by hand, and then + there would be two ways the canvas gets replaced. + + The key is `agent:`, which is the same key the model's own reads produce + -- so a file opened here and the same file opened by a tool call are one tab + rather than two spellings of it. That is `canvas.path_key`'s whole job, and it + is why the prefix is added here rather than asked of the reader. +*/ +(function () { + document.addEventListener("click", function (event) { + var button = event.target.closest && event.target.closest("[data-canvas-open]"); + if (!button) return; + event.preventDefault(); + + var profile = button.dataset.profile; + var chat = button.dataset.chat; + if (!profile || !chat) { + window.lembas.notify("This chat is not pointed at a machine, so there is nothing to browse."); + return; + } + + window.lembas.chooseFile(profile, button.dataset.dir || "", function (path) { + if (!path) return; + window.htmx.ajax("POST", "/api/chats/" + encodeURIComponent(chat) + "/canvas/tabs", { + target: "#canvas-inner", + swap: "innerHTML", + values: { key: "agent:" + path } + }); + }); + }); +})(); diff --git a/src/lembas/web/templates/agents/_browse.html b/src/lembas/web/templates/agents/_browse.html index ca4998b..47fbd16 100644 --- a/src/lembas/web/templates/agents/_browse.html +++ b/src/lembas/web/templates/agents/_browse.html @@ -33,9 +33,12 @@ {% endif %} {% for entry in entries %} - {# Files are listed but not selectable. Hiding them would make a directory - of only files look empty, which is worse than showing what is there and - not letting it be chosen. #} + {# In directory mode files are listed but not selectable: hiding them would + make a directory of only files look empty, which is worse than showing + what is there and not letting it be chosen. In file mode they are the + point, and it is the *directory* that stays a step rather than a choice. + One fragment for both, because a second copy of this listing is a second + place for the path arithmetic below to be got subtly differently. #}
  • {% if entry.is_dir %} + {% elif pick == "file" %} + {% else %} {{ icon("file-text", "icon--sm") }} diff --git a/src/lembas/web/templates/chat/_canvas_inner.html b/src/lembas/web/templates/chat/_canvas_inner.html index aa37799..e1d9d12 100644 --- a/src/lembas/web/templates/chat/_canvas_inner.html +++ b/src/lembas/web/templates/chat/_canvas_inner.html @@ -37,25 +37,29 @@ {% include "chat/_canvas_tabs.html" %} {# - Opening one by hand. A path box rather than a file browser: the model opens - what it touches, which is the path this feature is really for, and a second - directory browser beside the one the composer already has would be a lot of - interface for the rarer case. A relative path resolves against the project - directory, exactly as it does for the model. + Opening one by hand. + + This was a path box, and it was the one control left in the application that + asked somebody to remember an absolute path on another machine -- the same + complaint the folder page's directory field answered with a picker. The model + opens what it touches, which is still the commonest way a file gets here, but + "open that other file" should not mean typing it out. + + The dialog itself still takes a typed path, so nothing has been removed: what + has gone is having to type one when you would rather look. #}
    {% if canvas_agent %} - {# Its own form. A second control named `key` in the same one -- the Scratch - button below -- would send two values for one field, and which of them the - server took would be an accident. #} -
    - - -
    + {# The profile is what the browse endpoint is hung off, so the button carries + it. `data-canvas-open` rather than an `hx-` verb because the path is not + known until the dialog closes -- app.js posts it once it is. #} + {% endif %}