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
+70
View File
@@ -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.