diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a43c4338f..7716852497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ 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**, `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, 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, 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, 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 ac1aa68465..70572d7147 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -598,24 +598,53 @@ 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 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; 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-` and `gpt--sol` is `GPT--Sol`, as + 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 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, 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 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..ab8155445c 100644 --- a/scripts/composer-fit-demo-regression.ts +++ b/scripts/composer-fit-demo-regression.ts @@ -13,9 +13,12 @@ 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"; 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(() => { @@ -28,10 +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 > *")] + // 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}`); } @@ -39,7 +47,37 @@ 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")) + // 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, @@ -56,6 +94,11 @@ 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)}`); + // 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; @@ -108,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; }; @@ -142,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)); @@ -162,9 +205,49 @@ 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", 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, 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 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 +258,39 @@ 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`); + }); + // 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-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 +351,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 b327f0b2de..a5ebb090b0 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,44 @@ /* 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); +} + +/* 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; @@ -216,6 +258,20 @@ 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 { + display: inline-block; + vertical-align: bottom; +} + +.composer-reasoning-short::first-letter { + text-transform: uppercase; +} + /* 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 +311,12 @@ stroke: var(--border); } +/* 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); +} + .composer-context-used { stroke: currentColor; stroke-linecap: round; @@ -286,6 +348,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..e2a57868a2 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,30 @@ 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, 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 —")} + {/* 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)} />} + {/* 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..4e6efa081c --- /dev/null +++ b/src/lib/modelName.test.ts @@ -0,0 +1,81 @@ +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("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 + "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-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", "", "", + ]) 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 new file mode 100644 index 0000000000..e52dce7a29 --- /dev/null +++ b/src/lib/modelName.ts @@ -0,0 +1,57 @@ +/** + * 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), 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). + * + * 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 + * 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; +} + +// 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); + 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]}${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 }; +}