From 82f8ed24edd8c74aafa915842002b02c29616aae Mon Sep 17 00:00:00 2001 From: Hako Date: Mon, 5 Oct 2026 19:32:33 +0900 Subject: [PATCH 1/5] feat(chat): the model and the level in one quiet pill, with a readable name where the id has one The agent mark, the model, the reasoning level and the context ring sit in one pill in the composer's controls row. It is display only: no role, no focus, no hover. DONE stays drawn outside it. modelLabel (src/lib/modelName.ts) names an id only when it matches a vendor's regular pattern whole: claude--[-] (also behind anthropic/), gpt-, glm-. Every other id is drawn as received in the mono face, so no suffix is dropped and no name is guessed. When the label steps out (composerModelDraw, unchanged) the pill goes with it and the ring stays. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 +++ DESIGN.md | 33 ++++++++-- scripts/composer-fit-demo-regression.ts | 83 +++++++++++++++++++++---- src/AGENTS.md | 2 +- src/components/Composer.css | 70 ++++++++++++++++++--- src/components/Composer.tsx | 27 +++++--- src/lib/modelName.test.ts | 36 +++++++++++ src/lib/modelName.ts | 43 +++++++++++++ 8 files changed, 269 insertions(+), 34 deletions(-) create mode 100644 src/lib/modelName.test.ts create mode 100644 src/lib/modelName.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index db4b03644a..912a993c4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ## [Unreleased] ### Changed +- In the chat's message box the agent's mark, the model, the reasoning level and the context + ring sit together in one quiet pill, and the model is shown by its name where the id is a + regular one: `claude-opus-5-5` reads **Opus 5.5**, `claude-sonnet-5` **Sonnet 5**, `gpt-5.6` + **GPT-5.6**, `glm-5.3` **GLM-5.3**, and the level follows as **High**. No name is ever + guessed: any other id (a dated snapshot, a suffix such as `gpt-5.6-sol`, another provider's + prefix, another vendor) is shown exactly as received, in the code font. Hovering the name + shows the id as received. The pill only shows the model, it is not a button yet. Where the + pill does not fit beside Queue (on a phone with a background-task chip, always) it steps + aside whole while Queue is showing, and the context ring stays. - In the chat, the line with the model sits inside the message box, as its last row, instead of above it, on a phone as on a desktop. It shows the agent's mark, background tasks, the model, the reasoning level as one word (`high`) and the context ring. The agent's written name and the diff --git a/DESIGN.md b/DESIGN.md index c867ba5861..a9eeffe3ae 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -597,24 +597,45 @@ One set for both themes: the card is island black wherever it shows. `--status-blocked` on hover and focus. Not connected, Stop is disabled and loses its fill (`--border-strong` outline, `--text-dim` glyph). - The status content (`.composer-status`, `role="status"`) sits between the two control groups, - pushed to the button's side. It draws, in `--text-dim` at `--fs-xs`: the agent mark, the - model, the reasoning level as one word with no outline (`high`), the context ring, and the - uploading or reconnecting sentence. The background-task chip is a button in the left controls; + pushed to the button's side. It draws, at `--fs-xs`: the model pill, `DONE`, and the uploading + or reconnecting sentence in `--text-dim`. The background-task chip is a button in the left controls; its count is repeated here as `.visually-hidden` text, so a change is still announced. The agent's written name, its separator, the state words `READY` / `RUN` / `INPUT` and the sentence `Reasoning high` stay in it for assistive tech only (`.visually-hidden`): the header names the pane, and the state is told by Stop, the live row - and the prompt card. `DONE` alone is drawn, after the mark, in `--status-done` caps: nothing + and the prompt card. `DONE` alone is drawn, before the pill, in `--status-done` caps: nothing else in the chat says a turn ended and was not seen yet, and on a phone the sidebar's label is in a closed drawer. +- The model pill (`.composer-pill`) holds the agent mark, the model, the reasoning level and the + context ring in one quiet surface: `--bg-hover` fill, `--radius-pill`, `--control-h` tall, + `--space-3` inline padding, `--space-2` between its parts. The model is in `--text` at + `--fw-medium`; the level follows a middle dot in `--text-dim`, as the agent's own word with + only its first letter drawn as a capital (`text-transform`, the text is not rewritten). On the + fill the ring's track is `--border-strong`. It is display only: a `span` with no role, no + focus, no hover or pressed state, no pointer cursor and no chevron; the ring inside it is the + one thing to press. The `title` of the model is the id as received, and the level's is its + sentence. A pane that names no model draws no pill: the mark and the ring stand alone. +- The model is drawn by name only where its id is one `modelLabel` (`src/lib/modelName.ts`) can + name for certain, from the vendor's own regular naming and matched whole: + `claude--[-]`, also behind `anthropic/`, is ` .` + (`Opus 5.5`, `Sonnet 5`); `gpt-` is `GPT-`; `glm-` is + `GLM-`. A name is never guessed. Any other id — a dated snapshot, a tier or product + suffix, another provider's prefix, another vendor — is drawn exactly as received in + `--font-mono` at regular weight (`.composer-model.is-id`), so it reads as an identifier and no + suffix is dropped. - What does not fit the row gives way in this order: the task chip's words (icon and count below a `640px` card — the card's own width, `composerStatusCompact`, not the window's); then the model label, decided from the measured row and not from a width (`composerModelDraw`), so the mic, a long model id, the language and the opened context text all count: the row is measured again when that text opens or closes. While Queue is showing, a label that does not fit steps out whole — the mark, the model and the level are read, not drawn, and are back once - the draft is sent, held or cleared — so Queue keeps its word and no name is cut mid-word. + the draft is sent, held or cleared — so Queue keeps its word and no name is cut mid-word. The + pill goes with its label: the ring then stands alone, with no empty pill around it. On a + `390px` phone the pill never fits beside the task chip and Queue together, so it is out for as + long as Queue shows there. Without Queue the level steps out whole first, never drawn in part; a name still too long is - ellipsized as the last resort, then the opened context text. The context ring is never cut. + ellipsized inside the pill as the last resort, then the opened context text. The context ring + is never cut. Over a sentence that took a line of its own the pill is `--chip-h` tall, so the + card does not grow. - Queue is drawn only while the agent works, the bridge is live and the box holds a draft or a file still uploading (`composerQueueShown`): with an empty box Stop is the one resting control, also when an attachment tile is left in it without its mention, since only the text is sent. The rule diff --git a/scripts/composer-fit-demo-regression.ts b/scripts/composer-fit-demo-regression.ts index 0c0e239ce1..7e3c4768a5 100644 --- a/scripts/composer-fit-demo-regression.ts +++ b/scripts/composer-fit-demo-regression.ts @@ -13,7 +13,10 @@ import panes from "../site/demo/fixtures/panes.json"; // All files and HTTP traffic stay in this disposable, loopback-only app; no herdr session is opened. const repo = join(import.meta.dir, ".."); const app = mkdtempSync(join(tmpdir(), "herdr-composer-fit-demo-")); +// an id the composer cannot name is drawn as received, in the mono face: the widest label there is const LONG_MODEL = "gpt-5.6-sol-codex-preview-2026-10"; +// ids it names: "GPT-5.6" and "Opus 5.5" +const NAMED = ["gpt-5.6", "claude-opus-5-5"] as const; interface Case { model: string; status?: "working" | "idle"; mic?: boolean; ring?: boolean } type Draw = "full" | "no-effort" | "out"; @@ -39,6 +42,22 @@ const measure = (page: Page) => page.evaluate(() => { return { draw: status.getAttribute("data-model") ?? "full", hintAlone: status.hasAttribute("data-hint-alone"), card: { width: surface.width, height: surface.height }, status: status.getBoundingClientRect().height, + pill: (() => { + const pill = document.querySelector(".composer-pill")!; + const box = pill.getBoundingClientRect(), row = status.getBoundingClientRect(); + const inside = (selector: string): boolean => { + const item = pill.querySelector(selector)?.getBoundingClientRect(); + return item !== undefined && item.left >= box.left - 0.5 && item.right <= box.right + 0.5 && item.top >= box.top - 0.5 && item.bottom <= box.bottom + 0.5; + }; + const style = getComputedStyle(pill); + return { + width: box.width, height: box.height, inRow: box.left >= row.left - 0.5 && box.right <= row.right + 0.5, + holds: inside(".agent-mark") && inside(".composer-model") && (pill.querySelector(".composer-context") === null || inside(".composer-context")), + // it only shows: nothing about it says it can be pressed + inert: pill.tagName === "SPAN" && !pill.hasAttribute("role") && !pill.hasAttribute("tabindex") && style.cursor === "auto", + }; + })(), + name: document.querySelector(".composer-model")?.textContent ?? null, named: !document.querySelector(".composer-model")?.classList.contains("is-id"), label: part(".composer-model-info"), model: part(".composer-model"), effort: part(".composer-reasoning"), ring: part(".composer-context"), ringText: part(".composer-context-text"), hint: part(".composer-status-hint"), queue: document.querySelector(".composer-queue-button") !== null, chip: document.querySelector(".bg-tasks-toggle") !== null, mic: document.querySelector(".composer-controls-left .voice-mic") !== null, @@ -56,6 +75,9 @@ const drawn = async (page: Page, draw: Draw, what: string): Promise => { if (draw === "full" || row.queue) assert.ok(!row.model?.clipped && !row.effort?.clipped, `${what}: no word of the model label is cut: ${JSON.stringify(row)}`); if (draw === "no-effort") assert.ok((row.effort?.width ?? 0) <= 1, `${what}: the level is read, not drawn: ${JSON.stringify(row)}`); if (draw === "out") assert.ok((row.label?.width ?? 0) <= 1, `${what}: the name and the level are read, not drawn: ${JSON.stringify(row)}`); + // the pill goes with the label: no empty pill is left around the ring. Drawn, it holds the mark, the name and the ring + if (draw === "out") assert.equal(row.pill.width, 0, `${what}: no pill is drawn without its label: ${JSON.stringify(row)}`); + else assert.ok(row.pill.width > 1 && row.pill.inRow && row.pill.holds && row.pill.inert, `${what}: the mark, the name and the ring sit in one pill that is not a control: ${JSON.stringify(row)}`); assert.deepEqual(row.overlaps, [], `${what}: nothing overlaps its neighbour`); assert.equal(row.overflowing, false, `${what}: the page does not scroll sideways`); return row; @@ -162,9 +184,21 @@ try { try { const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); try { + // a readable name for an id the composer can name, and any other id exactly as received + for (const [model, name, named] of [["claude-opus-5-5", "Opus 5.5", true], ["gpt-5.6", "GPT-5.6", true], ["gpt-5.6-sol", "gpt-5.6-sol", false], [LONG_MODEL, LONG_MODEL, false]] as const) await withCard(browser, 1440, { model }, async (page) => { + const row = await drawn(page, "full", `${model} in a wide window`); + assert.deepEqual([row.name, row.named], [name, named]); + assert.equal(await page.locator(".composer-model").getAttribute("title"), model, "the id as received is the name's title"); + assert.equal(await page.locator(".composer-model").evaluate((node) => getComputedStyle(node).fontFamily.includes("monospace")), !named, "an id with no name is drawn in the mono face"); + // the level is the agent's own word, drawn with a capital and never rewritten + assert.deepEqual(await page.locator(".composer-reasoning-short").evaluate((node: HTMLElement) => [node.textContent, getComputedStyle(node).textTransform]), ["xhigh", "capitalize"]); + assert.equal(row.pill.height, await page.locator(".composer-attach").evaluate((node) => node.getBoundingClientRect().height), "the pill is as tall as a control"); + }); + console.log("PASS the model is named only where its id is one the composer can name, and the pill holds it at a control's height"); + // The context ring's number opens inside the row and the card keeps its size: the label is - // fitted again. With Queue it steps out whole and comes back when the number closes - for (const model of ["gpt-5.6", "gpt-5.6-sol"]) await withCard(browser, 390, { model }, async (page) => { + // fitted again. With Queue it steps out whole, its pill with it, and comes back when the number closes + for (const model of NAMED) await withCard(browser, 800, { model }, async (page) => { await draft(page, true); const closed = await drawn(page, "full", `${model}, a draft`); assert.ok(closed.chip && closed.ring, "the task chip and the context ring are in the row"); @@ -175,10 +209,24 @@ try { await toggleRing(page, false, "full"); await drawn(page, "full", `${model}, a draft, the context number closed again`); }); - console.log("PASS on a phone with Queue showing, opening the context number steps the model label out whole, and closing it brings the label back"); + console.log("PASS with Queue showing, opening the context number steps the model label and its pill out whole, and closing it brings them back"); + + // a phone: beside the task chip and Queue the pill has no room, so the label is out while the + // draft is there and back, whole, once it is gone; the card keeps its height through it + for (const model of NAMED) await withCard(browser, 390, { model }, async (page) => { + const rest = await drawn(page, "full", `${model} on a phone, no draft`); + assert.ok(rest.chip && rest.ring && !rest.queue); + await draft(page, true); + const held = await drawn(page, "out", `${model} on a phone, a draft`); + assert.ok(held.queue && (held.ring?.width ?? 0) > 1, `the ring is still drawn: ${JSON.stringify(held)}`); + assert.equal(held.card.height, rest.card.height); + await page.getByRole("textbox", { name: "Message", exact: true }).fill(""); + await drawn(page, "full", `${model} on a phone, the draft cleared`); + }); + console.log("PASS on a phone the pill is whole beside Stop and steps out, ring kept, while Queue shows"); // without Queue the level alone steps out, and no sliver of it is left beside the number - await withCard(browser, 390, { model: "gpt-5.6-sol", status: "idle" }, async (page) => { + await withCard(browser, 390, { model: "gpt-5.6", status: "idle" }, async (page) => { await drawn(page, "full", "a resting pane"); await toggleRing(page, true, "no-effort"); const open = await drawn(page, "no-effort", "a resting pane, the context number open"); @@ -239,16 +287,29 @@ try { }); console.log("PASS a resting pane keeps one row of metadata over the uploading sentence, and the sentence alone is centred"); - // a task chip, the context ring and Queue together, from a phone to a wide window, with the - // number closed and open: nothing overlaps and the label is whole or stepped out - for (const [width, closed, open] of [[390, "out", "out"], [800, "out", "out"], [1024, "full", "out"], [1440, "full", "full"]] as const) await withCard(browser, width, { model: LONG_MODEL }, async (page) => { + // a task chip, the context ring, the mic and Queue together, from a phone to a wide window, + // with the number closed and open: nothing overlaps and the label is whole or stepped out, + // for a short name and for a long id that has none + const widths = [390, 800, 1024, 1440] as const; + const fits: Record> = { + "claude-opus-5-5": { + plain: [["out", "out"], ["full", "out"], ["full", "full"], ["full", "full"]], + mic: [["out", "out"], ["out", "out"], ["full", "full"], ["full", "full"]], + }, + [LONG_MODEL]: { + plain: [["out", "out"], ["out", "out"], ["out", "out"], ["full", "full"]], + mic: [["out", "out"], ["out", "out"], ["out", "out"], ["full", "out"]], + }, + }; + for (const [model, byMic] of Object.entries(fits)) for (const mic of [false, true]) for (const [index, width] of widths.entries()) await withCard(browser, width, { model, mic }, async (page) => { + const [closed, open] = byMic[mic ? "mic" : "plain"][index]!; await draft(page, true); - const row = await drawn(page, closed, `${width}px`); - assert.ok(row.chip && row.ring && row.queue); + const row = await drawn(page, closed, `${model}, ${width}px${mic ? ", the mic" : ""}`); + assert.ok(row.chip && row.ring && row.queue && row.mic === mic); await toggleRing(page, true, open); - await drawn(page, open, `${width}px, the context number open`); + await drawn(page, open, `${model}, ${width}px${mic ? ", the mic" : ""}, the context number open`); }); - console.log("PASS at 390, 800, 1024 and 1440px a long model id is whole or stepped out beside the chip, the ring and Queue"); + console.log("PASS at 390, 800, 1024 and 1440px a short name and a long model id are whole or stepped out beside the chip, the ring, the mic and Queue"); } finally { await browser.close(); } diff --git a/src/AGENTS.md b/src/AGENTS.md index f8d2bcd062..84bf966ff7 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -30,7 +30,7 @@ React 18 + xterm.js browser client. The render-state machine, xterm lifecycle an | Held-input draft | components/PaneTerminal.tsx + lib/draft.ts | input during a disconnect accumulates in a draft (printable only); the review banner offers Send/Discard after reconnect | | Geometry/fit | components/PaneTerminal.tsx | fit on attach, ResizeObserver on the host, `visibilitychange` refit — all SKIPPED while observing; single-finger touch drag becomes synthetic wheel events (listeners `passive: false` only there) | | Key bar | components/KeyBar.tsx + lib/keys.ts | taps call `term.input(keySequence(key, term.modes.applicationCursorKeysMode))`, reusing the onData → WS path; one-shot Ctrl is armed in PaneTerminal; hidden while observing | -| Chat composer | components/Composer.tsx + lib/compose.ts + lib/mentions.ts | Enter or Mod+Enter sends per `settings.enterSends`, IME Enter never sends; `/` slash commands are cached 60s per pane, `@` file search is debounced 150ms; Stop is Escape into the pty via `onAbort`; Queue when a send would land mid-run; disabled (text held, never queued) while disconnected | +| Chat composer | components/Composer.tsx + lib/compose.ts + lib/mentions.ts + lib/modelName.ts | the model pill shows a name only for an id `modelLabel` matches whole, else the id as received in mono: never add a guessed name; Enter or Mod+Enter sends per `settings.enterSends`, IME Enter never sends; `/` slash commands are cached 60s per pane, `@` file search is debounced 150ms; Stop is Escape into the pty via `onAbort`; Queue when a send would land mid-run; disabled (text held, never queued) while disconnected | | Session panel | components/SessionPanel.tsx + lib/activity.ts | derived client-side from the turns ChatView already has (`onActivity`); task state comes from the tool part's `task`; open state lives in App (`herdr-web-ui:session-panel`); phones (≤640px) show neither the panel nor its strip | | Chat lens | components/ChatView.tsx + Markdown.tsx (lib/markdown.ts) + lib/workBlocks.ts + lib/toolVerbs.ts + PromptCard.tsx | `splitTurn` folds tools/thinking/narration into one `.work-block` and honors explicit Codex commentary/final phases; a row is verb + object by exact tool id (`toolVerb`), the id kept in its title and opened detail; duration = `end_ts − ts`; conversation polls run serially, a send refresh preserves history/scroll, a pane change resets; PromptCard polls `/api/pane/prompt` every 2s while blocked and answers through `/api/pane/prompt/answer` (409 → re-read) | | Sidebar roster | components/Sidebar.tsx + TabStrip.tsx | rendered only inside `MachineSidebar`, one per PC; grouping is a setting (`SidebarGrouping`, by workspace or by folder); a row shows the roll-up of its panes' states (`rollupStatus`, lib/status.ts); workspace reorder is `workspace.move`, optimistic; other panes of a workspace are reached from the TabStrip, the palette and Needs you; no search or filter here on purpose, the command palette is the search; Add PC lives in Settings → Remote PCs and the palette (`actions.openAddPc`) | diff --git a/src/components/Composer.css b/src/components/Composer.css index 79d05396d1..e54b5a84e2 100644 --- a/src/components/Composer.css +++ b/src/components/Composer.css @@ -112,9 +112,10 @@ card). What does not fit gives way in this order: the task chip's words (BackgroundTasks.css, by the card's width); then the model label, as composerModelDraw says of the measured row (Composer.tsx marks data-model): while Queue is showing the mark, the name and the level step - out whole, without it the level alone does, each read and not drawn, so no word is drawn in - part; then, with no Queue, the model's name and the opened context text. Nothing overlaps its - neighbour, and the context ring at the end is never the part that is cut. */ + out whole and their pill with them, without it the level alone does, each read and not drawn, + so no word is drawn in part; then, with no Queue, the model's name and the opened context + text. Nothing overlaps its neighbour, and the context ring at the end is never the part that + is cut. */ .composer-status { grid-area: status; display: flex; @@ -166,6 +167,30 @@ display: contents; } +/* the mark, the model, the level and the ring in one quiet pill, as tall as a control. It is a + surface, not a control: no border, no hover, no cursor, and the ring inside it is the only + thing to press. It shrinks before its neighbours do, so a name that does not fit is measured + clipped inside it (Composer.tsx) and the label steps out */ +.composer-pill { + display: flex; + flex: 0 1 auto; + align-items: center; + gap: var(--space-2); + min-width: 0; + height: var(--control-h); + padding: 0 var(--space-3); + border-radius: var(--radius-pill); + background: var(--bg-hover); + overflow: hidden; +} + +/* no model to show, or the label stepped out: no pill either. Its box is gone, so the mark and + the ring stand in the status row itself, as they did before there was a pill */ +.composer-pill.is-bare, +.composer-status[data-model="out"] .composer-pill { + display: contents; +} + .composer-status[data-model="out"] .agent-mark { display: none; } @@ -187,27 +212,38 @@ /* the one state word the row draws: a turn that ended and was not seen yet */ .composer-status[data-status="done"] strong { flex: none; + margin-right: var(--space-1); color: var(--status-done); font-size: var(--fs-xs); letter-spacing: var(--tracking-caps); } .composer-status:not([data-model="out"]) .composer-model-info { - /* no box of its own: the name and the level sit in the status line itself, so the name can - give way before anything else does and the level never overlaps what follows */ + /* no box of its own: the name and the level sit in the pill itself, so the name can give way + before anything else does and the level never overlaps what follows */ display: contents; } +/* a readable name in the UI face */ .composer-model { flex: 0 1 auto; min-width: 3ch; overflow: hidden; + color: var(--text); + font-weight: var(--fw-medium); text-overflow: ellipsis; white-space: nowrap; } -/* the level as one word beside the model, without an outline: the card's own border is the only - one at rest. The sentence ("Reasoning high") is the screen reader's and the tooltip's */ +/* an id with no name: the identifier face, as a path or a command has in the transcript */ +.composer-model.is-id { + font-family: var(--font-mono); + font-weight: var(--fw-regular); +} + +/* the level as one word after a middle dot, dim beside the name. The word is the agent's own; + only its first letter is drawn as a capital. The sentence ("Reasoning high") is the screen + reader's and the tooltip's */ .composer-reasoning { flex: 0 100 auto; min-width: 0; @@ -216,6 +252,15 @@ white-space: nowrap; } +.composer-reasoning-short { + text-transform: capitalize; +} + +.composer-reasoning-short::before { + content: "·"; + margin-right: var(--space-2); +} + /* what is left of the context: plain until a compaction is near */ /* the context left: a ring filled by what is used, its number on hover or a tap */ .composer-context { @@ -255,6 +300,11 @@ stroke: var(--border); } +/* on the pill's fill --border would be the fill itself in two palettes: the track stays a ring */ +.composer-pill:not(.is-bare) .composer-context-track { + stroke: var(--border-strong); +} + .composer-context-used { stroke: currentColor; stroke-linecap: round; @@ -286,6 +336,12 @@ display: none; } +/* over a sentence on its own line the pill is a chip's height, so the two lines fit the row and + the card does not grow by them */ +.composer-status[data-hint-alone] .composer-pill { + height: var(--chip-h); +} + /* the message is the card's first row at its full width; under it one row: add and the task chip | the status content | Queue and the one round button. The attachment strip is its own row above both. The three cells of the last row stretch and centre what they hold, so the diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index 8486db8624..01b25ab568 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -35,6 +35,7 @@ import { rankSlashCommands, terminalOnlyCommand, } from "../lib/compose.ts"; +import { modelLabel } from "../lib/modelName.ts"; import { activeTrigger, applyCompletion, type ActiveTrigger } from "../lib/mentions.ts"; import { quickReplyButtons, useSettings } from "../lib/settings.ts"; import { AgentMark } from "./AgentMark.tsx"; @@ -736,6 +737,8 @@ export function Composer({ const statusCompact = composerStatusCompact(cardWidth); const queueShown = composerQueueShown({ queueMode, connected, text, uploading }); const hint = composerStatusHint({ uploading, connected, text }); + const model = metadata?.model ? modelLabel(metadata.model) : null; + const modelShown = Boolean(metadata?.model || metadata?.reasoning_effort); const hintText = hint === null ? null : t(hint === "uploading" ? "Uploading file…" : "Reconnecting… message held here, never queued"); const menuId = `composer-menu-${paneId}`; @@ -932,18 +935,24 @@ export function Composer({ data-offline={connected ? undefined : ""} data-hint={hint ?? undefined}> {/* everything but the sentence: one row that never wraps, also where the sentence takes a line of its own */} - {agent && } {agentLabel} {t(composerStatusWord(agentStatus))} - {(metadata?.model || metadata?.reasoning_effort) && - {metadata.model ?? t("Model —")} - - {t("Reasoning {effort}", { effort: metadata.reasoning_effort ?? "—" })} - - - } - {metadata?.context && setContextShown((open) => !open)} />} + {/* the mark, the model, the level and the context ring as one quiet pill. It only shows: no role, + no focus, nothing to press but the ring inside it. A pane that names no model draws no pill + (.is-bare): the mark and the ring stand in the row as they are */} + + {agent && } + {modelShown && + {/* a name only for an id modelLabel can name for certain; any other id is drawn as received, in the identifier face */} + {model?.text ?? t("Model —")} + + {t("Reasoning {effort}", { effort: metadata?.reasoning_effort ?? "—" })} + + + } + {metadata?.context && setContextShown((open) => !open)} />} + {/* the chip is a button in the left controls; its count is still said here, where a change is announced */} {backgroundTasks > 0 && {t(backgroundTasks === 1 ? "{n} background task" : "{n} background tasks", { n: backgroundTasks })}} diff --git a/src/lib/modelName.test.ts b/src/lib/modelName.test.ts new file mode 100644 index 0000000000..75226a9083 --- /dev/null +++ b/src/lib/modelName.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { modelLabel } from "./modelName.ts"; + +describe("modelLabel", () => { + test("names a Claude id by the vendor's family and version", () => { + expect(modelLabel("claude-opus-5-5")).toEqual({ text: "Opus 5.5", named: true }); + expect(modelLabel("claude-sonnet-5")).toEqual({ text: "Sonnet 5", named: true }); + expect(modelLabel("claude-haiku-4-5")).toEqual({ text: "Haiku 4.5", named: true }); + expect(modelLabel("claude-opus-4-1")).toEqual({ text: "Opus 4.1", named: true }); + expect(modelLabel("claude-fable-5-1")).toEqual({ text: "Fable 5.1", named: true }); + }); + + test("reads the vendor's own provider prefix as the same model", () => { + expect(modelLabel("anthropic/claude-opus-5-5")).toEqual({ text: "Opus 5.5", named: true }); + }); + + test("names a bare GPT or GLM version", () => { + expect(modelLabel("gpt-5.6")).toEqual({ text: "GPT-5.6", named: true }); + expect(modelLabel("gpt-6")).toEqual({ text: "GPT-6", named: true }); + expect(modelLabel("glm-5.3")).toEqual({ text: "GLM-5.3", named: true }); + }); + + test("shows an id it cannot name for certain exactly as received", () => { + for (const id of [ + // a dated snapshot: the date is not dropped + "claude-haiku-4-5-20251001", "claude-opus-5-5[1m]", "claude-opus-5-5-fast", + // the older order of family and version, and a family the vendor does not list + "claude-3-5-sonnet-20241022", "claude-3", "claude-nova-5", + // tier and product words + "gpt-5.6-sol", "gpt-5.6-sol-max", "gpt-5.6-sol-codex-preview-2026-10", "gpt-4.1-mini", "gpt-transcribe", + // another provider's route, another vendor, and ids that only look close + "bedrock/claude-opus-5-5", "openrouter/anthropic/claude-opus-5-5", "openai/gpt-5.6", "anthropic/x", + "qwen-3-8-flash", "grok-4.7", "glm-5.3-air", "Claude-Opus-5-5", " claude-opus-5-5", "codex-test-model", "", "", + ]) expect(modelLabel(id)).toEqual({ text: id, named: false }); + }); +}); diff --git a/src/lib/modelName.ts b/src/lib/modelName.ts new file mode 100644 index 0000000000..1a40e52f66 --- /dev/null +++ b/src/lib/modelName.ts @@ -0,0 +1,43 @@ +/** + * The name a person calls a model by, for the composer's pill, or the id exactly as received. + * + * A name is never guessed. Only an id that matches one of the patterns below WHOLE gets one, + * and each pattern is the vendor's own regular id-to-name rule: + * - Anthropic: `claude--[-]` is "Claude [.]" + * (claude-opus-5-5 is Claude Opus 5.5, claude-sonnet-5 is Claude Sonnet 5, claude-haiku-4-5 is + * Claude Haiku 4.5). The pill draws it as Claude Code's own status line does, without the + * vendor word ("Opus 5.5"): the agent mark beside it and the agent's read name say whose it is. + * The same id behind the vendor's own provider prefix (`anthropic/claude-opus-5-5`, as pi and + * omo record it) is the same model. + * - OpenAI: `gpt-` is "GPT-" (gpt-5.6 is GPT-5.6). + * - Z.ai: `glm-` is "GLM-" (glm-5.3 is GLM-5.3). + * + * Everything else is drawn as the identifier it is: a dated snapshot (claude-haiku-4-5-20251001), + * a tier or product word this file cannot name for certain (gpt-5.6-sol, gpt-5.6-sol-max), an + * older id order (claude-3-5-sonnet), another provider's route to a model (bedrock/…), and every + * vendor not listed. So no suffix is ever dropped: an id either reads whole as a name or is shown + * whole. The raw id stays in the label's title either way. + */ +export interface ModelLabel { + /** what the pill draws */ + text: string; + /** false: `text` is the id as received, drawn in the identifier face */ + named: boolean; +} + +const CLAUDE = /^(?:anthropic\/)?claude-(opus|sonnet|haiku|fable|mythos)-(\d{1,2})(?:-(\d{1,2}))?$/u; +const GPT = /^gpt-(\d{1,2}(?:\.\d{1,2})?)$/u; +const GLM = /^glm-(\d{1,2}(?:\.\d{1,2})?)$/u; + +export function modelLabel(id: string): ModelLabel { + const claude = CLAUDE.exec(id); + if (claude) { + const family = claude[1]!; + return { text: `${family.charAt(0).toUpperCase()}${family.slice(1)} ${claude[2]}${claude[3] === undefined ? "" : `.${claude[3]}`}`, named: true }; + } + const gpt = GPT.exec(id); + if (gpt) return { text: `GPT-${gpt[1]}`, named: true }; + const glm = GLM.exec(id); + if (glm) return { text: `GLM-${glm[1]}`, named: true }; + return { text: id, named: false }; +} From fdf5d74abcaf16b1f7d3384775a3e5061227631f Mon Sep 17 00:00:00 2001 From: Hako Date: Mon, 5 Oct 2026 19:36:20 +0900 Subject: [PATCH 2/5] test(chat): the fit check still measures the ring when its pill has no box Co-Authored-By: Claude Opus 5.5 --- scripts/composer-fit-demo-regression.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/composer-fit-demo-regression.ts b/scripts/composer-fit-demo-regression.ts index 7e3c4768a5..7f100e6990 100644 --- a/scripts/composer-fit-demo-regression.ts +++ b/scripts/composer-fit-demo-regression.ts @@ -31,6 +31,8 @@ const measure = (page: Page) => page.evaluate(() => { }; // everything drawn in the last row: no two of them may share a pixel const drawn = [...document.querySelectorAll(".composer-controls-left > *:not(input), .composer-status-meta > *, .composer-status-hint, .composer-controls-right > *")] + // a pill without a box (the label out, or no model) and the label's wrapper: their parts stand in the row themselves + .flatMap((item) => item.classList.contains("composer-pill") && getComputedStyle(item).display === "contents" ? [...item.children] as HTMLElement[] : [item]) .flatMap((item) => item.classList.contains("composer-model-info") && getComputedStyle(item).display === "contents" ? [...item.children] as HTMLElement[] : [item]) .filter((item) => item.getBoundingClientRect().width > 1.5 && item.getBoundingClientRect().height > 1.5); const overlaps: string[] = []; From 451c9dd874647971928614a7e56f0bdeeff0bea6 Mon Sep 17 00:00:00 2001 From: Hako Date: Mon, 5 Oct 2026 19:52:51 +0900 Subject: [PATCH 3/5] fix(chat): the model pill after review: the id still read behind a name, no pill or dash for what a pane does not record - The fit check compares the pill's parts with each other again, in every draw, and the level is one of the parts the pill must hold. - Behind a name the id as received is repeated as visually hidden text (a touch cannot reach the title). - A pane that records a level and no model draws no pill: a dim placeholder and the level stand in the row. A pane that records no level draws the name alone, no dot and no dash. - The level's first letter alone is a capital (::first-letter), so "extra high" keeps its words. - The ring's stronger track is used only on the pill's fill, not after the label stepped out. - gpt--sol is named GPT--Sol, as Codex's own status line writes it (server/prompt.test.ts); no other tier word is named. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++-- DESIGN.md | 17 ++++--- scripts/composer-fit-demo-regression.ts | 65 +++++++++++++++++++++---- src/components/Composer.css | 30 ++++++++---- src/components/Composer.tsx | 16 ++++-- src/lib/modelName.test.ts | 7 ++- src/lib/modelName.ts | 10 ++-- 7 files changed, 115 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f63210a7d..6f7d0de81f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,11 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa - In the chat's message box the agent's mark, the model, the reasoning level and the context ring sit together in one quiet pill, and the model is shown by its name where the id is a regular one: `claude-opus-5-5` reads **Opus 5.5**, `claude-sonnet-5` **Sonnet 5**, `gpt-5.6` - **GPT-5.6**, `glm-5.3` **GLM-5.3**, and the level follows as **High**. No name is ever - guessed: any other id (a dated snapshot, a suffix such as `gpt-5.6-sol`, another provider's - prefix, another vendor) is shown exactly as received, in the code font. Hovering the name - shows the id as received. The pill only shows the model, it is not a button yet. Where the + **GPT-5.6**, `gpt-5.6-sol` **GPT-5.6-Sol**, `glm-5.3` **GLM-5.3**, and the level follows as **High**. No name is ever + guessed: any other id (a dated snapshot, a suffix such as `gpt-5.6-sol-max`, another provider's + prefix, another vendor) is shown exactly as received, in the code font. Behind a name the id + as received is in the name's tooltip and is read by a screen reader; on a touch screen it is + not shown yet. A pane that records no reasoning level shows the name alone. The pill only shows the model, it is not a button yet. Where the pill does not fit beside Queue (on a phone with a background-task chip, always) it steps aside whole while Queue is showing, and the context ring stays. - In the chat, the line with the model sits inside the message box, as its last row, instead of diff --git a/DESIGN.md b/DESIGN.md index d51960ae8d..1fd8853710 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -609,18 +609,21 @@ One set for both themes: the card is island black wherever it shows. - The model pill (`.composer-pill`) holds the agent mark, the model, the reasoning level and the context ring in one quiet surface: `--bg-hover` fill, `--radius-pill`, `--control-h` tall, `--space-3` inline padding, `--space-2` between its parts. The model is in `--text` at - `--fw-medium`; the level follows a middle dot in `--text-dim`, as the agent's own word with - only its first letter drawn as a capital (`text-transform`, the text is not rewritten). On the - fill the ring's track is `--border-strong`. It is display only: a `span` with no role, no + `--fw-medium`; the level follows a middle dot in `--text-dim`, as the agent's own words with + only the first letter drawn as a capital (`::first-letter`, the text is not rewritten); a pane + that records no level draws no dot and no dash, only the name. On the + fill the ring's track is `--border-strong`; a ring left bare on the card keeps `--border`. It is display only: a `span` with no role, no focus, no hover or pressed state, no pointer cursor and no chevron; the ring inside it is the one thing to press. The `title` of the model is the id as received, and the level's is its - sentence. A pane that names no model draws no pill: the mark and the ring stand alone. + sentence; behind a name the id is also repeated as `.visually-hidden` text, since a touch + cannot reach a title. A pane that names no model draws no pill: the mark and the ring stand + alone, with a dim `Model —` and the level between them if the pane records only a level. - The model is drawn by name only where its id is one `modelLabel` (`src/lib/modelName.ts`) can name for certain, from the vendor's own regular naming and matched whole: `claude--[-]`, also behind `anthropic/`, is ` .` - (`Opus 5.5`, `Sonnet 5`); `gpt-` is `GPT-`; `glm-` is - `GLM-`. A name is never guessed. Any other id — a dated snapshot, a tier or product - suffix, another provider's prefix, another vendor — is drawn exactly as received in + (`Opus 5.5`, `Sonnet 5`); `gpt-` is `GPT-` and `gpt--sol` is `GPT--Sol`, as + Codex's own status line writes it; `glm-` is `GLM-`. A name is never guessed. Any other id — a dated snapshot, another tier or + product suffix, another provider's prefix, another vendor — is drawn exactly as received in `--font-mono` at regular weight (`.composer-model.is-id`), so it reads as an identifier and no suffix is dropped. - What does not fit the row gives way in this order: the task chip's words (icon and count below diff --git a/scripts/composer-fit-demo-regression.ts b/scripts/composer-fit-demo-regression.ts index 7f100e6990..4f59c3f2b2 100644 --- a/scripts/composer-fit-demo-regression.ts +++ b/scripts/composer-fit-demo-regression.ts @@ -15,10 +15,10 @@ const repo = join(import.meta.dir, ".."); const app = mkdtempSync(join(tmpdir(), "herdr-composer-fit-demo-")); // an id the composer cannot name is drawn as received, in the mono face: the widest label there is const LONG_MODEL = "gpt-5.6-sol-codex-preview-2026-10"; -// ids it names: "GPT-5.6" and "Opus 5.5" +// ids it names: "GPT-5.6" and "Opus 5.5"; null: the conversation names no model const NAMED = ["gpt-5.6", "claude-opus-5-5"] as const; -interface Case { model: string; status?: "working" | "idle"; mic?: boolean; ring?: boolean } +interface Case { model: string | null; effort?: string | null; status?: "working" | "idle"; mic?: boolean; ring?: boolean } type Draw = "full" | "no-effort" | "out"; const measure = (page: Page) => page.evaluate(() => { @@ -31,12 +31,15 @@ const measure = (page: Page) => page.evaluate(() => { }; // everything drawn in the last row: no two of them may share a pixel const drawn = [...document.querySelectorAll(".composer-controls-left > *:not(input), .composer-status-meta > *, .composer-status-hint, .composer-controls-right > *")] - // a pill without a box (the label out, or no model) and the label's wrapper: their parts stand in the row themselves - .flatMap((item) => item.classList.contains("composer-pill") && getComputedStyle(item).display === "contents" ? [...item.children] as HTMLElement[] : [item]) + // the pill and what it holds: its parts are compared with each other too, in every draw (a + // pill without a box, the label out or no model, has no size and is filtered out below) + .flatMap((item) => item.classList.contains("composer-pill") ? [item, ...item.children] as HTMLElement[] : [item]) .flatMap((item) => item.classList.contains("composer-model-info") && getComputedStyle(item).display === "contents" ? [...item.children] as HTMLElement[] : [item]) .filter((item) => item.getBoundingClientRect().width > 1.5 && item.getBoundingClientRect().height > 1.5); const overlaps: string[] = []; for (const [index, one] of drawn.entries()) for (const other of drawn.slice(index + 1)) { + // the pill is around its own parts + if (one.classList.contains("composer-pill") && one.contains(other)) continue; const a = one.getBoundingClientRect(), b = other.getBoundingClientRect(); if (Math.min(a.right, b.right) - Math.max(a.left, b.left) > 0.5 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 0.5) overlaps.push(`${one.className} | ${other.className}`); } @@ -54,13 +57,27 @@ const measure = (page: Page) => page.evaluate(() => { const style = getComputedStyle(pill); return { width: box.width, height: box.height, inRow: box.left >= row.left - 0.5 && box.right <= row.right + 0.5, - holds: inside(".agent-mark") && inside(".composer-model") && (pill.querySelector(".composer-context") === null || inside(".composer-context")), + holds: inside(".agent-mark") && inside(".composer-model") && (pill.querySelector(".composer-context") === null || inside(".composer-context")) + // the level too, wherever it is drawn (stepped out or not recorded it is a 1px box that is only read) + && ((pill.querySelector(".composer-reasoning")?.getBoundingClientRect().width ?? 0) <= 1.5 || inside(".composer-reasoning")), // it only shows: nothing about it says it can be pressed inert: pill.tagName === "SPAN" && !pill.hasAttribute("role") && !pill.hasAttribute("tabindex") && style.cursor === "auto", }; })(), name: document.querySelector(".composer-model")?.textContent ?? null, named: !document.querySelector(".composer-model")?.classList.contains("is-id"), label: part(".composer-model-info"), model: part(".composer-model"), effort: part(".composer-reasoning"), ring: part(".composer-context"), ringText: part(".composer-context-text"), + // the ring's track, and the two tokens it can be: the bare ring's and the one on the pill's fill + track: (() => { + const track = document.querySelector(".composer-context-track"); + const token = (name: string): string => { + const probe = document.body.appendChild(document.createElement("span")); + probe.style.color = `var(${name})`; + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }; + return track ? { stroke: getComputedStyle(track).stroke, bare: token("--border"), onFill: token("--border-strong") } : null; + })(), hint: part(".composer-status-hint"), queue: document.querySelector(".composer-queue-button") !== null, chip: document.querySelector(".bg-tasks-toggle") !== null, mic: document.querySelector(".composer-controls-left .voice-mic") !== null, overlaps, overflowing: document.documentElement.scrollWidth > window.innerWidth, @@ -80,6 +97,8 @@ const drawn = async (page: Page, draw: Draw, what: string): Promise => { // the pill goes with the label: no empty pill is left around the ring. Drawn, it holds the mark, the name and the ring if (draw === "out") assert.equal(row.pill.width, 0, `${what}: no pill is drawn without its label: ${JSON.stringify(row)}`); else assert.ok(row.pill.width > 1 && row.pill.inRow && row.pill.holds && row.pill.inert, `${what}: the mark, the name and the ring sit in one pill that is not a control: ${JSON.stringify(row)}`); + // the ring's track is the stronger one only on the pill's fill: bare on the card it is the bare ring's + if (row.track) assert.equal(row.track.stroke, draw === "out" ? row.track.bare : row.track.onFill, `${what}: the ring's track is the one for where it stands: ${JSON.stringify(row.track)}`); assert.deepEqual(row.overlaps, [], `${what}: nothing overlaps its neighbour`); assert.equal(row.overflowing, false, `${what}: the page does not scroll sideways`); return row; @@ -132,7 +151,7 @@ try { if ("agent" in value) Object.assign(value, { agent: "codex", background_tasks: 2 }); value.agent_status = window.fitCase.status; } - if (Array.isArray(value.turns) && value.metadata) value.metadata = { model: window.fitCase.model, reasoning_effort: "xhigh", ...(window.fitCase.ring ? { context: { used: 151000, window: 272000 } } : {}) }; + if (Array.isArray(value.turns) && value.metadata) value.metadata = { model: window.fitCase.model, reasoning_effort: window.fitCase.effort, ...(window.fitCase.ring ? { context: { used: 151000, window: 272000 } } : {}) }; for (const key of Object.keys(value)) fix(value[key]); return value; }; @@ -166,7 +185,7 @@ try { await context.addInitScript((fitCase) => { localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en", voiceInput: fitCase.mic })); Object.assign(window, { fitCase }); - }, { status: "working", mic: false, ring: true, ...state }); + }, { status: "working", effort: "xhigh", mic: false, ring: true, ...state }); const page = await context.newPage(); const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); @@ -187,17 +206,45 @@ try { const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); try { // a readable name for an id the composer can name, and any other id exactly as received - for (const [model, name, named] of [["claude-opus-5-5", "Opus 5.5", true], ["gpt-5.6", "GPT-5.6", true], ["gpt-5.6-sol", "gpt-5.6-sol", false], [LONG_MODEL, LONG_MODEL, false]] as const) await withCard(browser, 1440, { model }, async (page) => { + for (const [model, name, named] of [["claude-opus-5-5", "Opus 5.5", true], ["gpt-5.6", "GPT-5.6", true], ["gpt-5.6-sol", "GPT-5.6-Sol", true], ["gpt-5.6-sol-max", "gpt-5.6-sol-max", false], [LONG_MODEL, LONG_MODEL, false]] as const) await withCard(browser, 1440, { model }, async (page) => { const row = await drawn(page, "full", `${model} in a wide window`); assert.deepEqual([row.name, row.named], [name, named]); assert.equal(await page.locator(".composer-model").getAttribute("title"), model, "the id as received is the name's title"); assert.equal(await page.locator(".composer-model").evaluate((node) => getComputedStyle(node).fontFamily.includes("monospace")), !named, "an id with no name is drawn in the mono face"); // the level is the agent's own word, drawn with a capital and never rewritten - assert.deepEqual(await page.locator(".composer-reasoning-short").evaluate((node: HTMLElement) => [node.textContent, getComputedStyle(node).textTransform]), ["xhigh", "capitalize"]); + assert.deepEqual(await page.locator(".composer-reasoning-short").evaluate((node: HTMLElement) => [node.textContent, node.innerText]), ["xhigh", "Xhigh"]); + // behind a name the id as received is still read by assistive tech; an id drawn as received is not said twice + assert.deepEqual(await page.locator(".composer-model-id.visually-hidden").allTextContents(), named ? [model] : []); assert.equal(row.pill.height, await page.locator(".composer-attach").evaluate((node) => node.getBoundingClientRect().height), "the pill is as tall as a control"); }); console.log("PASS the model is named only where its id is one the composer can name, and the pill holds it at a control's height"); + // a level of two words: the first letter alone is drawn as a capital, the words are the agent's + await withCard(browser, 1440, { model: "claude-opus-5-5", effort: "extra high" }, async (page) => { + await drawn(page, "full", "a level of two words"); + assert.deepEqual(await page.locator(".composer-reasoning-short").evaluate((node: HTMLElement) => [node.textContent, node.innerText]), ["extra high", "Extra high"]); + }); + // no level recorded: the pill reads the name and the ring, with no dot and no dash drawn; the sentence is still read + for (const width of [390, 1440]) await withCard(browser, width, { model: "claude-opus-5-5", effort: null }, async (page) => { + const row = await drawn(page, "full", `no level, ${width}px`); + assert.equal(row.name, "Opus 5.5"); + assert.equal(await page.locator(".composer-reasoning-dot, .composer-reasoning-short").count(), 0, "no dot and no dash"); + assert.ok((row.effort?.width ?? 0) <= 1.5, `nothing is drawn for a level that is not recorded: ${JSON.stringify(row)}`); + assert.equal(await page.locator(".composer-reasoning-full").textContent(), "Reasoning —"); + }); + // a level and no model: no pill is drawn around a placeholder; the placeholder and the level + // stand in the row, dim, with the mark and a bare ring + await withCard(browser, 1440, { model: null, effort: "high" }, async (page) => { + await page.waitForFunction(() => document.querySelector(".composer-status")?.getAttribute("data-model") === null); + const row = await measure(page); + assert.equal(row.pill.width, 0, `no pill without a model: ${JSON.stringify(row)}`); + assert.ok(row.name === "Model —" && (row.effort?.width ?? 0) > 1.5 && row.track !== null && row.track.stroke === row.track.bare, JSON.stringify(row)); + assert.deepEqual(await page.locator(".composer-model").evaluate((node) => [getComputedStyle(node).color, getComputedStyle(node).fontWeight]), + await page.locator(".composer-status").evaluate((node) => [getComputedStyle(node).color, getComputedStyle(node).fontWeight]), "the placeholder is not drawn as a name"); + assert.deepEqual(row.overlaps, []); + }); + console.log("PASS a level of two words keeps its words, a missing level draws nothing, and a pane with no model draws no pill"); + // The context ring's number opens inside the row and the card keeps its size: the label is // fitted again. With Queue it steps out whole, its pill with it, and comes back when the number closes for (const model of NAMED) await withCard(browser, 800, { model }, async (page) => { diff --git a/src/components/Composer.css b/src/components/Composer.css index 81d92d4c02..a5ebb090b0 100644 --- a/src/components/Composer.css +++ b/src/components/Composer.css @@ -241,9 +241,15 @@ font-weight: var(--fw-regular); } -/* the level as one word after a middle dot, dim beside the name. The word is the agent's own; - only its first letter is drawn as a capital. The sentence ("Reasoning high") is the screen - reader's and the tooltip's */ +/* a pane that names no model: the placeholder is not a name, so it is dim like the row */ +.composer-model.is-none { + color: inherit; + font-weight: var(--fw-regular); +} + +/* the level after a middle dot, dim beside the name. The words are the agent's own; only the + first letter of the first one is drawn as a capital. The sentence ("Reasoning high") is the + screen reader's and the tooltip's */ .composer-reasoning { flex: 0 100 auto; min-width: 0; @@ -252,13 +258,18 @@ white-space: nowrap; } +.composer-reasoning-dot { + margin-right: var(--space-2); +} + +/* a block of its own, which is what ::first-letter needs */ .composer-reasoning-short { - text-transform: capitalize; + display: inline-block; + vertical-align: bottom; } -.composer-reasoning-short::before { - content: "·"; - margin-right: var(--space-2); +.composer-reasoning-short::first-letter { + text-transform: uppercase; } /* what is left of the context: plain until a compaction is near */ @@ -300,8 +311,9 @@ stroke: var(--border); } -/* on the pill's fill --border would be the fill itself in two palettes: the track stays a ring */ -.composer-pill:not(.is-bare) .composer-context-track { +/* on the pill's fill --border would be the fill itself in two palettes: the track stays a ring. + Only on the fill: a ring left bare by a label that stepped out has the bare ring's track */ +.composer-status:not([data-model="out"]) .composer-pill:not(.is-bare) .composer-context-track { stroke: var(--border-strong); } diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index 01b25ab568..e2a57868a2 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -940,15 +940,21 @@ export function Composer({ {t(composerStatusWord(agentStatus))} {/* the mark, the model, the level and the context ring as one quiet pill. It only shows: no role, no focus, nothing to press but the ring inside it. A pane that names no model draws no pill - (.is-bare): the mark and the ring stand in the row as they are */} - + (.is-bare): the mark, a level if it has one, and the ring stand in the row as they are */} + {agent && } {modelShown && {/* a name only for an id modelLabel can name for certain; any other id is drawn as received, in the identifier face */} - {model?.text ?? t("Model —")} - + {model?.text ?? t("Model —")} + {/* behind a name the id as received is still read; a touch cannot reach the title */} + {model?.named && {metadata?.model}} + {/* no level recorded: nothing is drawn for it, no dot and no dash; the sentence is still read */} + {t("Reasoning {effort}", { effort: metadata?.reasoning_effort ?? "—" })} - + {metadata?.reasoning_effort && <> + + + } } {metadata?.context && setContextShown((open) => !open)} />} diff --git a/src/lib/modelName.test.ts b/src/lib/modelName.test.ts index 75226a9083..78e3ec6587 100644 --- a/src/lib/modelName.test.ts +++ b/src/lib/modelName.test.ts @@ -20,6 +20,11 @@ describe("modelLabel", () => { expect(modelLabel("glm-5.3")).toEqual({ text: "GLM-5.3", named: true }); }); + test("names the one GPT tier Codex's own status line writes, with its hyphen", () => { + expect(modelLabel("gpt-5.6-sol")).toEqual({ text: "GPT-5.6-Sol", named: true }); + expect(modelLabel("gpt-6-sol")).toEqual({ text: "GPT-6-Sol", named: true }); + }); + test("shows an id it cannot name for certain exactly as received", () => { for (const id of [ // a dated snapshot: the date is not dropped @@ -27,7 +32,7 @@ describe("modelLabel", () => { // the older order of family and version, and a family the vendor does not list "claude-3-5-sonnet-20241022", "claude-3", "claude-nova-5", // tier and product words - "gpt-5.6-sol", "gpt-5.6-sol-max", "gpt-5.6-sol-codex-preview-2026-10", "gpt-4.1-mini", "gpt-transcribe", + "gpt-5.6-sol-max", "gpt-5.6-Sol", "gpt-5.6-solo", "gpt-sol", "gpt-5.6-sol-codex-preview-2026-10", "gpt-4.1-mini", "gpt-transcribe", // another provider's route, another vendor, and ids that only look close "bedrock/claude-opus-5-5", "openrouter/anthropic/claude-opus-5-5", "openai/gpt-5.6", "anthropic/x", "qwen-3-8-flash", "grok-4.7", "glm-5.3-air", "Claude-Opus-5-5", " claude-opus-5-5", "codex-test-model", "", "", diff --git a/src/lib/modelName.ts b/src/lib/modelName.ts index 1a40e52f66..5e5a2d443c 100644 --- a/src/lib/modelName.ts +++ b/src/lib/modelName.ts @@ -9,11 +9,13 @@ * vendor word ("Opus 5.5"): the agent mark beside it and the agent's read name say whose it is. * The same id behind the vendor's own provider prefix (`anthropic/claude-opus-5-5`, as pi and * omo record it) is the same model. - * - OpenAI: `gpt-` is "GPT-" (gpt-5.6 is GPT-5.6). + * - OpenAI: `gpt-` is "GPT-" (gpt-5.6 is GPT-5.6), and `gpt--sol` is + * "GPT--Sol", written as Codex's own status line writes it (the "GPT-6-Sol" captured + * in server/prompt.test.ts). No other tier word has a source here, so none is named. * - Z.ai: `glm-` is "GLM-" (glm-5.3 is GLM-5.3). * * Everything else is drawn as the identifier it is: a dated snapshot (claude-haiku-4-5-20251001), - * a tier or product word this file cannot name for certain (gpt-5.6-sol, gpt-5.6-sol-max), an + * a tier or product word this file cannot name for certain (gpt-5.6-sol-max, gpt-4.1-mini), an * older id order (claude-3-5-sonnet), another provider's route to a model (bedrock/…), and every * vendor not listed. So no suffix is ever dropped: an id either reads whole as a name or is shown * whole. The raw id stays in the label's title either way. @@ -26,7 +28,7 @@ export interface ModelLabel { } const CLAUDE = /^(?:anthropic\/)?claude-(opus|sonnet|haiku|fable|mythos)-(\d{1,2})(?:-(\d{1,2}))?$/u; -const GPT = /^gpt-(\d{1,2}(?:\.\d{1,2})?)$/u; +const GPT = /^gpt-(\d{1,2}(?:\.\d{1,2})?)(-sol)?$/u; const GLM = /^glm-(\d{1,2}(?:\.\d{1,2})?)$/u; export function modelLabel(id: string): ModelLabel { @@ -36,7 +38,7 @@ export function modelLabel(id: string): ModelLabel { return { text: `${family.charAt(0).toUpperCase()}${family.slice(1)} ${claude[2]}${claude[3] === undefined ? "" : `.${claude[3]}`}`, named: true }; } const gpt = GPT.exec(id); - if (gpt) return { text: `GPT-${gpt[1]}`, named: true }; + if (gpt) return { text: `GPT-${gpt[1]}${gpt[2] === undefined ? "" : "-Sol"}`, named: true }; const glm = GLM.exec(id); if (glm) return { text: `GLM-${glm[1]}`, named: true }; return { text: id, named: false }; From 4f6d873904cba2ed6c67c2972458f49a6248154d Mon Sep 17 00:00:00 2001 From: Hako Date: Mon, 5 Oct 2026 20:05:03 +0900 Subject: [PATCH 4/5] docs(changelog): link #467 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f7d0de81f..fe2a937e55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa not shown yet. A pane that records no reasoning level shows the name alone. The pill only shows the model, it is not a button yet. Where the pill does not fit beside Queue (on a phone with a background-task chip, always) it steps aside whole while Queue is showing, and the context ring stays. + ([#467](https://github.com/devswha/herdr-web-ui/pull/467)) - In the chat, the line with the model sits inside the message box, as its last row, instead of above it, on a phone as on a desktop. It shows the agent's mark, background tasks, the model, the reasoning level as one word (`high`) and the context ring. The agent's written name and the From d79e3358cc47f484497ab218b2289c6852341f01 Mon Sep 17 00:00:00 2001 From: Hako Date: Mon, 5 Oct 2026 20:58:04 +0900 Subject: [PATCH 5/5] fix(chat): a model id that is not in the vendor's canonical form is shown as received The pill still names a model by the vendor's id syntax, not from a list of released versions, so a new model needs no change. The syntax is now read strictly: a version part with a leading zero (claude-sonnet-5-05, gpt-05.6, glm-5.03), a major of 0, and a ".0" minor on a GPT or GLM id are not named. The header comment and a test state the accepted consequence: a well-formed id of a version nobody released is named. DESIGN.md and the changelog said the pill always steps out beside a task chip and Queue on a phone. It steps out when it does not fit: the fit check now pins a short raw id with no level drawn whole at 390px, and the shortest named label with a level stepped out. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++-- DESIGN.md | 13 +++++--- scripts/composer-fit-demo-regression.ts | 17 ++++++++++- src/lib/modelName.test.ts | 40 +++++++++++++++++++++++++ src/lib/modelName.ts | 18 +++++++++-- 5 files changed, 84 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2a937e55..7716852497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,12 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa regular one: `claude-opus-5-5` reads **Opus 5.5**, `claude-sonnet-5` **Sonnet 5**, `gpt-5.6` **GPT-5.6**, `gpt-5.6-sol` **GPT-5.6-Sol**, `glm-5.3` **GLM-5.3**, and the level follows as **High**. No name is ever guessed: any other id (a dated snapshot, a suffix such as `gpt-5.6-sol-max`, another provider's - prefix, another vendor) is shown exactly as received, in the code font. Behind a name the id + prefix, another vendor, a spelling that is not the vendor's own such as `claude-sonnet-5-05`) is + shown exactly as received, in the code font. Behind a name the id as received is in the name's tooltip and is read by a screen reader; on a touch screen it is not shown yet. A pane that records no reasoning level shows the name alone. The pill only shows the model, it is not a button yet. Where the - pill does not fit beside Queue (on a phone with a background-task chip, always) it steps - aside whole while Queue is showing, and the context ring stays. + pill does not fit beside Queue (on a phone with a background-task chip, every named model + with a level) it steps aside whole while Queue is showing, and the context ring stays. ([#467](https://github.com/devswha/herdr-web-ui/pull/467)) - In the chat, the line with the model sits inside the message box, as its last row, instead of above it, on a phone as on a desktop. It shows the agent's mark, background tasks, the model, diff --git a/DESIGN.md b/DESIGN.md index 1fd8853710..70572d7147 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -622,8 +622,12 @@ One set for both themes: the card is island black wherever it shows. name for certain, from the vendor's own regular naming and matched whole: `claude--[-]`, also behind `anthropic/`, is ` .` (`Opus 5.5`, `Sonnet 5`); `gpt-` is `GPT-` and `gpt--sol` is `GPT--Sol`, as - Codex's own status line writes it; `glm-` is `GLM-`. A name is never guessed. Any other id — a dated snapshot, another tier or - product suffix, another provider's prefix, another vendor — is drawn exactly as received in + Codex's own status line writes it; `glm-` is `GLM-`. A name is never guessed. The rule is the + vendor's id syntax, not a list of released versions, so a new model needs no change and a + well-formed id of a version that does not exist is named too. Any other id — a dated snapshot, another tier or + product suffix, another provider's prefix, another vendor, a spelling that is not the vendor's + canonical one (a leading zero as in `claude-sonnet-5-05`, uppercase, a stray separator or + space) — is drawn exactly as received in `--font-mono` at regular weight (`.composer-model.is-id`), so it reads as an identifier and no suffix is dropped. - What does not fit the row gives way in this order: the task chip's words (icon and count below @@ -634,8 +638,9 @@ One set for both themes: the card is island black wherever it shows. fit steps out whole — the mark, the model and the level are read, not drawn, and are back once the draft is sent, held or cleared — so Queue keeps its word and no name is cut mid-word. The pill goes with its label: the ring then stands alone, with no empty pill around it. On a - `390px` phone the pill never fits beside the task chip and Queue together, so it is out for as - long as Queue shows there. + `390px` phone, beside the task chip, the ring and Queue, that is the case for every named model + with a level, so there the pill is out for as long as Queue shows; a label short enough to + fit (a short id with no level) stays drawn. Without Queue the level steps out whole first, never drawn in part; a name still too long is ellipsized inside the pill as the last resort, then the opened context text. The context ring is never cut. Over a sentence that took a line of its own the pill is `--chip-h` tall, so the diff --git a/scripts/composer-fit-demo-regression.ts b/scripts/composer-fit-demo-regression.ts index 4f59c3f2b2..ab8155445c 100644 --- a/scripts/composer-fit-demo-regression.ts +++ b/scripts/composer-fit-demo-regression.ts @@ -272,7 +272,22 @@ try { await page.getByRole("textbox", { name: "Message", exact: true }).fill(""); await drawn(page, "full", `${model} on a phone, the draft cleared`); }); - console.log("PASS on a phone the pill is whole beside Stop and steps out, ring kept, while Queue shows"); + // the pill steps out where it does not fit, not on a phone as such: a short id with no + // level is drawn whole there beside the task chip, the ring and Queue + await withCard(browser, 390, { model: "o3", effort: null }, async (page) => { + await draft(page, true); + const row = await drawn(page, "full", "a short id with no level on a phone, a draft"); + assert.ok(row.chip && row.ring && row.queue, `the task chip, the ring and Queue are in the row: ${JSON.stringify(row)}`); + assert.deepEqual([row.name, row.named], ["o3", false]); + assert.ok((row.effort?.width ?? 0) <= 1.5, `no level is drawn: ${JSON.stringify(row)}`); + }); + // the shortest label a named model with a level can have still has no room there + await withCard(browser, 390, { model: "gpt-6", effort: "low" }, async (page) => { + await draft(page, true); + const row = await drawn(page, "out", "the shortest name with a level on a phone, a draft"); + assert.ok(row.chip && row.ring && row.queue, JSON.stringify(row)); + }); + console.log("PASS on a phone the pill is whole beside Stop and steps out, ring kept, while Queue shows; a short id with no level stays drawn beside Queue"); // without Queue the level alone steps out, and no sliver of it is left beside the number await withCard(browser, 390, { model: "gpt-5.6", status: "idle" }, async (page) => { diff --git a/src/lib/modelName.test.ts b/src/lib/modelName.test.ts index 78e3ec6587..4e6efa081c 100644 --- a/src/lib/modelName.test.ts +++ b/src/lib/modelName.test.ts @@ -38,4 +38,44 @@ describe("modelLabel", () => { "qwen-3-8-flash", "grok-4.7", "glm-5.3-air", "Claude-Opus-5-5", " claude-opus-5-5", "codex-test-model", "", "", ]) expect(modelLabel(id)).toEqual({ text: id, named: false }); }); + + test("shows a version with a leading zero as the id it is, for every vendor", () => { + for (const id of [ + "claude-sonnet-5-05", "claude-opus-05-5", "claude-opus-05", "claude-opus-0-5", "claude-opus-0", "claude-opus-5-00", "anthropic/claude-sonnet-5-05", + "gpt-05.6", "gpt-5.06", "gpt-05", "gpt-0", "gpt-0.5", "gpt-05.6-sol", "gpt-5.06-sol", + "glm-05.3", "glm-5.03", "glm-05", "glm-0", + // a minor of 0 is the vendor's form only for Anthropic: the others write the bare major + "gpt-5.0", "gpt-5.0-sol", "glm-5.0", + ]) expect(modelLabel(id)).toEqual({ text: id, named: false }); + expect(modelLabel("claude-opus-4-0")).toEqual({ text: "Opus 4.0", named: true }); + expect(modelLabel("claude-sonnet-10-10")).toEqual({ text: "Sonnet 10.10", named: true }); + }); + + test("shows every other shape that is not the vendor's canonical form as received", () => { + for (const id of [ + // an empty part + "claude-opus-", "claude-opus--5", "claude--5-5", "claude-opus-5--5", "gpt-", "gpt-.6", "gpt--sol", "glm-", "glm-.3", "anthropic/", "/claude-opus-5-5", + // a part too long, or too many parts + "claude-opus-555", "claude-opus-5-555", "claude-opus-5-5-5", "gpt-555", "gpt-5.666", "gpt-5.6.1", "glm-555", "glm-5.333", "glm-5.3.1", + // a trailing or a wrong separator + "claude-opus-5-5-", "claude-opus-5-", "claude-opus-5.5", "claude_opus_5_5", "gpt-5.", "gpt-5.6-", "gpt-5.6-sol-", "gpt-5-6", "gpt5.6", "glm-5.", "glm-5.3-", "glm-5-3", + // uppercase anywhere + "CLAUDE-opus-5-5", "claude-Opus-5-5", "claude-OPUS-5-5", "Anthropic/claude-opus-5-5", "GPT-5.6", "Gpt-5.6", "gpt-5.6-SOL", "GLM-5.3", "Glm-5.3", + // whitespace before, inside or after, and digits that are not ASCII + "claude-opus-5-5 ", "claude-opus-5-5\n", "\nclaude-opus-5-5", "claude-opus -5-5", "claude-opus-5\t-5", "gpt-5.6 ", "gpt-5.6\n", " gpt-5.6", "gpt- 5.6", "glm-5.3\n", "glm-5.3\u00a0", + "claude-opus-\uff15-\uff15", "gpt-\u0665.\u0666", + ]) expect(modelLabel(id)).toEqual({ text: id, named: false }); + }); + + // The accepted consequence of naming by the vendor's id syntax and not from a list of released + // versions: a well-formed id is named even where no such model exists, so that a new model + // needs no change here. This is by design; do not turn it into a list. + test("names a well-formed id of a version nobody released", () => { + expect(modelLabel("claude-opus-9-9")).toEqual({ text: "Opus 9.9", named: true }); + expect(modelLabel("claude-mythos-12")).toEqual({ text: "Mythos 12", named: true }); + expect(modelLabel("gpt-10")).toEqual({ text: "GPT-10", named: true }); + expect(modelLabel("gpt-9.9-sol")).toEqual({ text: "GPT-9.9-Sol", named: true }); + expect(modelLabel("glm-9.9")).toEqual({ text: "GLM-9.9", named: true }); + expect(modelLabel("glm-10.10")).toEqual({ text: "GLM-10.10", named: true }); + }); }); diff --git a/src/lib/modelName.ts b/src/lib/modelName.ts index 5e5a2d443c..e52dce7a29 100644 --- a/src/lib/modelName.ts +++ b/src/lib/modelName.ts @@ -14,6 +14,16 @@ * in server/prompt.test.ts). No other tier word has a source here, so none is named. * - Z.ai: `glm-` is "GLM-" (glm-5.3 is GLM-5.3). * + * The patterns follow the vendor's id SYNTAX, not a list of released versions. That was chosen + * so a new model needs no change here, and its cost is accepted: an id of the right shape for a + * version that does not exist (claude-opus-9-9, gpt-10) is named too. + * + * In return the syntax is read strictly: an id that is not in the vendor's canonical form is not + * named. A version part is one or two digits with no leading zero (claude-sonnet-5-05, gpt-05.6 + * and glm-5.03 stay ids), a major is never 0, and a minor of 0 is canonical only where the + * vendor writes one: Anthropic does (claude-opus-4-0), OpenAI and Z.ai write the bare major + * (gpt-5, not gpt-5.0). The id is lowercase, with nothing before, between or after its parts. + * * Everything else is drawn as the identifier it is: a dated snapshot (claude-haiku-4-5-20251001), * a tier or product word this file cannot name for certain (gpt-5.6-sol-max, gpt-4.1-mini), an * older id order (claude-3-5-sonnet), another provider's route to a model (bedrock/…), and every @@ -27,9 +37,11 @@ export interface ModelLabel { named: boolean; } -const CLAUDE = /^(?:anthropic\/)?claude-(opus|sonnet|haiku|fable|mythos)-(\d{1,2})(?:-(\d{1,2}))?$/u; -const GPT = /^gpt-(\d{1,2}(?:\.\d{1,2})?)(-sol)?$/u; -const GLM = /^glm-(\d{1,2}(?:\.\d{1,2})?)$/u; +// a version part in canonical form: no leading zero, two digits at most +const NUMBER = "[1-9]\\d?"; +const CLAUDE = new RegExp(`^(?:anthropic/)?claude-(opus|sonnet|haiku|fable|mythos)-(${NUMBER})(?:-(0|${NUMBER}))?$`, "u"); +const GPT = new RegExp(`^gpt-(${NUMBER}(?:\\.${NUMBER})?)(-sol)?$`, "u"); +const GLM = new RegExp(`^glm-(${NUMBER}(?:\\.${NUMBER})?)$`, "u"); export function modelLabel(id: string): ModelLabel { const claude = CLAUDE.exec(id);