Narrow a chat before it starts, and find a file rather than spell it
Six things, all found by using the thing rather than by reading it. The scope menu only appeared once a chat existed, on the reasoning that there was no row to post to. True, and the wrong conclusion: the harness puts a tool's guidance in front of the model the moment the tool is offered, so the menu could not be reached until after the model had been told how to keep notes and handed the tools to do it -- and switching it off then does not un-send that turn. It is on the new-chat screen now and writes nothing: `_scope_context` builds a stand-in Chat, which is `draft.as_chat`'s trick again, and the switches ride along with the first message. Checked means on and a browser submits only the ticked boxes, so every gate also renders a hidden input naming it and `start_chat` subtracts one list from the other; inverting the control would read backwards under a menu that says everything is on unless you say otherwise. Only the off ones are written, because absent means on and one representation of it is what keeps "why is this off?" to a single answer. Nothing is validated against the offered set, since scope_json narrows after every gate -- naming a gate that was never offered switches off something that was not on. Then the scheduling instructions, audited against a 4B model on this machine rather than against my own reading of them. Ten realistic requests, ten compiled, twice over -- so the prompt is sound. What was not sound was `describe`, which built a phrase by joining fragments and read "Every the 1st at 09:00" for the commonest monthly schedule there is, and "Every of January" for a month with no day. That string is the whole of what somebody sees before approving a schedule and the whole of what the model is told about its own chat, so a phrase nobody can parse is a review step nobody performs. It reads as English now, collapses Monday-to-Friday to "every weekday" and seven days to "every day", and every case in the test is a rule that model actually produced. The one mistake it made was naming Wednesday for "every other tuesday", so the weekday numbering is spelled out rather than left as "0-6, Monday is 0": getting that wrong is the error here that still looks like a working schedule. Roughly one call in six also came back empty -- a local runner swapping models under the request will do that -- so an unusable reply is asked for once more before giving up. Not on an LLMError: an endpoint that refused will refuse again, and the reader is better served by the form than by waiting twice for the same answer. Canvas asked for a typed path, which was the last control in the application expecting somebody to remember an absolute path on another machine -- the same complaint the folder page's directory field answered with a picker. /browse takes pick=file and the same fragment makes files buttons, because a second copy of that listing is a second place for the path arithmetic to be got subtly differently. The button carries data-canvas-open rather than an hx-post since the path is not known until the dialog closes, and ui.js posts it through htmx.ajax so the response lands in the panel exactly as every other canvas action's does. The key is `agent:<path>`, so a file opened by hand and one opened by the model are one tab rather than two spellings of it. The tabs already existed and already closed; they now square off at the bottom and the active one takes the body's background, so which is selected is structural rather than a tint nobody can see in a theme they did not choose. Highlighting was already there for every language named and is checked for fifteen of them. Three smaller ones. Tabs kept their scroll position, so switching from a long panel to a short one left the browser clamping to that panel's bottom: the end of it above a screen of nothing, which reads as a page that failed to load. Nothing in CSS can reset a scroll position. The sidebar's footer and the composer sit either side of one vertical edge and were both content-sized, so their top borders met it at different heights and read as one line that had been broken -- `--footer-height` is a calc of the pieces the footer is built from, applied as a min-height to both, which is exactly what `--header-height` already does at the top of the shell. And "Add a workflow" sat flush against the list it adds to, stated as an adjacency because `.btn-row` is right to carry no margin everywhere else it appears. Both pieces of JavaScript were driven under a DOM stub before committing, which is how the tab listener's delegation and the canvas button's six behaviours were checked at all -- `node --check` parses a file that does nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -980,6 +980,30 @@ allowed to shrink past its content and scroll sideways, everything else is
|
||||
`flex: none`. There is a test asserting the file contains no `@media`, so nobody
|
||||
"fixes" a future version of this with a breakpoint.
|
||||
|
||||
**The scope menu is on the new-chat screen too, and there it writes nothing.**
|
||||
It used to appear only once a chat existed, on the reasoning that there was no
|
||||
row to post to -- which was true and was the wrong conclusion. The harness puts
|
||||
a tool's guidance in front of the model the moment the tool is offered, so the
|
||||
menu could not be reached until after the model had been told how to keep notes
|
||||
and handed the tools to do it; switching it off then does not un-send that turn.
|
||||
|
||||
`_scope_context` builds a stand-in `Chat` for the prospective one, which is
|
||||
`agent/draft.py:as_chat`'s trick again -- `resolve_tools` reads the kind, the
|
||||
model and the scope and never queries or writes the row, so one constructed and
|
||||
never added satisfies it unchanged. Deliberately an *ordinary* chat even though
|
||||
the kind is still switchable on that screen: an agent chat's tools depend on a
|
||||
connection that is not settled until the chat is created.
|
||||
|
||||
**Checked means on, and a browser submits only the ticked boxes** -- so every
|
||||
gate also renders a hidden input naming it, always submitted, and `start_chat`
|
||||
subtracts one list from the other. Inverting the control so ticking means "off"
|
||||
would read backwards under a menu that says everything is on unless you say
|
||||
otherwise. `_new_chat` writes only the *off* ones, because absent means on and
|
||||
one representation of "on" is what keeps "why is this off?" to a single answer.
|
||||
Nothing needs validating against the offered set: `scope_json` narrows inside
|
||||
`resolve_tools` after every gate, so naming a gate that was never offered
|
||||
switches off something that was not on.
|
||||
|
||||
**The `@` button became the scope menu, and is called Toggle.** It only ever
|
||||
inserted the character, which the `@` key already does without a button. It then
|
||||
kept that as a *row inside the menu*, which was the same redundancy one level
|
||||
@@ -1205,6 +1229,29 @@ 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.
|
||||
|
||||
**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` -- the element that actually scrolls. Read half way down the
|
||||
long Tools panel on `/admin/prompts`, switch to a short one, and the browser
|
||||
clamps the kept scrollTop to that panel's bottom: what lands on screen is the end
|
||||
of it above a screen of nothing, which reads as a page that failed to load.
|
||||
Nothing in CSS can reset a scroll position, so `ui.js` does it on change,
|
||||
delegated and keyed on the class -- the next tabbed screen would otherwise have
|
||||
the same bug and no sign of it.
|
||||
|
||||
**The shell has one line along its bottom edge, and it took a token.** The
|
||||
sidebar's footer and the composer sit either side of the same vertical edge and
|
||||
were both content-sized, so their top borders met it at different heights and
|
||||
read as one line that had been broken. Neither could match the other by
|
||||
accident: the footer's height depends on which entries a reader's permissions
|
||||
allow, and the composer's on how much has been typed. `--footer-height` is a
|
||||
calc of the pieces the footer is built from -- four rows at `--control-h`, the
|
||||
gaps, the padding -- applied as a `min-height` to both, which holds the footer
|
||||
at full height for somebody who sees fewer entries and lifts the composer to
|
||||
meet it. Exactly what `--header-height` already does at the top of the shell. A
|
||||
composer that grows past it as somebody types is expected; nothing is pretending
|
||||
the sidebar should follow.
|
||||
|
||||
**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,
|
||||
@@ -1212,6 +1259,29 @@ 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 -- in Canvas as well now.** The panel asked for a
|
||||
typed path, which was the last control in the application expecting somebody to
|
||||
remember an absolute path on another machine. `GET /api/agents/{id}/browse` takes
|
||||
`pick=file`, and `agents/_browse.html` then makes files buttons carrying
|
||||
`data-file-open` while directories stay a step. **One fragment for both modes**,
|
||||
because a second copy of that listing is a second place for the path arithmetic
|
||||
to be got subtly differently -- and differently means a file that opens to the
|
||||
wrong path, or to nothing.
|
||||
|
||||
`app.js:chooseFile` is its own function rather than a flag on `chooseDirectory`:
|
||||
what a click does, what finishes it, whether there is a "use this" button at all
|
||||
and what the dialog is called all differ. What they share is the listing, and
|
||||
that is shared where it matters -- on the server.
|
||||
|
||||
The button carries `data-canvas-open` rather than an `hx-post`, because **the
|
||||
path is not known until the dialog closes**; `ui.js` posts it afterwards through
|
||||
`htmx.ajax` so the response lands in the panel exactly as every other canvas
|
||||
action's does. `fetch` would mean swapping the fragment by hand, and then there
|
||||
would be two ways the canvas gets replaced. The key is `agent:<path>` -- the same
|
||||
key a tool call's read produces, so a file opened by hand and one opened by the
|
||||
model are one tab rather than two spellings of it, which is `path_key`'s whole
|
||||
job and why the prefix is added in code rather than asked of the reader.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -9,7 +9,7 @@ with web search, custom HTTP tools and MCP servers, agent chats that work on a
|
||||
machine over SSH, a knowledge library, notes, memory and skills, speech in and
|
||||
out, image generation over ComfyUI, users and groups, model administration,
|
||||
installable as an app, reports, messages, and scheduled work that runs on its
|
||||
own. 1805 tests, `ruff` clean.
|
||||
own. 1824 tests, `ruff` clean.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "0.8.0"
|
||||
__version__ = "0.8.1"
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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 "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+43
-8
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 =
|
||||
'<div class="dialog__form">' +
|
||||
'<h2 class="dialog__title">Open a file</h2>' +
|
||||
'<p class="dialog__note">Pick a file to open in the canvas. Folders walk ' +
|
||||
"deeper; you can also type a path and press Enter.</p>" +
|
||||
'<div class="dialog__results"></div>' +
|
||||
'<input class="input input--mono" type="text" spellcheck="false" ' +
|
||||
'aria-label="Path" placeholder="/project/src/main.py">' +
|
||||
'<div class="dialog__actions">' +
|
||||
'<button class="btn" type="button" data-file-cancel>Cancel</button>' +
|
||||
"</div></div>";
|
||||
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
|
||||
};
|
||||
|
||||
|
||||
@@ -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:<path>`, 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 }
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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. #}
|
||||
<li>
|
||||
{% if entry.is_dir %}
|
||||
<button class="picker__option" type="button"
|
||||
@@ -46,6 +49,14 @@
|
||||
</span>
|
||||
{{ icon("chevron-right", "icon--sm") }}
|
||||
</button>
|
||||
{% elif pick == "file" %}
|
||||
<button class="picker__option" type="button"
|
||||
data-file-open="{{ (here.rstrip('/') ~ '/' ~ entry.name) if here != '/' else '/' ~ entry.name }}">
|
||||
{{ icon("file-text", "icon--sm") }}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">{{ entry.name }}</span>
|
||||
</span>
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="picker__option is-inert">
|
||||
{{ icon("file-text", "icon--sm") }}
|
||||
|
||||
@@ -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.
|
||||
#}
|
||||
<div class="canvas__open">
|
||||
{% 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. #}
|
||||
<form class="canvas__open-form"
|
||||
hx-post="/api/chats/{{ chat.id }}/canvas/tabs"
|
||||
hx-target="#canvas-inner" hx-swap="innerHTML">
|
||||
<input class="input input--mono canvas__path" type="text" name="key"
|
||||
placeholder="agent:path/to/file" aria-label="Open a file"
|
||||
autocomplete="off" spellcheck="false">
|
||||
<button class="btn btn--sm" type="submit">Open</button>
|
||||
</form>
|
||||
{# 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. #}
|
||||
<button class="btn btn--sm" type="button"
|
||||
data-canvas-open
|
||||
data-profile="{{ agent_profile_id }}"
|
||||
data-dir="{{ agent_dir }}"
|
||||
data-chat="{{ chat.id }}">
|
||||
{{ icon("folder-open", "icon--sm") }} Open a file
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<button class="btn btn--sm" type="button"
|
||||
|
||||
@@ -188,10 +188,23 @@
|
||||
element carrying `name` has to be the element carrying the request,
|
||||
which is what tests/conftest.py:control_named exists to pin.
|
||||
|
||||
Only on an existing chat -- there is no row to write to before one
|
||||
exists, and a switch that went nowhere is worse than no switch.
|
||||
It is on the new-chat screen too, and there the switches post
|
||||
nothing: they are plain checkboxes carried by the first message.
|
||||
That matters more than it sounds. The harness puts a tool's guidance
|
||||
in front of the model the moment the tool is offered, so a menu that
|
||||
only appeared once a chat existed was one you could not reach until
|
||||
after the model had been told how to keep notes and given the tools
|
||||
to do it. Switching it off then does not un-send that turn.
|
||||
|
||||
Checked means ON, which is the natural reading -- but a browser
|
||||
submits only the *ticked* boxes, and this feature needs to know which
|
||||
ones were unticked. So every gate also has a hidden input naming it,
|
||||
always submitted, and `start_chat` subtracts one list from the other.
|
||||
The alternative, inverting the control so ticking means "off", reads
|
||||
backwards in a menu that says "everything is on unless you say
|
||||
otherwise". Still no JavaScript.
|
||||
#}
|
||||
{% set has_scope = chat and (scope_families or scope_skills or scope_allow) %}
|
||||
{% set has_scope = scope_families or scope_skills or scope_allow %}
|
||||
{% if has_scope %}
|
||||
<div class="picker picker--up" data-picker>
|
||||
<button class="btn btn--icon composer__btn" type="button" data-picker-toggle
|
||||
@@ -212,10 +225,16 @@
|
||||
<p class="picker__group">Tools</p>
|
||||
{% for family in scope_families %}
|
||||
<label class="picker__option picker__option--toggle">
|
||||
{% if scope_prospective %}
|
||||
<input type="hidden" name="scope_all" value="{{ family.gate }}">
|
||||
<input type="checkbox" name="scope_on" value="{{ family.gate }}"
|
||||
{{ 'checked' if family.on }}>
|
||||
{% else %}
|
||||
<input type="checkbox" name="on" value="true"
|
||||
{{ 'checked' if family.on }}
|
||||
hx-post="/api/chats/{{ chat.id }}/scope" hx-swap="none"
|
||||
hx-vals='{"kind": "family", "name": "{{ family.gate }}"}'>
|
||||
{% endif %}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">{{ family.label }}</span>
|
||||
</span>
|
||||
@@ -227,10 +246,16 @@
|
||||
<p class="picker__group">Skills</p>
|
||||
{% for skill in scope_skills %}
|
||||
<label class="picker__option picker__option--toggle">
|
||||
{% if scope_prospective %}
|
||||
<input type="hidden" name="scope_skill_all" value="{{ skill.name }}">
|
||||
<input type="checkbox" name="scope_skill_on" value="{{ skill.name }}"
|
||||
{{ 'checked' if skill.on }}>
|
||||
{% else %}
|
||||
<input type="checkbox" name="on" value="true"
|
||||
{{ 'checked' if skill.on }}
|
||||
hx-post="/api/chats/{{ chat.id }}/scope" hx-swap="none"
|
||||
hx-vals='{"kind": "skill", "name": "{{ skill.name }}"}'>
|
||||
{% endif %}
|
||||
<span class="picker__option-body">
|
||||
<span class="picker__option-name">{{ skill.name }}</span>
|
||||
{% if skill.description %}
|
||||
|
||||
@@ -329,3 +329,56 @@ def test_reindexing_somebody_elses_chat_is_not_possible(
|
||||
)
|
||||
|
||||
assert client.post(f"/api/chats/{chat.id}/index").status_code == 404
|
||||
|
||||
|
||||
# --- Picking a file rather than a directory ---------------------------------------
|
||||
def test_files_are_inert_when_a_directory_is_wanted(
|
||||
client: TestClient, db, registered, served_tree
|
||||
):
|
||||
"""The default, and the older of the two. Hiding files would make a folder
|
||||
of nothing but files look empty, which is worse than showing what is there
|
||||
and not letting it be chosen."""
|
||||
profile = _profile(db, served_tree)
|
||||
|
||||
body = client.get(
|
||||
f"/api/agents/{profile.id}/browse", params={"path": served_tree["root"]}
|
||||
).text
|
||||
|
||||
assert "README.md" in body
|
||||
assert "data-file-open" not in body
|
||||
|
||||
|
||||
def test_files_become_choices_when_a_file_is_wanted(
|
||||
client: TestClient, db, registered, served_tree
|
||||
):
|
||||
"""Canvas asks for `pick=file`. One listing serves both, because a second
|
||||
copy is a second place for the path arithmetic to be got subtly
|
||||
differently -- and getting it differently means a file that opens to the
|
||||
wrong path, or to nothing."""
|
||||
profile = _profile(db, served_tree)
|
||||
|
||||
body = client.get(
|
||||
f"/api/agents/{profile.id}/browse",
|
||||
params={"path": served_tree["root"], "pick": "file"},
|
||||
).text
|
||||
|
||||
assert "data-file-open" in body
|
||||
assert f'data-file-open="{served_tree["root"]}/README.md"' in body
|
||||
# Directories stay a step rather than becoming a choice.
|
||||
assert "data-dir-open" in body
|
||||
|
||||
|
||||
def test_an_unknown_pick_falls_back_to_directories(
|
||||
client: TestClient, db, registered, served_tree
|
||||
):
|
||||
"""It arrives off a query string, so it is read as one of two things rather
|
||||
than trusted -- the same shape every other value read off a request here
|
||||
takes."""
|
||||
profile = _profile(db, served_tree)
|
||||
|
||||
body = client.get(
|
||||
f"/api/agents/{profile.id}/browse",
|
||||
params={"path": served_tree["root"], "pick": "whatever"},
|
||||
).text
|
||||
|
||||
assert "data-file-open" not in body
|
||||
|
||||
@@ -529,3 +529,76 @@ def test_the_tab_routes_refuse_the_wrong_method(
|
||||
chat_id = make_chat()
|
||||
assert getattr(client, verb)(f"/api/chats/{chat_id}/canvas/tabs").status_code == 405
|
||||
assert getattr(client, verb)(f"/api/chats/{chat_id}/canvas/save").status_code == 405
|
||||
|
||||
|
||||
# --- Opening a file by looking rather than by spelling --------------------------
|
||||
def _agent_chat(db, make_chat) -> Chat:
|
||||
"""An agent chat with a usable connection, so `agent_ready` says yes.
|
||||
|
||||
No SSH server is needed: the panel's head, tab strip and open row render
|
||||
without reading anything off the far side, which is exactly the part under
|
||||
test here.
|
||||
"""
|
||||
from lembas.db.models import SshProfile
|
||||
|
||||
_add_connection(db)
|
||||
chat = db.get(Chat, make_chat())
|
||||
profile = SshProfile(
|
||||
owner_id=chat.user_id,
|
||||
name="box",
|
||||
host="127.0.0.1",
|
||||
port=22,
|
||||
username="tester",
|
||||
auth="password",
|
||||
password_encrypted=encrypt(""),
|
||||
host_key="ssh-ed25519 AAAA",
|
||||
default_dir="/srv/app",
|
||||
enabled=True,
|
||||
)
|
||||
db.add(profile)
|
||||
db.commit()
|
||||
chat.kind = KIND_AGENT
|
||||
chat.ssh_profile_id = profile.id
|
||||
chat.project_dir = "/srv/app"
|
||||
db.commit()
|
||||
settings_store.update(db, {"enabled": True}, key=settings_store.AGENTS)
|
||||
return chat
|
||||
|
||||
|
||||
def test_the_panel_offers_a_dialog_rather_than_a_path_box(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
"""This was the one control left 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 button cannot carry an `hx-post`: the path is not known until the dialog
|
||||
closes, so ui.js posts it afterwards. What it must carry is the profile the
|
||||
browse endpoint is hung off and the directory to open at, or the dialog
|
||||
starts at the account's home and every path is a walk from there.
|
||||
"""
|
||||
chat = _agent_chat(db, make_chat)
|
||||
|
||||
body = client.get(f"/api/chats/{chat.id}/canvas").text
|
||||
|
||||
assert "data-canvas-open" in body
|
||||
assert 'data-profile="' + chat.ssh_profile_id + '"' in body
|
||||
assert 'data-dir="/srv/app"' in body
|
||||
# The typed path box is gone, and with it the only field named `key` that a
|
||||
# person was expected to fill in by hand.
|
||||
assert 'name="key"' not in body
|
||||
|
||||
|
||||
def test_a_chat_with_no_machine_is_offered_no_file_dialog(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
"""`canvas_agent` gates it, and it is re-derived server-side on every
|
||||
request -- a button that opened an empty browser would be worse than none."""
|
||||
_add_connection(db)
|
||||
chat_id = make_chat()
|
||||
|
||||
body = client.get(f"/api/chats/{chat_id}/canvas").text
|
||||
|
||||
assert "data-canvas-open" not in body
|
||||
# Scratch is still there: it needs no machine.
|
||||
assert "scratch:" in body
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import Chat, Connection, Model, User
|
||||
from lembas.services import settings_store
|
||||
@@ -322,3 +323,120 @@ def test_with_nothing_to_narrow_there_is_no_button_at_all(
|
||||
html = client.get(f"/chat/{chat.id}").text
|
||||
|
||||
assert "picker__menu--scope" not in html
|
||||
|
||||
|
||||
# --- Before the chat exists -------------------------------------------------------
|
||||
def test_the_menu_is_there_before_the_first_message(
|
||||
client: TestClient, db, chat, registered
|
||||
):
|
||||
"""The bug this section exists for.
|
||||
|
||||
The harness puts a tool's guidance in front of the model the moment the tool
|
||||
is offered — so a menu that only appeared once a chat existed was one you
|
||||
could not reach until after the model had been told how to keep notes and
|
||||
been handed the tools to do it. Switching it off then does not un-send that
|
||||
turn.
|
||||
"""
|
||||
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
|
||||
html = client.get("/chat").text
|
||||
|
||||
assert 'aria-label="Toggle"' in html
|
||||
assert 'name="scope_all"' in html
|
||||
assert 'name="scope_on"' in html
|
||||
# And it posts nothing on its own: there is no row to post to yet.
|
||||
assert "/scope" not in html
|
||||
|
||||
|
||||
def test_the_prospective_switches_ride_with_the_first_message(
|
||||
client: TestClient, db, chat, registered
|
||||
):
|
||||
"""A browser submits only the ticked boxes, so "which were unticked" needs
|
||||
the hidden mirror. This asserts the pair exists per gate rather than that
|
||||
the markup looks a certain way."""
|
||||
from html.parser import HTMLParser
|
||||
|
||||
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
|
||||
html = client.get("/chat").text
|
||||
|
||||
named: dict[str, list[str]] = {"scope_all": [], "scope_on": []}
|
||||
|
||||
class Finder(HTMLParser):
|
||||
def handle_starttag(self, tag, attrs):
|
||||
got = {key: (value or "") for key, value in attrs}
|
||||
if got.get("name") in named:
|
||||
named[got["name"]].append(got.get("value", ""))
|
||||
|
||||
Finder().feed(html)
|
||||
|
||||
assert named["scope_all"], "the prospective menu rendered no gates"
|
||||
# Every gate offered has both halves, or one of them can never be turned off.
|
||||
assert set(named["scope_all"]) == set(named["scope_on"])
|
||||
|
||||
|
||||
def test_unticking_before_sending_writes_it_to_the_new_chat(
|
||||
client: TestClient, db, chat, registered
|
||||
):
|
||||
"""End to end: what the menu was set to is what the row is created with, so
|
||||
the very first request is already narrowed."""
|
||||
settings_store.update(db, {"enabled": True}, key=settings_store.SEARCH)
|
||||
|
||||
client.post(
|
||||
"/api/chats/start",
|
||||
data={
|
||||
"content": "hello",
|
||||
"model_id": "m",
|
||||
"scope_all": ["notes", "memory", "web_search"],
|
||||
# `notes` left out: unticked.
|
||||
"scope_on": ["memory", "web_search"],
|
||||
},
|
||||
)
|
||||
|
||||
fresh = db.scalars(
|
||||
select(Chat).where(Chat.user_id == chat.user_id).order_by(Chat.created_at.desc())
|
||||
).first()
|
||||
assert tools_service.scoped_off(fresh) == frozenset({"notes"})
|
||||
assert "notes_search" not in _names(db, fresh, db.get(User, chat.user_id))
|
||||
|
||||
|
||||
def test_leaving_everything_ticked_writes_nothing(client: TestClient, db, chat, registered):
|
||||
"""Absent means on, and there is one representation of it. A row full of
|
||||
`True`s would be a second one, and "why is this off?" would have two
|
||||
answers."""
|
||||
client.post(
|
||||
"/api/chats/start",
|
||||
data={
|
||||
"content": "hello",
|
||||
"model_id": "m",
|
||||
"scope_all": ["notes", "memory"],
|
||||
"scope_on": ["notes", "memory"],
|
||||
},
|
||||
)
|
||||
|
||||
fresh = db.scalars(
|
||||
select(Chat).where(Chat.user_id == chat.user_id).order_by(Chat.created_at.desc())
|
||||
).first()
|
||||
assert fresh.scope_json == {}
|
||||
|
||||
|
||||
def test_starting_a_chat_cannot_widen_through_the_menu(
|
||||
client: TestClient, db, chat, registered
|
||||
):
|
||||
"""The security-shaped half, from the new-chat side. `scope_json` narrows
|
||||
inside `resolve_tools` *after* every gate, so naming a gate that was never
|
||||
offered switches off something that was not on — which is nothing. A
|
||||
crafted POST cannot turn anything on, because there is no representation
|
||||
for "on" to send."""
|
||||
client.post(
|
||||
"/api/chats/start",
|
||||
data={
|
||||
"content": "hello",
|
||||
"model_id": "m",
|
||||
"scope_all": ["agent", "made_up"],
|
||||
"scope_on": ["agent", "made_up"],
|
||||
},
|
||||
)
|
||||
|
||||
fresh = db.scalars(
|
||||
select(Chat).where(Chat.user_id == chat.user_id).order_by(Chat.created_at.desc())
|
||||
).first()
|
||||
assert "shell_run" not in _names(db, fresh, db.get(User, chat.user_id))
|
||||
|
||||
@@ -337,3 +337,82 @@ def test_an_ordinary_chat_is_told_none_of_it(client: TestClient, db, registered)
|
||||
block = harness.compose(db, _user(db), [], chat)
|
||||
assert "nobody is necessarily reading" not in block
|
||||
assert "This scheduled task" not in block
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_an_unusable_reply_is_asked_again_once(
|
||||
client: TestClient, db, registered, monkeypatch
|
||||
):
|
||||
"""Measured against a 4B model: the prompt 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."""
|
||||
replies = ["", json.dumps({"title": "T", "instruction": "I",
|
||||
"schedule": {"at": {"times": ["09:00"]}}})]
|
||||
calls = 0
|
||||
|
||||
async def answer(endpoint, payload):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return replies.pop(0)
|
||||
|
||||
monkeypatch.setattr(compile_service, "complete", answer)
|
||||
compiled = await compile_service.compile_request(
|
||||
_endpoint(), "test-model", "daily at nine", template=_template(db), user=_user(db)
|
||||
)
|
||||
|
||||
assert calls == 2
|
||||
assert compiled.ok is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_it_gives_up_after_the_second_try(
|
||||
client: TestClient, db, registered, monkeypatch
|
||||
):
|
||||
calls = 0
|
||||
|
||||
async def answer(endpoint, payload):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return "I'd suggest weekly."
|
||||
|
||||
monkeypatch.setattr(compile_service, "complete", answer)
|
||||
compiled = await compile_service.compile_request(
|
||||
_endpoint(), "test-model", "daily at nine", template=_template(db), user=_user(db)
|
||||
)
|
||||
|
||||
assert calls == 2
|
||||
assert compiled.ok is False
|
||||
assert compiled.instruction == "daily at nine"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_an_endpoint_that_refuses_is_not_asked_twice(
|
||||
client: TestClient, db, registered, monkeypatch
|
||||
):
|
||||
"""It will refuse again, and the reader is better served by the form than by
|
||||
waiting twice for the same answer."""
|
||||
calls = 0
|
||||
|
||||
async def refuse(endpoint, payload):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise LLMError("connection refused")
|
||||
|
||||
monkeypatch.setattr(compile_service, "complete", refuse)
|
||||
await compile_service.compile_request(
|
||||
_endpoint(), "test-model", "daily at nine", template=_template(db), user=_user(db)
|
||||
)
|
||||
|
||||
assert calls == 1
|
||||
|
||||
|
||||
def test_the_weekday_numbering_is_spelled_out(client: TestClient, db, registered):
|
||||
"""The one mistake a small model actually made in the audit: "every other
|
||||
tuesday" came back as Wednesday. Naming the wrong day is the error here that
|
||||
still looks like a working schedule, so the mapping is written out rather
|
||||
than left as "0-6, Monday is 0"."""
|
||||
template = _template(db)
|
||||
|
||||
for day, number in (("Monday", 0), ("Wednesday", 2), ("Sunday", 6)):
|
||||
assert f"{day}={number}" in template
|
||||
|
||||
@@ -356,7 +356,51 @@ def test_describe_says_what_the_rule_actually_does():
|
||||
{"start": "2026-08-05T12:00:00Z", "every": {"minutes": 10}, "count": 5},
|
||||
"Every 10 minutes, 5 times",
|
||||
),
|
||||
({"at": {"days": [1], "times": ["09:00"]}}, "Every the 1st at 09:00"),
|
||||
]
|
||||
for raw, expected in cases:
|
||||
assert rule_service.describe(rule_service.validate(raw), zone=PRAGUE) == expected
|
||||
|
||||
|
||||
def test_describe_reads_as_english_for_what_a_model_actually_writes():
|
||||
"""Every case below is a rule a 4B model produced from a plain request, and
|
||||
the first two used to read "Every the 1st at 09:00" and "Every of January".
|
||||
|
||||
Worth pinning as *wording*, which is not a thing tests usually assert. This
|
||||
string is the whole of what the reader sees before approving a schedule and
|
||||
the whole of what the model is told about its own chat — so a phrase nobody
|
||||
can parse is a review step nobody performs.
|
||||
"""
|
||||
cases = [
|
||||
# "on the 1st of every month, write up what changed"
|
||||
({"at": {"days": [1], "times": ["09:00"]}}, "On the 1st of each month at 09:00"),
|
||||
(
|
||||
{"at": {"days": [1, 15], "times": ["09:00"]}},
|
||||
"On the 1st and 15th of each month at 09:00",
|
||||
),
|
||||
# "every weekday at 8am give me a briefing" — five names in a row is the
|
||||
# commonest thing this produces otherwise.
|
||||
({"at": {"weekdays": [0, 1, 2, 3, 4], "times": ["08:00"]}}, "Every weekday at 08:00"),
|
||||
(
|
||||
{"at": {"weekdays": [5, 6], "times": ["10:00"]}},
|
||||
"Every Saturday and Sunday at 10:00",
|
||||
),
|
||||
# Naming all seven is no constraint at all, and saying so is how "every
|
||||
# day" comes out of a rule that enumerated them.
|
||||
(
|
||||
{"at": {"weekdays": [0, 1, 2, 3, 4, 5, 6], "times": ["09:00"]}},
|
||||
"Every day at 09:00",
|
||||
),
|
||||
({"at": {"months": [1, 7], "times": ["09:00"]}}, "Every day in January and July at 09:00"),
|
||||
(
|
||||
{"at": {"months": [1], "days": [1], "times": ["00:00"]}},
|
||||
"On the 1st of January at 00:00",
|
||||
),
|
||||
# Both set is an AND, and rare. Said plainly rather than smoothed into
|
||||
# something that reads like an OR.
|
||||
(
|
||||
{"at": {"weekdays": [0], "days": [1], "times": ["09:00"]}},
|
||||
"On the 1st of each month, if it is a Monday at 09:00",
|
||||
),
|
||||
]
|
||||
for raw, expected in cases:
|
||||
assert rule_service.describe(rule_service.validate(raw), zone=PRAGUE) == expected
|
||||
|
||||
@@ -132,3 +132,73 @@ def test_the_finished_bubble_repeats_the_live_container_s_id():
|
||||
message = (TEMPLATES / "chat/_message.html").read_text(encoding="utf-8")
|
||||
|
||||
assert message.count('id="steps-{{ message.id }}" data-steps') == 2
|
||||
|
||||
|
||||
def test_switching_a_tab_puts_its_body_back_at_the_top():
|
||||
"""A tab is a radio and a panel is shown by CSS, so switching one changes
|
||||
nothing about `.tabs__body` -- the element that actually scrolls. Read half
|
||||
way down a long panel, switch to a short one, and the browser clamps the
|
||||
kept scrollTop to that panel's bottom: what lands on screen is the end of it
|
||||
above a screen of nothing, which reads as a page that failed to load.
|
||||
|
||||
Nothing in CSS can reset a scroll position. Driven under a DOM stub before
|
||||
committing; what is pinned here is that the listener is delegated and keyed
|
||||
on the class rather than on one page's ids, because the next tabbed screen
|
||||
would otherwise have the same bug and no sign of it.
|
||||
"""
|
||||
assert '.closest(".tabs__bar")' in SOURCE or 'closest(".tabs__bar")' in SOURCE
|
||||
assert 'querySelector(".tabs__body")' in SOURCE
|
||||
assert "scrollTop = 0" in SOURCE
|
||||
|
||||
|
||||
def test_the_shell_has_one_line_along_its_bottom_edge():
|
||||
"""The sidebar footer and the composer sit either side of the same vertical
|
||||
edge and were both content-sized, so their top borders met it at different
|
||||
heights and read as one line that had been broken.
|
||||
|
||||
Neither can match the other by accident: the footer's height depends on
|
||||
which entries a reader's permissions allow, and the composer's on how much
|
||||
has been typed. So both take a `min-height` from one token, the way
|
||||
`--header-height` already does this at the top of the shell.
|
||||
"""
|
||||
tokens = (ROOT / "web/static/css/tokens.css").read_text(encoding="utf-8")
|
||||
app = (ROOT / "web/static/css/app.css").read_text(encoding="utf-8")
|
||||
chat = (ROOT / "web/static/css/chat.css").read_text(encoding="utf-8")
|
||||
|
||||
assert "--footer-height:" in tokens
|
||||
# A calc of the pieces the footer is built from, not a measured constant --
|
||||
# a number would stop being true the moment `--control-h` moved.
|
||||
assert "var(--control-h)" in tokens.split("--footer-height:")[1].split(";")[0]
|
||||
assert "min-height: var(--footer-height)" in app
|
||||
assert "min-height: var(--footer-height)" in chat
|
||||
|
||||
|
||||
def test_the_canvas_open_button_posts_through_htmx():
|
||||
"""The button cannot carry an `hx-post`: the path is not known until the
|
||||
dialog closes. So it posts afterwards — through htmx's own `ajax`, so the
|
||||
response lands in the panel exactly as every other canvas action's does.
|
||||
|
||||
`fetch` would mean parsing and swapping the fragment by hand, and then there
|
||||
would be two ways the canvas gets replaced. Driven under a DOM stub before
|
||||
committing; what is pinned here is the shape that keeps them one.
|
||||
"""
|
||||
assert "window.htmx.ajax(" in SOURCE
|
||||
assert '"#canvas-inner"' in SOURCE
|
||||
# The key is `agent:<path>` -- the same key a tool call's read produces, so
|
||||
# a file opened here and one opened by the model are one tab rather than two
|
||||
# spellings of it. That is `canvas.path_key`'s whole job.
|
||||
assert '"agent:" + path' in SOURCE
|
||||
|
||||
|
||||
def test_the_file_picker_asks_for_files():
|
||||
"""One listing serves the directory picker and the file picker, because a
|
||||
second copy is a second place for the path arithmetic to be got subtly
|
||||
differently — and getting it differently means a file that opens to the
|
||||
wrong path, or to nothing."""
|
||||
app = (ROOT / "web/static/js/app.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "pick=file" in app
|
||||
assert "data-file-open" in app or "dataset.fileOpen" in app
|
||||
# Directories stay a step in file mode, or a file two folders down is
|
||||
# unreachable.
|
||||
assert app.count("[data-dir-open]") >= 2
|
||||
|
||||
Reference in New Issue
Block a user