Show full model name in conversation header (#135) (#139)

Port of OpenHands/OpenHands#14284. The LLM model badge in the conversation
header was constrained to max-w-[150px] with an inner `truncate`, which
cut off long model identifiers such as `litellm_proxy/claude-sonnet-4-5-20250929`
to `litellm_proxy/cl…`. Drop the width cap and inner truncate, and apply
`whitespace-nowrap` to the outer span so the full name renders inline.

Also adds scripts/record-demo.mjs - a small playwright recorder used to
capture the verification GIF under .pr/issue-135/ - and updates the
existing test to assert the un-truncated structure.

Closes #135.

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Graham Neubig
2026-05-07 10:46:05 -04:00
committed by GitHub
co-authored by openhands
parent 0cd6ed390e
commit 84ed4d1217
8 changed files with 149 additions and 4 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 677 KiB

+26
View File
@@ -0,0 +1,26 @@
# Issue #135 — Show full model name in conversation header
Port of [OpenHands/OpenHands#14284](https://github.com/OpenHands/OpenHands/pull/14284).
## Recording setup
* Branch: `port/full-model-name-header`
* Backend: `npm run dev` (spawns isolated `openhands-agent-server@main` on `:18000`)
* Frontend: `localhost:12000` (exposed at `https://work-1-yrsrggnfhzzshrxo.prod-runtime.all-hands.dev/` for the recording)
* LLM configured via `PATCH /api/settings` with `agent_settings_diff.llm.model = litellm_proxy/claude-sonnet-4-5-20250929` and the live `LLM_API_KEY`.
The recordings were produced by `node scripts/record-demo.mjs full-model-name <out>.webm`, then converted to GIF with the ffmpeg snippet documented in that script.
## Visual diff
`comparison.png` stacks the same conversation header band before and after the fix:
* **Before** (`max-w-[150px] overflow-hidden` + inner `truncate`): `litellm_proxy/claude…` cuts off after the first 150 px.
* **After** (`whitespace-nowrap`, no inner truncate): the full `litellm_proxy/claude-sonnet-4-5-20250929` renders inline.
`before.png` / `after.png` are full 1280×720 captures of the conversation page in each state. `demo.gif` is the recorded "after" flow (home → New Conversation → header rendered with the un-truncated badge).
## Tests
* `npm test -- conversation-name` → 25/25 passing (includes the new assertions that the outer span has `whitespace-nowrap` and no `max-w-[150px]` / `overflow-hidden`, and the inner span has no `truncate`).
* `npm run typecheck` → clean.
@@ -312,10 +312,14 @@ describe("ConversationName", () => {
expect(model).toHaveAttribute("title", "openai/gpt-4o");
expect(model.querySelector("svg")).toBeInTheDocument();
// Verify truncation structure: text is wrapped in a span with truncate class
const textSpan = model.querySelector("span.truncate");
// Verify the model name is rendered in full (no truncation)
const textSpan = model.querySelector("span");
expect(textSpan).toBeInTheDocument();
expect(textSpan).toHaveTextContent("openai/gpt-4o");
expect(textSpan).not.toHaveClass("truncate");
expect(model).not.toHaveClass("max-w-[150px]");
expect(model).not.toHaveClass("overflow-hidden");
expect(model).toHaveClass("whitespace-nowrap");
});
it("should not render the llm model when not available", () => {
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env node
/**
* Record a short demo of agent-canvas for PR GIFs.
*
* Usage:
* node scripts/record-demo.mjs <flow> <output-webm>
*
* Convert the resulting webm to GIF with:
* ffmpeg -i <output>.webm \
* -vf "fps=10,scale=960:-1:flags=lanczos,split[s0][s1];\
* [s0]palettegen=max_colors=128[p];\
* [s1][p]paletteuse=dither=bayer:bayer_scale=4" \
* -loop 0 <output>.gif
*
* `npm run dev` from this repo must already be running. By default the
* recorder hits `http://localhost:12000`; override with `DEMO_BASE_URL` when
* the frontend is exposed via a remote host (e.g. an All Hands work URL),
* otherwise the browser-side calls to the agent-server will fail with CORS.
*
* Flows:
* - full-model-name : open home → click "New Conversation" → wait for the
* conversation header → hover the un-truncated model
* badge so the tooltip is visible at the end of the
* recording.
*/
import { chromium } from "playwright";
import { mkdirSync, renameSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
import process from "node:process";
const FLOW = process.argv[2] ?? "full-model-name";
const OUTPUT = process.argv[3]
? path.resolve(process.argv[3])
: path.resolve(`.pr/${FLOW}.webm`);
const BASE_URL = process.env.DEMO_BASE_URL ?? "http://localhost:12000";
const VIEWPORT = { width: 1280, height: 720 };
const outDir = path.dirname(OUTPUT);
mkdirSync(outDir, { recursive: true });
const tmpDir = path.join(outDir, ".tmp-record");
mkdirSync(tmpDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: VIEWPORT,
recordVideo: { dir: tmpDir, size: VIEWPORT },
});
const page = await context.newPage();
page.on("console", (msg) => {
if (msg.type() === "error") {
console.error(`[browser console error] ${msg.text()}`);
}
});
page.on("pageerror", (error) => {
console.error(`[browser page error] ${error.message}`);
});
async function flowFullModelName() {
await page.goto(`${BASE_URL}/`, { waitUntil: "domcontentloaded" });
// The "Account settings" popover (rendered when the user menu is open in
// the host shell) can overlay the home content during initial load. Press
// Escape so the home content is interactive, then dismiss any tooltip.
await page.waitForTimeout(1_500);
await page.keyboard.press("Escape");
await page.mouse.click(640, 360);
await page.waitForTimeout(500);
// Wait for the "Start from Scratch" card.
await page
.getByRole("button", { name: /New Conversation/i })
.first()
.waitFor({ state: "visible", timeout: 60_000 });
await page.waitForTimeout(800);
await page
.getByRole("button", { name: /New Conversation/i })
.first()
.click();
// Wait for navigation to /conversations/<id>.
await page.waitForURL(/\/conversations\//, { timeout: 30_000 });
// Wait for the model name badge to appear in the header.
await page
.getByTestId("conversation-name-llm-model")
.waitFor({ state: "visible", timeout: 60_000 });
// Hold so the GIF lingers on the un-truncated model name.
await page.waitForTimeout(2_500);
// Hover the model badge to make the tooltip appear.
await page.getByTestId("conversation-name-llm-model").hover();
await page.waitForTimeout(2_500);
}
const flows = {
"full-model-name": flowFullModelName,
};
if (!flows[FLOW]) {
throw new Error(`Unknown flow: ${FLOW}`);
}
await flows[FLOW]();
await context.close();
await browser.close();
// Move the generated webm into the requested output path.
const generated = readdirSync(tmpDir)
.filter((name) => name.endsWith(".webm"))
.map((name) => path.join(tmpDir, name))
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
if (!generated) {
throw new Error("Playwright did not produce a video file");
}
renameSync(generated, OUTPUT);
console.log(`Saved ${OUTPUT}`);
@@ -158,12 +158,12 @@ export function ConversationName() {
{titleMode !== "edit" && conversation.llm_model && (
<span
className="text-xs text-[#A3A3A3] max-w-[150px] flex items-center gap-1 overflow-hidden"
className="text-xs text-[#A3A3A3] flex items-center gap-1 whitespace-nowrap"
title={conversation.llm_model}
data-testid="conversation-name-llm-model"
>
<CircuitIcon width={12} height={12} className="shrink-0" />
<span className="truncate">{conversation.llm_model}</span>
<span>{conversation.llm_model}</span>
</span>
)}