Two selects that never wrote anything, and a queue
The approval card in Auto mode and the missing /effort were one bug. Both
selects hung their hx-patch on an empty sibling form reached by form="…",
and htmx binds a trigger to the annotated element: change fires on the
select and bubbles to its ancestors, which a sibling is not. The live rows
read agent_mode=manual and params_json={} while the browser showed Auto and
Effort: high. policy.py was never involved.
The verb moves onto the control; the empty form stays as value scoping,
which is the half of the CLAUDE.md note that was right. conftest gains
control_named so a test asserts the element carrying the name carries the
verb, rather than asserting the markup that was there throughout.
The composer's highlight was a third instance of the same carelessness in
CSS: .tok-mention is written for the transcript and scoped to nothing, so
the mirror painted its token in accent-coloured monospace over the
textarea's own text. Scoped under .msg; the mirror restates transparency
and font rather than inheriting them, and bleeds by box-shadow.
/effort is now offered before the first prompt and _new_chat reads it.
/index re-walks the project directory on demand, file_write drops the
listing it just invalidated, and the index ladder falls through to SFTP on
a host that refuses exec instead of returning nothing.
A second message during a reply is queued rather than starting a second
concurrent generation: a real Message row with queued set, so it survives a
restart and can be withdrawn. _drain hands one on at the end of a reply,
_inject takes one in at a tool-round boundary so an agent can be steered
mid-task. Stop leaves the queue undelivered. The terminal's Auto toggle
becomes off/copy/send, and send posts straight to the chat without touching
the composer.
@ now offers notes, skills, this chat's attachments and a URL to fetch; a
knowledge base attaches as a reference rather than a copy. copy_document
carries provenance, which was the one attach path that dropped it.
Also fixes an unrelated live bug: the round loop compared against the
global MAX_ROUNDS of 3 while sizing itself from the agent budget of 40, so
agent replies stopped after three rounds and reported forty.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -576,6 +576,24 @@
|
||||
.msg:focus-within .msg__actions { opacity: 1; }
|
||||
.msg__actions .is-copied { color: var(--success); }
|
||||
|
||||
/* --- A turn that is waiting to be sent ------------------------------------
|
||||
Its actions do not fade in on hover like the others: they are the only way
|
||||
to withdraw something that has not happened yet, and a control you have to
|
||||
find by hovering is one somebody will not find. */
|
||||
.msg--queued { opacity: 0.75; }
|
||||
.msg--queued .msg__body {
|
||||
border-inline-start: 2px dashed var(--border-strong);
|
||||
padding-inline-start: var(--sp-2);
|
||||
}
|
||||
.msg__actions--queued { opacity: 1; align-items: center; }
|
||||
.msg__note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* --- Rendered Markdown ---------------------------------------------------- */
|
||||
.msg__body > :first-child { margin-top: 0; }
|
||||
.msg__body > :last-child { margin-bottom: 0; }
|
||||
@@ -761,16 +779,32 @@
|
||||
.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;
|
||||
/* Restated rather than inherited. `color: transparent` on the mirror is an
|
||||
inherited value, and a colour the span declares itself beats it -- which is
|
||||
exactly what the transcript's rule below used to do from across the file,
|
||||
painting the token in accent-coloured mono at 0.95em on top of the
|
||||
textarea's own text. Doubled, and shifted from there on, because the
|
||||
metrics differ. The font must be restated for the same reason. */
|
||||
color: transparent;
|
||||
font: inherit;
|
||||
/* Bled sideways by a shadow, not by padding: a rectangle that spreads cannot
|
||||
move a glyph, and the negative margin that used to do this was the only
|
||||
thing in the mirror that could. */
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.composer__mirror .tok-mention {
|
||||
background: var(--accent-soft);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
.composer__mirror .tok-command {
|
||||
background: var(--leaf-soft);
|
||||
box-shadow: 0 0 0 2px var(--leaf-soft);
|
||||
}
|
||||
.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 {
|
||||
/* The same two in a sent message, where they are text rather than a backdrop --
|
||||
and scoped to it, because unscoped they also matched the mirror's spans. */
|
||||
.msg .tok-mention {
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 2px;
|
||||
background: var(--accent-soft);
|
||||
|
||||
@@ -75,7 +75,11 @@
|
||||
name: "effort",
|
||||
summary: "How hard a reasoning model should think",
|
||||
argument: "low | medium | high",
|
||||
when: function () { return !!chat() && !!el("[data-effort]"); },
|
||||
/* Offered wherever there is a model, not only where the control is.
|
||||
`available()` filters `find()` and `run()` as well as the menu, so a
|
||||
command hidden here is not merely unlisted -- typing it in full stops
|
||||
being a command and gets sent as a message. Better to answer. */
|
||||
when: function () { return !!el('[name="model_id"]'); },
|
||||
run: function (rest) { setEffort(rest); }
|
||||
},
|
||||
{
|
||||
@@ -115,6 +119,12 @@
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "index",
|
||||
summary: "Read the project directory again",
|
||||
when: function () { return !!chat() && isAgent(); },
|
||||
run: function () { reindex(); }
|
||||
},
|
||||
{
|
||||
name: "terminal",
|
||||
summary: "Show or hide the terminal",
|
||||
@@ -163,6 +173,32 @@
|
||||
return function () { window.location = url; };
|
||||
}
|
||||
|
||||
/* --- Reading the project directory again --------------------------------
|
||||
The listing is cached for five minutes and only ever built when a reply
|
||||
starts, so anything done in the terminal panel -- a checkout, a build --
|
||||
is invisible to it until then. This is the "look again now". */
|
||||
var indexing = false;
|
||||
|
||||
function reindex() {
|
||||
if (indexing) return note("Already reading the project directory.");
|
||||
indexing = true;
|
||||
note("Reading the project directory…");
|
||||
post("/api/chats/" + chat() + "/index")
|
||||
.then(function (response) {
|
||||
return response.json().then(function (body) {
|
||||
return { ok: response.ok, body: body };
|
||||
});
|
||||
})
|
||||
.then(function (result) {
|
||||
if (!result.ok) {
|
||||
return note(result.body.detail || "Could not read the project directory.", "error");
|
||||
}
|
||||
note(result.body.message);
|
||||
})
|
||||
.catch(function () { note("Could not read the project directory.", "error"); })
|
||||
.finally(function () { indexing = false; });
|
||||
}
|
||||
|
||||
/* --- Reasoning effort ---------------------------------------------------
|
||||
The command drives the same select the composer shows, so there is one
|
||||
piece of state and the control updates itself when the command is used. */
|
||||
@@ -171,7 +207,11 @@
|
||||
function setEffort(rest) {
|
||||
var select = el("[data-effort]");
|
||||
if (!select) {
|
||||
return note("This model is not marked as a reasoning model.", "error");
|
||||
return note(
|
||||
"This model is not marked as a reasoning model, so effort would do " +
|
||||
"nothing. An administrator can mark it on the model's page.",
|
||||
"error"
|
||||
);
|
||||
}
|
||||
var wanted = (rest || "").trim().toLowerCase();
|
||||
if (!wanted) {
|
||||
|
||||
@@ -273,6 +273,24 @@
|
||||
} else if (option.dataset.mentionKnowledge) {
|
||||
body.append("document_id", option.dataset.mentionKnowledge);
|
||||
attach("/api/files/from-knowledge", body);
|
||||
} else if (option.dataset.mentionNote) {
|
||||
body.append("note_id", option.dataset.mentionNote);
|
||||
attach("/api/files/from-note", body);
|
||||
} else if (option.dataset.mentionSkill) {
|
||||
body.append("skill_id", option.dataset.mentionSkill);
|
||||
attach("/api/files/from-skill", body);
|
||||
} else if (option.dataset.mentionAttachment) {
|
||||
body.append("attachment_id", option.dataset.mentionAttachment);
|
||||
attach("/api/files/from-attachment", body);
|
||||
} else if (option.dataset.mentionUrl) {
|
||||
body.append("url", option.dataset.mentionUrl);
|
||||
attach("/api/files/link", body);
|
||||
} else if (option.dataset.mentionBase) {
|
||||
/* Not an attachment: nothing is copied, and what changes is what this
|
||||
chat is allowed to search. It goes on the chat, so the route is the
|
||||
chat's own. */
|
||||
body.append("base_id", option.dataset.mentionBase);
|
||||
attach("/api/chats/" + where.chatId + "/bases", body);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,11 @@
|
||||
/* Whether this shell tells us where commands begin and end -- "live",
|
||||
"loading" or "none". Everything the three buttons do keys off it. */
|
||||
var integration = "loading";
|
||||
var autoSend = false;
|
||||
/* "off" | "copy" | "send". Three states rather than a boolean, because the
|
||||
old one did the wrong one of them: it appended into the composer, on top of
|
||||
whatever was being typed there. A select rather than a cycling button --
|
||||
a button cannot say which of three states it is in. */
|
||||
var autoMode = "off";
|
||||
var lastCommand = null;
|
||||
|
||||
function say(text, isError) {
|
||||
@@ -169,8 +173,12 @@
|
||||
and the buttons fetch what they need when they are pressed. */
|
||||
if (integration !== "live") { integration = "live"; applyIntegration(); }
|
||||
showLast(payload.command);
|
||||
if (autoSend) {
|
||||
capture(true).then(function (text) { intoComposer(text, true); });
|
||||
if (autoMode !== "off") {
|
||||
capture(true).then(function (text) {
|
||||
if (!text) return;
|
||||
if (autoMode === "copy") return intoComposer(text, true);
|
||||
sendStraightToChat(text);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -287,10 +295,10 @@
|
||||
var usable = integration === "live";
|
||||
auto.disabled = !usable;
|
||||
auto.title = usable
|
||||
? "Attach every command you run to your next message"
|
||||
? "What to do with each command you run"
|
||||
: "This shell did not load LLeMbas's command markers, so there is no way " +
|
||||
"to tell where one command's output ends.";
|
||||
if (!usable && autoSend) setAuto(false);
|
||||
if (!usable && autoMode !== "off") setAuto("off");
|
||||
}
|
||||
|
||||
function showLast(command) {
|
||||
@@ -301,16 +309,32 @@
|
||||
slot.textContent = lastCommand ? lastCommand.summary : "";
|
||||
}
|
||||
|
||||
function setAuto(on) {
|
||||
autoSend = !!on;
|
||||
var button = panel.querySelector("[data-terminal-auto]");
|
||||
if (button) {
|
||||
button.setAttribute("aria-pressed", autoSend ? "true" : "false");
|
||||
button.classList.toggle("is-active", autoSend);
|
||||
}
|
||||
say(autoSend
|
||||
? "Every command you run will be attached to your next message."
|
||||
: "Commands are no longer attached automatically.");
|
||||
var AUTO_SAID = {
|
||||
off: "Commands are no longer attached automatically.",
|
||||
copy: "Every command you run will be put into the message box.",
|
||||
send: "Every command you run will be sent as a message on its own."
|
||||
};
|
||||
|
||||
function setAuto(mode) {
|
||||
autoMode = AUTO_SAID[mode] ? mode : "off";
|
||||
var select = panel && panel.querySelector("[data-terminal-auto]");
|
||||
if (select && select.value !== autoMode) select.value = autoMode;
|
||||
say(AUTO_SAID[autoMode]);
|
||||
}
|
||||
|
||||
/* Sent, not typed. The composer is left entirely alone -- somebody may be
|
||||
half-way through a sentence in it, and overwriting that is the complaint
|
||||
this replaces. The thread receives whatever the server decides the message
|
||||
is: a streaming pair, or a single queued bubble if a reply is already being
|
||||
written. Nothing here needs to know which. */
|
||||
function sendStraightToChat(text) {
|
||||
var url = panel.dataset.url.replace(/\/terminal\/ws$/, "/messages");
|
||||
if (!window.htmx) return;
|
||||
window.htmx.ajax("POST", url, {
|
||||
target: "#thread",
|
||||
swap: "beforeend",
|
||||
values: { content: text }
|
||||
});
|
||||
}
|
||||
|
||||
/* A selection always wins, in every state. People rely on it, and it is the
|
||||
@@ -427,10 +451,11 @@
|
||||
event.preventDefault();
|
||||
return copyToClipboard();
|
||||
}
|
||||
if (event.target.closest("[data-terminal-auto]")) {
|
||||
event.preventDefault();
|
||||
return setAuto(!autoSend);
|
||||
}
|
||||
});
|
||||
|
||||
panel.addEventListener("change", function (event) {
|
||||
var select = event.target.closest("[data-terminal-auto]");
|
||||
if (select) setAuto(select.value);
|
||||
});
|
||||
|
||||
/* xterm holds colours as values, not as variables, so a theme change has
|
||||
|
||||
Reference in New Issue
Block a user