Repository navigation
Description
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 mainIt 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
- The model trigger may shrink to
min-w-13(52px) (ComposerControlsBar.tsx#L217), but its effort suffix is ashrink-0row, so the effort word keeps its full width and spills out of the trigger (ComposerModelEffort.tsx#L148). - The only rule that hides
data-composer-control-labelis scoped to resting blocks; the model trigger is not one (ComposerControlsBar.tsx#L283). data-composer-secondary-trait, whose comment says compacted rows drop it, is read nowhere (ComposerModelEffort.tsx#L166-L169).- The layout measurement takes the trigger's CSS
min-widthas its minimum (restingComposerControlsMeasurement.ts#L48-L51).
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
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.





Activity