The chat's settings at the top right of its header; the composer keeps the message
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 4s

Model, effort, subagents and an agent chat's mode were chips inside the composer, cut off or out
of reach with a long model name or every chip on. They are in the chat's header now, beside the
Terminal, the activity panel, a Tools and skills button and the menu; a new agent chat's computer
and folder sit beside the title (where an open one says where it works), and its Terminal is the
header's button as an open chat's is. The composer keeps text, attach, Dictate, Draw a picture
and Send/Stop. Where the header has no room -- measured by fitHead on every drawing and resize:
the settings cut at their end, or the place squeezed under 10rem -- they fold behind Chat
settings, which opens them as a wrapped row under the header. The new chat page shows its header.

Tests (tests/ui CI2): the composer holds only the message's controls; at 1920/1280/1024/800/390
px, for a chat and an agent chat with long model names and every chip, every button is inside the
window and not clipped by its container, nothing is drawn over another, folded or not, and the
model's menu opens; a new agent chat at 1280 and 390. Earlier tests follow the chips' new places.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 16:30:15 +00:00
1 parent fe4bf064f5
commit b13cfa4ace
8 files changed
+287 -52

No files matched your search

+70 -17
View File
@@ -731,8 +731,49 @@ function renderHead(chat) {
}
// An archived chat says so where it is read, with the way back into the sidebar.
if (chat.archived) tools.append(h("button", { type: "button", class: "btn btn--sm head__unarchive", text: t("Restore chat"), title: t("Archived"), onclick: () => unarchiveChat(open?.chat || chat) }))
if (!chat.archived) tools.append(h("button", { type: "button", class: "tool", id: "scope-open", "aria-label": t("Tools and skills…"), title: t("Tools and skills…"), "aria-haspopup": "dialog", onclick: (e) => scopePopover(e.currentTarget, open?.chat || chat) }, icon(ICONS.tools)))
tools.append(activityButton())
tools.append(h("button", { type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
fitHead()
}
// --- The chat's settings, top right ---------------------------------------------------------------
//
// The model, how hard it thinks, the subagents and an agent chat's mode sit in the chat's header,
// beside the Terminal, the activity panel, the tools and skills and the chat's menu; the composer
// keeps only what belongs to the message. Where the header has no room for them all on its line,
// they fold behind one button (Chat settings), which opens them as a row of their own under the
// header -- wrapped, every one reachable, never a line cut off at its end. Whether they fit is
// measured, not guessed: a long model name or one more chip folds them at any width.
function fitHead() {
const head = $("chat-head")
const box = $("chat-settings")
if (!head || !box) return
const any = box.childElementCount > 0
head.dataset.fold = "false"
// Folded when the settings are cut at their end, or when they leave where the chat works (its
// folder) less than its own width or 10rem, whichever is less.
const place = $("chat-place")
const room = Math.min(place.scrollWidth, 10 * parseFloat(getComputedStyle(document.documentElement).fontSize))
const squeezed = place.childElementCount > 0 && place.clientWidth + 1 < room
const fold = any && (box.scrollWidth > box.clientWidth + 1 || squeezed)
head.dataset.fold = String(fold)
if (!fold) head.dataset.unfold = "false"
$("chat-fold").hidden = !fold
$("chat-fold").setAttribute("aria-expanded", String(head.dataset.unfold === "true"))
}
function initHead() {
$("chat-fold").replaceChildren(icon(ICONS.sliders))
$("chat-fold").hidden = true
$("chat-fold").addEventListener("click", () => {
const head = $("chat-head")
head.dataset.unfold = String(head.dataset.unfold !== "true")
$("chat-fold").setAttribute("aria-expanded", head.dataset.unfold)
if (head.dataset.unfold === "true") $("chat-settings").querySelector("button")?.focus()
})
new ResizeObserver(() => fitHead()).observe($("chat-head"))
}
/** The models this chat may switch to (its data group's), or the account's when that fails. */
@@ -966,7 +1007,8 @@ let chipsDrawn = 0 // which drawing is the latest: an older one's late answers a
/** The chips for what the composer is writing to: a new chat (`draft`) or the open chat. */
async function renderChips() {
const box = $("composer-chips")
const box = $("chat-settings")
const message = $("composer-chips")
const drawing = ++chipsDrawn
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
if (draft) {
@@ -974,18 +1016,20 @@ async function renderChips() {
const models = ctx.models()
const model = models.find((m) => m.id === draft.model)
const parts = []
const where = [] // an agent chat's computer and folder: where it works, beside the title
if (draft.kind === "agent") {
const others = otherDevices()
if (others.length) parts.push(deviceChip(others))
if (others.length) where.push(deviceChip(others))
if (draftHere()) {
const roots = s?.remote?.enabled ? s.remote.roots : []
if (!roots.includes(draft.dir)) draft.dir = roots[0] || ""
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
where.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
} else {
parts.push(elsewhereFolderChip(draftDevice(), draft.dir, (d) => ((draft.dir = d), renderChips())))
where.push(elsewhereFolderChip(draftDevice(), draft.dir, (d) => ((draft.dir = d), renderChips())))
}
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
}
$("chat-place").replaceChildren(...where)
const shell = terminalTarget()
aimTerminal(shell)
parts.push(modelChip(models, draft.model, (id) => {
@@ -994,8 +1038,11 @@ async function renderChips() {
renderChips()
}, mayPick))
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
if (draft.kind === "chat" && picturesOffered()) parts.push(pictureChip())
else wantPicture = false
if (draft.kind === "chat" && picturesOffered()) message.replaceChildren(pictureChip())
else {
wantPicture = false
message.replaceChildren()
}
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), { cap: subagentsCap(), own: namedOnly(model?.subagents) }))
// An agent chat's subagents run on its device: offered as the server says for that device.
const device = draft.kind === "agent" ? draftDevice() : ""
@@ -1007,20 +1054,20 @@ async function renderChips() {
if (f && drawing === chipsDrawn && draft?.kind === "agent") renderChips()
})
}
// Last, so the chat's own settings come first where the line scrolls.
if (shell) {
const b = chip({ ico: ICONS.terminal, label: t("Terminal"), menuish: false, cls: "cchip--terminal", onclick: toggleTerminal })
b.dataset.terminalToggle = ""
b.setAttribute("aria-pressed", String(terminalOpen()))
b.setAttribute("aria-controls", "terminal")
parts.push(b)
}
// The Terminal, as an open chat has it: in the header's tools.
$("chat-tools").replaceChildren(...(shell ? [terminalButton()] : []))
box.replaceChildren(...parts.filter(Boolean))
fitHead()
return
}
const chat = open?.chat
aimTerminal(terminalTarget())
if (!chat?.kind) return box.replaceChildren()
if (!chat?.kind) {
box.replaceChildren()
message.replaceChildren()
fitHead()
return
}
const save = async (body) => {
try {
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body)
@@ -1041,12 +1088,17 @@ async function renderChips() {
if (chat.kind === "agent") parts.push(modeChip(chat.mode || "edit", (m) => save({ mode: m })))
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
if (chat.kind === "chat" && picturesOffered()) parts.push(pictureChip())
else wantPicture = false
if (chat.kind === "chat" && picturesOffered() && !chat.archived) message.replaceChildren(pictureChip())
else {
wantPicture = false
message.replaceChildren()
}
box.replaceChildren(...parts.filter(Boolean))
fitHead()
const offer = await teamOffered(chat)
if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
box.append(agentsChip(open.chat.agents || chat.agents, (a) => save({ agents: a }), offer))
fitHead()
}
/**
@@ -1505,5 +1557,6 @@ export function initChat(context) {
}
})
onAgentChange(renderNotice)
initHead()
showComposer(false)
}
+2
View File
@@ -104,6 +104,8 @@ export const ICONS = {
mic: ["M8 1.5a2.5 2.5 0 012.5 2.5v4a2.5 2.5 0 01-5 0V4A2.5 2.5 0 018 1.5zM8 3a1 1 0 00-1 1v4a1 1 0 002 0V4a1 1 0 00-1-1z", "M3.5 7.5H5a3 3 0 006 0h1.5a4.5 4.5 0 01-3.75 4.44V14h-1.5v-2.06A4.5 4.5 0 013.5 7.5z"],
speaker: ["M2 6h2.5L8 3v10L4.5 10H2z", "M10.2 5.2a4 4 0 010 5.6l-1.06-1.06a2.5 2.5 0 000-3.48z", "M12.3 3.1a7 7 0 010 9.8l-1.06-1.06a5.5 5.5 0 000-7.68z"],
picture: ["M1.5 2.5h13v11h-13zM3 4v6.4l2.75-2.75 2.5 2.5 1.75-1.75L13 11.4V4z", "M10.5 5a1.25 1.25 0 110 2.5 1.25 1.25 0 010-2.5z"],
tools: "M10.6 1.5a3.9 3.9 0 00-3.7 5.1L1.8 11.7a1.6 1.6 0 002.3 2.3l5.1-5.1a3.9 3.9 0 005.1-3.7l-.1-.9-2.1 2.1-1.9-.4-.4-1.9 2.1-2.1zM3 12.4l.6.6 5.3-5.3-.6-.6z",
sliders: ["M2 4h7v1.5H2zM12 4h2v1.5h-2zM2 10.5h2V12H2zM7 10.5h7V12H7z", "M9.5 2.75h2v4h-2zM4.5 9.25h2v4h-2z"],
fileText: ["M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z", "M6 8h4v1H6zM6 10h4v1H6z"],
fileCode: ["M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z", "M6.7 7.6l.7.7-1.2 1.2 1.2 1.2-.7.7-1.9-1.9z", "M9.3 7.6l1.9 1.9-1.9 1.9-.7-.7 1.2-1.2-1.2-1.2z"],
drive: ["M1.5 4h13v8h-13zM3 5.5v5h10v-5z", "M10.5 7.25h1.5v1.5h-1.5z"],