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:
Jaroslav Beneš
2026-08-05 22:37:47 +02:00
parent a195e1b3ed
commit 00ce04addf
25 changed files with 981 additions and 73 deletions
+11 -1
View File
@@ -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",
},
)
+6
View File
@@ -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 "",
},
)
+31
View File
@@ -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
View File
@@ -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,
}