The menu that never appeared, and the reason it never did
composer.js built its menu lazily inside show(), and refresh() wrote list.innerHTML before calling it. `list` is null until build() has run, so the first `/` or `@` ever typed threw a TypeError and took the handler with it. The menu has never appeared in any browser. That is why /compact "isn't there": nothing was. I shipped it having only run `node --check`, which parses the file happily. So this also brings the thing that catches it: a DOM stub driven under node -- not committed, hard rule 1 stands, it is an instrument like curl. It reproduced the crash in one run and immediately found two more: choosing a command from the menu left `/help` sitting in the box so the next Enter ran it again, and Tab completed nothing. Tab now completes and Enter runs, which is the split that matters for a command taking an argument. `.select--sm` was used three times and defined nowhere. I deleted the copy in chat.css and left a comment saying it "is defined once, in app.css", where it did not exist -- so those selects fell back to plain `.select`: width 100% in a flex row where four siblings wanted the same, all of them shrinking together until each was a few characters wide, and half a rem taller than everything beside them. That was the whole of "the connection switch needs to be wider". The connection and directory move to the topbar. They cannot change -- update_chat refuses both with a 409 -- so they are facts about the chat, of a kind with the Temporary badge, not controls on the message. The mode stays by the box. Compaction says it is working. It makes a model call that takes seconds and had no indicator anywhere: `hx-indicator` appears nowhere in this codebase, and the Generation.status channel that says "Summarising earlier messages…" for the automatic path cannot be borrowed, because it lives in the streaming bubble and this endpoint refuses to run while any message is unfinished. The overflow menu now runs the same code as /compact rather than posting for itself, so there is one implementation, one spinner, and one place the endpoint's four carefully written 409s finally reach somebody. /effort, low medium high, per chat with a per-model default. It goes out twice because there is no field that works everywhere: OpenAI and vLLM read reasoning_effort, llama.cpp's own docs say other values "have no effect" and its maintainer says the field "simply gets dropped without error or logging" -- what reaches gpt-oss behind it is chat_template_kwargs. Both are sent, and only once an effort has been chosen, so a provider strict about unknown parameters sees exactly the request it always did until somebody opts in. The control appears only on a model marked `reasoning`, a flag that has existed since the beginning with no reader at all. Mentions and recognised commands are marked as you type -- a mirror behind the textarea holding the same text with every character transparent, contributing nothing but a rounded rectangle, so a pixel of drift is a misplaced rectangle rather than a doubled glyph. A command is marked only when it resolves, so `/thoughts on this` visibly is not one before you send it. And again in the transcript, where user turns had no render step at all and now escape before they inject. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -676,6 +676,43 @@ body.is-resizing .terminal__screen { pointer-events: none; }
|
||||
}
|
||||
.topbar__actions { display: flex; align-items: center; gap: var(--sp-2); flex: none; }
|
||||
|
||||
/*
|
||||
Which machine an agent chat runs on, and where.
|
||||
|
||||
Its own flex item between the title and the actions, and it shrinks before
|
||||
the title does: `flex: 0 1 auto` with `min-width: 0` means a long path gives
|
||||
up its characters first, which is right -- a truncated title is a chat you
|
||||
cannot identify, a truncated path is still a path you recognise. Below 64rem
|
||||
it goes entirely; the terminal button beside it already says which machine.
|
||||
*/
|
||||
.topbar__where {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 24rem;
|
||||
padding: 0 var(--sp-2);
|
||||
height: var(--control-h-sm);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-hover);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.topbar__where-name { flex: none; }
|
||||
.topbar__where-dir {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink-faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 64rem) {
|
||||
.topbar__where { display: none; }
|
||||
}
|
||||
|
||||
/* The model picker: an avatar and a select sharing one frame, so it reads as a
|
||||
single control rather than two things that happen to be adjacent. */
|
||||
.model-select {
|
||||
@@ -1057,7 +1094,29 @@ body.is-resizing .terminal__screen { pointer-events: none; }
|
||||
.dialog__results .picker__list { max-height: none; }
|
||||
|
||||
.picker__search { padding: var(--sp-2); border-bottom: 1px solid var(--border); }
|
||||
/*
|
||||
Small controls, declared here because this is the file every page loads.
|
||||
|
||||
`.select--sm` was used in three places and defined in none: chat.css had a
|
||||
copy, it was deleted as a duplicate, and the replacement never landed. A
|
||||
select with only `.select` takes `width: 100%` and `--control-h` -- so in a
|
||||
flex row every one of them asked for the whole width, all of them shrank
|
||||
together until each was a few characters wide, and each stood half a rem
|
||||
taller than the buttons beside it. That is what "the connection switch needs
|
||||
to be wider" was.
|
||||
|
||||
`width: auto` is the important half: a small select should be as wide as its
|
||||
longest option, not as wide as it can get away with.
|
||||
*/
|
||||
.input--sm { height: var(--control-h-sm); font-size: var(--text-xs); }
|
||||
.select--sm {
|
||||
height: var(--control-h-sm);
|
||||
padding: 0 var(--sp-6) 0 var(--control-px-sm);
|
||||
font-size: var(--text-xs);
|
||||
width: auto;
|
||||
max-width: 14rem;
|
||||
background-position: right 0.75rem center, right 0.5rem center;
|
||||
}
|
||||
.picker__list { max-height: 22rem; overflow-y: auto; scrollbar-width: thin; padding: var(--sp-1); }
|
||||
|
||||
.picker__option {
|
||||
|
||||
@@ -243,6 +243,18 @@
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
|
||||
/* Something is happening to the whole thread rather than to one message --
|
||||
compaction, which makes a model call and can take a while. Shaped like a
|
||||
turn so it sits in the column rather than floating over it. */
|
||||
.thread__working {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) 0;
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* --- Metrics ---------------------------------------------------------------
|
||||
What a reply cost, under the bubble. Quiet by default: it is reference, not
|
||||
something to read every time.
|
||||
@@ -697,20 +709,76 @@
|
||||
box-shadow: var(--ring);
|
||||
}
|
||||
|
||||
.composer__input {
|
||||
/*
|
||||
The text, and the mirror behind it.
|
||||
|
||||
Every property that affects where a character lands is declared once, on both
|
||||
-- font, size, line height, padding, wrapping. A single difference and the
|
||||
rectangles slide off the words they are marking. `font: inherit` on the
|
||||
textarea is not enough: a textarea's default font is not the page's.
|
||||
*/
|
||||
.composer__field { position: relative; }
|
||||
|
||||
.composer__input,
|
||||
.composer__mirror {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
resize: none;
|
||||
margin: 0;
|
||||
padding: var(--sp-2) var(--sp-2) var(--sp-1);
|
||||
border: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
letter-spacing: normal;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
word-break: normal;
|
||||
max-height: 20rem;
|
||||
}
|
||||
|
||||
.composer__input {
|
||||
position: relative;
|
||||
display: block;
|
||||
background: none;
|
||||
resize: none;
|
||||
color: var(--ink);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.composer__input:focus { outline: none; }
|
||||
|
||||
/* Behind, and contributing nothing but background rectangles: its own text is
|
||||
transparent, so the glyphs on screen are always the textarea's. That is what
|
||||
makes a pixel of drift invisible rather than a doubled letter. */
|
||||
.composer__mirror {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
.composer__mirror .tok-mention,
|
||||
.composer__mirror .tok-command {
|
||||
border-radius: var(--radius-sm);
|
||||
/* Bled sideways so the rectangle does not sit hard against the next word,
|
||||
and the negative margin keeps the text metrics identical. */
|
||||
padding: 0 2px;
|
||||
margin: 0 -2px;
|
||||
}
|
||||
.composer__mirror .tok-mention { background: var(--accent-soft); }
|
||||
.composer__mirror .tok-command { background: var(--leaf-soft); }
|
||||
|
||||
/* The same two in a sent message, where they are text rather than a backdrop. */
|
||||
.tok-mention {
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 2px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
/* Everything that acts on the message, on one line under it. It wraps rather
|
||||
than scrolls: on a narrow window the context controls drop to their own row
|
||||
and attach/send stay where the thumb expects them. */
|
||||
@@ -740,30 +808,6 @@
|
||||
.composer__dir { max-width: 16rem; font-family: var(--font-mono); font-weight: 400; }
|
||||
.composer__dir-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* The connection and directory of a chat already under way. Not a control:
|
||||
update_chat refuses to change either, so showing them as one is honest. */
|
||||
.composer__where {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
min-width: 0;
|
||||
max-width: 20rem;
|
||||
padding: 0 var(--sp-2);
|
||||
height: var(--control-h-sm);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-hover);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.composer__where-dir {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--ink-faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composer__hint {
|
||||
margin: var(--sp-2) 0 0;
|
||||
font-size: var(--text-xs);
|
||||
@@ -807,6 +851,20 @@
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* What the keys do, pinned to the bottom of the menu. */
|
||||
.composer-menu__keys {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.picker__group {
|
||||
margin: 0;
|
||||
padding: var(--sp-2) var(--sp-3) var(--sp-1);
|
||||
@@ -1016,6 +1074,11 @@
|
||||
exactly the drift --control-h exists to prevent.
|
||||
*/
|
||||
|
||||
/* The connection. Wide enough to read a machine's name without truncating it
|
||||
to three characters, which is what it did while `.select--sm` did not exist.
|
||||
A floor rather than a fixed width, so a long name still grows. */
|
||||
.composer__connection { min-width: 10rem; }
|
||||
|
||||
/*
|
||||
Chat or Agent: one control with two halves, not two buttons that happen to be
|
||||
adjacent. Radios underneath, because the choice is permanent and mutually
|
||||
|
||||
Reference in New Issue
Block a user