Skip to content

Narrow composer: the model trigger's effort word overlaps the Compact chip #36923

Open

Description

@psjg-claude

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/web

Steps to reproduce

Written by an AI agent (Claude Opus 5.5) for @psjg, who hit the bug, read the report and approved posting.
When the composer footer runs out of room, the model trigger shrinks to its minimum width but its effort word does not shrink with it: the word paints over the control to its right. @psjg hit this in desktop v0.1.9 (the app's embedded t3codeCommitHash is 24e77473ecf4, the v0.1.9 tag) with the diff panel open and the idle Compact chip showing ("Medium" drawn across the chip's icon and 706k). I reproduced it on main a767d23 with a Playwright script, and a one-line fix is below. Area: apps/web. Impact: cosmetic.

Reproduce

With vp run dev running, mint a pairing token (node apps/server/src/bin.ts pair from apps/server) and run the script at the bottom from the repo root:

node repro-effort-overlap.mjs http://localhost:<webPort> <token> ./repro main

It opens the app headless at 360x800, expands the composer, grows a striped 150px box where the Compact chip goes (the real chip needs a 200k-token Claude thread idle for an hour), measures the trigger at each step, and saves a screenshot and a video. It needs a provider whose model has an effort option, such as Codex or Claude.

Result on main a767d23: the trigger sits at its 52px minimum and the effort word ends 13px past the trigger's right edge, 5px into the stand-in chip.

