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
co-authored by Claude Opus 5
parent 7ff4c2c0aa
commit 4c78215e31
23 changed files with 910 additions and 72 deletions
+10
View File
@@ -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); }
+32 -12
View File
@@ -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;
+8
View File
@@ -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 {
+17
View File
@@ -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. */