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 %}