In code, the chip appears for a Claude thread with at least 200k context tokens that has been idle past the prompt-cache TTL (70 minutes when the provider reports none) (contextWindow.ts#L225-L246). Effort display defaults to label (settings.ts#L414), so the word shows by default. Below 480px the Compact chip already drops its word (#27033) (ComposerPrimaryActions.tsx#L488).

Expected behavior

the effort word truncates or is dropped before it overlaps anything.

Actual behavior

it overflows the trigger and covers the neighbouring control.

Where in the code

These lines are unchanged between v0.1.9 and main (the links pin 2abf7be; those files are identical at a767d23):

git diff v0.1.9 a767d23297 -G 'min-w-13|flex shrink-0 items-center gap-1.5 text-muted-foreground|composer-secondary-trait|providerModelPickerMinimumWidth' --stat -- apps/web/src/components/chat/
# (empty)

A minimal fix

Let the suffix row shrink, so the word truncates inside the trigger (the word span already has truncate). In the diff, the suffix row drops shrink-0 and gains min-w-0:

--- a/apps/web/src/components/chat/ComposerModelEffort.tsx
+++ b/apps/web/src/components/chat/ComposerModelEffort.tsx
@@ -145,7 +145,7 @@ export function describeComposerEffort(
     suffix: (
       <span
         className={cn(
-          "flex shrink-0 items-center gap-1.5 text-muted-foreground group-hover/model-trigger:text-foreground group-data-pressed/model-trigger:text-foreground group-data-popup-open/model-trigger:text-foreground",
+          "flex min-w-0 items-center gap-1.5 text-muted-foreground group-hover/model-trigger:text-foreground group-data-pressed/model-trigger:text-foreground group-data-popup-open/model-trigger:text-foreground",
           // A word space after the model name: the regular trigger's gap is wider than one.
           size === "sm" && "-ms-0.5",
         )}

Same script, with the fix applied on a767d23 (still under Screenshots): at the 150px probe the word stays inside the trigger (8px wide, truncated) instead of ending 13px past it; with more room it keeps its full 30px. vp run check:changed passes. At 8px the word shows as a clipped first letter rather than an ellipsis; dropping it entirely (an icon-only step for the trigger, as resting blocks have) would be the polished version.

repro-effort-overlap.mjs (Playwright, uses the repo's scripts/node_modules/playwright-core)
// Reproduces the model trigger's effort word overflowing onto its right neighbour in a narrow composer.
//
// Usage (from a t3code checkout with `vp i` done and `vp run dev` running):
//   node repro-effort-overlap.mjs <web-origin> <pairing-token> <out-dir> [label]
// Mint the token with `node apps/server/src/bin.ts pair` (from apps/server).
// Prints one JSON line; writes <out-dir>/<label>.png and a .webm video of the run.
//
// A 150px box stands in for the idle "Compact before send" chip, which needs a 200k-token Claude
// thread idle for an hour. It needs a provider whose model has an effort option (Codex, Claude).
import { createRequire } from "node:module";
import { mkdirSync, renameSync } from "node:fs";
import path from "node:path";

const [origin, token, outDir, label = "run"] = process.argv.slice(2);
if (!origin || !token || !outDir) {
  console.error("usage: node repro-effort-overlap.mjs <web-origin> <pairing-token> <out-dir> [label]");
  process.exit(2);
}
const require = createRequire(path.join(process.cwd(), "scripts/package.json"));
const { chromium } = require("playwright-core");
mkdirSync(outDir, { recursive: true });

const size = { width: 360, height: 800 };
const browser = await chromium.launch();
const context = await browser.newContext({
  viewport: size,
  deviceScaleFactor: 2,
  colorScheme: "dark",
  recordVideo: { dir: outDir, size },
});
const page = await context.newPage();

await page.goto(`${origin}/pair`);
await page.getByRole("textbox").fill(token);
await page.keyboard.press("Enter");
await page.waitForURL((url) => !url.pathname.startsWith("/pair"));

// A narrow viewport opens with the composer collapsed into one row.
const actions = page.locator('[data-chat-composer-actions="right"]');
const expand = page.getByRole("button", { name: "Expand composer" });
await actions.or(expand).first().waitFor();
if (await expand.isVisible()) await expand.click();
await actions.locator("[data-chat-provider-model-picker] [data-composer-control-label]").first().waitFor();

const measure = (width) =>
  page.evaluate(async ({ width, label }) => {
    const frame = () => new Promise((resolve) => requestAnimationFrame(() => resolve()));
    let probe = document.getElementById("repro-probe");
    if (!probe) {
      const row = document.querySelector('[data-chat-composer-actions="right"]');
      probe = document.createElement("div");
      probe.id = "repro-probe";
      probe.style.cssText =
        "height:28px;flex-shrink:0;border-radius:8px;background:repeating-linear-gradient(45deg,#e5484d66 0 6px,#e5484d33 6px 12px)";
      row.insertBefore(probe, row.children[1]);
      const tag = document.createElement("div");
      tag.textContent = label;
      tag.style.cssText =
        "position:fixed;top:8px;left:8px;z-index:99999;padding:4px 8px;border-radius:6px;font:600 12px ui-monospace,monospace;background:#000c;color:#fff";
      document.body.append(tag);
    }
    probe.style.width = `${width}px`;
    // The footer layout settles through ResizeObserver callbacks over the next frames.
    for (let i = 0; i < 6; i += 1) await frame();
    const picker = document.querySelector("[data-chat-provider-model-picker]");
    const trigger = picker.getBoundingClientRect();
    const text = picker.querySelector("[data-composer-control-label]");
    const w = text.getBoundingClientRect();
    return {
      probe: width,
      triggerWidth: Math.round(trigger.width),
      word: text.textContent,
      wordWidth: Math.round(w.width),
      wordPastTrigger: Math.round(w.right - trigger.right),
      wordOverProbe: Math.round(w.right - probe.getBoundingClientRect().left),
    };
  }, { width, label });

const steps = [];
for (let width = 0; width <= 150; width += 10) {
  steps.push(await measure(width));
  await page.waitForTimeout(120); // pacing for the video only
}
await page.waitForTimeout(800);
const footer = page.locator('[data-chat-composer-footer="true"]');
await footer.screenshot({ path: path.join(outDir, `${label}.png`) });

const video = page.video();
await context.close();
await browser.close();
renameSync(await video.path(), path.join(outDir, `${label}.webm`));

const last = steps.at(-1);
console.log(JSON.stringify({ label, overlaps: last.wordOverProbe > 0, last, steps }));

Related: #27033 (merged: made the Compact chip icon-plus-count below 480px; the chip fits now, but the effort word still spills onto it); #31401 (closed: fixed collapse order in which the effort label collapses early, which would have dropped the overlapping word).

Prepared with the upstream skill (psjg/skills@d0420f5), which lists the checks this report claims to pass.

Impact

Cosmetic issue

Version or commit

desktop 0.1.9 (24e7747); reproduced on main a767d23

Environment

T3 Code (Beta) desktop 0.1.9, macOS 27.0 (26A428), Apple Silicon, Claude with effort Medium. Reproduced with playwright-core 1.64.1 (headless Chromium) against vp run dev on main a767d23, Codex GPT-5.6-Sol at effort Low.

Logs or stack traces

No response

Screenshots, recordings, or supporting files

Recording of the repro script, side by side. Left, main a767d23297: as the striped stand-in grows to 150px the trigger shrinks to 52px and "Low" slides 5px under the stand-in. Right, with the fix: "Low" truncates inside the trigger and nothing overlaps.

v0.1.9 composer: the effort word "Medium" is drawn across the Compact chip's icon and its 706k count

Whole window in v0.1.9 (sidebar, transcript and diff covered)

v0.1.9 window with the diff panel open; the narrow chat pane's composer shows the overlap; private content covered by opaque blocks

Composer footer at 360px with the 150px stand-in. Top, main a767d23297: the trigger is 52px wide and "Low" ends 13px past it, 5px into the striped box. Bottom, with the fix: "Low" truncates to 8px inside the trigger and the box is clear.

Workaround

Set Settings → Appearance → Effort display to Bars. With the repro script on a767d23 (changed to set the option and to measure the trigger's rightmost painted element, since bars have no word) and the 150px stand-in, the bars stop 7px short of the stand-in; the same variant on the default Word setting measures the word 5px over it, as above. Giving the chat pane more room (a wider window, or the diff panel closed) also helps: on main the word only reaches the stand-in once the trigger is squeezed to 56px or less.

Composer footer at 360px with Effort display set to Bars and the 150px stand-in: the effort bars sit inside the 52px trigger and the striped box is clear

Activity

  1. added
    bugSomething is broken or behaving incorrectly.
    needs-triageIssue needs maintainer review and initial categorization.
    on Nov 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething is broken or behaving incorrectly.needs-triageIssue needs maintainer review and initial categorization.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions