fix(frontend): truncate long subtask card titles to a single line (#5136)

* fix(frontend): truncate long subtask card titles to a single line

The subtask card header rendered task.description without any width
constraint; when a provider omits the optional description, the full
task prompt becomes the title and overflows the card.

Wrap the title in a truncating span (full text remains available via
the title tooltip and the expanded card body), give the step min-w-0
flex-1, and pin the status cluster with shrink-0 so overflow resolves
at the title.

Add an e2e test asserting a long prompt renders with the truncate
class, a real ellipsis (scrollWidth > clientWidth), and single-line
height.

* fix(frontend): keep subtask card status cluster shrinkable on narrow viewports

The shrink-0 status cluster could not shrink below its max-content (model
label + usage + status pill, up to ~456px with a long tool-call
description), so on narrow viewports it overflowed the header row while the
title collapsed to zero. Drop shrink-0 and add min-w-0 to both the cluster
and the pill (the pill's min-content is the status text's longest
unbreakable word, so one min-w-0 was not enough), and floor the title at
min-w-24 so it stays visible.

Also extend the e2e spec per review: an in_progress shimmer truncation test
(held-open SSE stream keeps the card running), a 375px no-overflow
assertion for both the resting and running card, and a pixel-budget
single-line check instead of parseFloat(lineHeight) which NaNs on the
'normal' keyword.

* test(frontend): honest fixture text and explicit visibility timeout in subtask spec

Review nits: the long-title fixture lifted the stopped test's human turn
whose text narrates the stop scenario; give it its own LONG_TASK_USER_TEXT
and override content alongside id and tool_calls. Add the missing 15s
timeout on the running-375px title visibility assertion so a future
reorder doesn't turn the 5s default into a cold-start flake.
This commit is contained in:
Zeren Wang 2026-09-01 18:08:38 +02:00 committed by GitHub
parent 340bff1107
commit fd22f1a31d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 312 additions and 12 deletions

View File

@ -138,23 +138,30 @@ export function SubtaskCard({
>
<div className="flex w-full items-center justify-between">
<ChainOfThoughtStep
className="font-normal"
className="min-w-24 flex-1 font-normal"
label={
task.status === "in_progress" ? (
<Shimmer duration={3} spread={3}>
{task.description}
</Shimmer>
) : (
task.description
)
<span className="block truncate" title={task.description}>
{task.status === "in_progress" ? (
<Shimmer
as="span"
className="inline"
duration={3}
spread={3}
>
{task.description}
</Shimmer>
) : (
task.description
)}
</span>
}
icon={<ClipboardListIcon />}
></ChainOfThoughtStep>
<div className="flex items-center gap-1">
<div className="flex min-w-0 items-center gap-1">
{collapsed && (
<div
className={cn(
"text-muted-foreground flex items-center gap-1 text-xs font-normal",
"text-muted-foreground flex min-w-0 items-center gap-1 text-xs font-normal",
task.status === "failed" ? "text-red-500 opacity-67" : "",
)}
>

View File

@ -1,9 +1,22 @@
import { expect, test } from "@playwright/test";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
import { expect, test, type Locator } from "@playwright/test";
import {
mockLangGraphAPI,
MOCK_RUN_ID,
MOCK_THREAD_ID,
} from "./utils/mock-api";
const STOPPED_TASK_PROMPT =
"Investigate why the stopped subtask card should not remain running after reload.";
const LONG_TASK_PROMPT =
"你的任务:分析 bytedance/deer-flow 前端核心线程同步文件 `frontend/src/core/threads/hooks.ts`(约 108KB提取其消息流同步机制的关键信息。背景用户在 DeerFlow 前端发现子代理任务卡片标题过长,需要确认截断行为。请重点关注消息合并、流式节流与本地排序逻辑,并输出结构化结论。";
const LONG_RUNNING_STATUS =
"Writing the quarterly infrastructure cost breakdown report to workspace/reports/q3-infra-cost-breakdown-final-v2.md";
const LONG_TASK_USER_TEXT =
"Analyze the thread sync hooks and report how the subtask card renders.";
const stoppedSubtaskMessages = [
{
@ -37,6 +50,173 @@ const stoppedSubtaskMessages = [
},
];
const longSubtaskThread = {
thread_id: MOCK_THREAD_ID,
title: "Long subtask title",
updated_at: "2026-06-18T12:00:00Z",
messages: [
{
// Reuse the stopped test's message *shape* only: its human text narrates
// the stop scenario, which none of the long-title tests exercise.
...stoppedSubtaskMessages[0],
id: "msg-human-long-subtask",
content: [{ type: "text", text: LONG_TASK_USER_TEXT }],
},
{
...stoppedSubtaskMessages[1],
id: "msg-ai-long-subtask",
tool_calls: [
{
id: "call-long-subtask",
name: "task",
args: {
subagent_type: "general-purpose",
prompt: LONG_TASK_PROMPT,
},
type: "tool_call",
},
],
},
],
};
async function expectSingleLineEllipsis(title: Locator) {
await expect(title).toHaveClass(/truncate/);
const metrics = await title.evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
height: el.getBoundingClientRect().height,
}));
expect(metrics.scrollWidth).toBeGreaterThan(metrics.clientWidth);
// A pixel budget instead of `parseFloat(lineHeight)`: the `normal` keyword
// parses to NaN and fails with an opaque "Received NaN" rather than pointing
// at the layout. One line of `text-sm` is 20px; 32px leaves slack without
// admitting a wrapped second line.
expect(metrics.height).toBeLessThanOrEqual(32);
}
/**
* SSE server that emits one `values` frame carrying an unresolved `task` tool
* call and then holds the connection open, keeping `thread.isLoading` true so
* the subtask card renders its running (shimmer) branch. Closed via
* `closeAllConnections` in test teardown.
*/
async function startRunningSubtaskStream() {
const aiMessage = {
type: "ai",
id: "msg-ai-running-subtask",
content: "",
additional_kwargs: {},
response_metadata: {},
tool_calls: [
{
id: "call-running-subtask",
name: "task",
args: {
subagent_type: "general-purpose",
prompt: LONG_TASK_PROMPT,
},
type: "tool_call",
},
],
invalid_tool_calls: [],
};
const server = createServer((request, response) => {
let body = "";
request.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
request.on("end", () => {
let inputMessages: unknown[] = [];
let bodyThreadId: string | undefined;
try {
const parsed = JSON.parse(body) as {
input?: { messages?: unknown[] };
thread_id?: string;
};
inputMessages = parsed.input?.messages ?? [];
bodyThreadId = parsed.thread_id;
} catch {
inputMessages = [];
}
const threadId =
/\/threads\/([^/]+)\/runs\/stream/.exec(request.url ?? "")?.[1] ??
bodyThreadId ??
MOCK_THREAD_ID;
const frames = [
{
event: "metadata",
data: { run_id: MOCK_RUN_ID, thread_id: threadId },
},
{
event: "values",
data: { messages: [...inputMessages, aiMessage] },
},
{
// A `task_running` step whose last tool call carries a long
// description: the collapsed header's status pill renders it via
// `explainLastToolCall`, growing the right-hand cluster past the
// width of a narrow card.
event: "custom",
data: {
type: "task_running",
task_id: "call-running-subtask",
message: {
type: "ai",
id: "msg-ai-running-subtask-step",
content: "",
additional_kwargs: {},
response_metadata: {},
tool_calls: [
{
id: "call-running-subtask-step",
name: "write_file",
args: { description: LONG_RUNNING_STATUS },
type: "tool_call",
},
],
invalid_tool_calls: [],
},
message_index: 1,
},
},
]
.map(
(event) =>
`event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`,
)
.join("");
response.writeHead(200, {
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache",
"Content-Type": "text/event-stream",
});
response.write(frames);
});
});
await new Promise<void>((resolve, reject) => {
const handleError = (error: Error) => reject(error);
server.once("error", handleError);
server.listen(0, "127.0.0.1", () => {
server.off("error", handleError);
resolve();
});
});
const { port } = server.address() as AddressInfo;
return {
url: `http://127.0.0.1:${port}/runs/stream`,
async close() {
server.closeAllConnections();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
},
};
}
test.describe("Subtask card", () => {
test("shows failed after a stopped task thread is reloaded", async ({
page,
@ -61,4 +241,117 @@ test.describe("Subtask card", () => {
await expect(page.getByText("Subtask failed")).toBeVisible();
await expect(page.getByText("Running subtask")).toHaveCount(0);
});
test("truncates a long task title to a single line", async ({ page }) => {
mockLangGraphAPI(page, {
threads: [longSubtaskThread],
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await page.reload();
const title = page.getByTitle(LONG_TASK_PROMPT, { exact: true });
await expect(title).toBeVisible({ timeout: 15_000 });
await expectSingleLineEllipsis(title);
});
test("keeps the header inside the card on a 375px viewport", async ({
page,
}) => {
await page.setViewportSize({ width: 375, height: 800 });
mockLangGraphAPI(page, {
threads: [longSubtaskThread],
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await page.reload();
const title = page.getByTitle(LONG_TASK_PROMPT, { exact: true });
await expect(title).toBeVisible({ timeout: 15_000 });
// The status cluster is shrinkable (`min-w-0`, per-item `truncate`), so a
// narrow card ellipsizes the cluster instead of collapsing the title to
// zero width and overflowing the row.
const row = title.locator(
"xpath=ancestor::div[contains(@class,'justify-between')][1]",
);
const rowMetrics = await row.evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
}));
expect(rowMetrics.scrollWidth).toBeLessThanOrEqual(rowMetrics.clientWidth);
await expectSingleLineEllipsis(title);
});
test("truncates a running task title with the shimmer inline", async ({
page,
}) => {
const streamServer = await startRunningSubtaskStream();
mockLangGraphAPI(page, {
runStreamHandler: (route) => route.continue({ url: streamServer.url }),
});
try {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("Run a subtask with a long title");
await textarea.press("Enter");
const title = page.getByTitle(LONG_TASK_PROMPT, { exact: true });
await expect(title).toBeVisible({ timeout: 15_000 });
// The shimmer must stay one inline text run inside the truncating span:
// `as="span"` avoids nesting the component's default <p>, and
// `className="inline"` overrides its `inline-block` so the parent span's
// nowrap/ellipsis still apply.
const shimmer = title.locator("span").first();
await expect(shimmer).toHaveCSS("display", "inline");
await expectSingleLineEllipsis(title);
} finally {
await streamServer.close();
}
});
test("keeps a running card inside the row on a 375px viewport", async ({
page,
}) => {
await page.setViewportSize({ width: 375, height: 800 });
const streamServer = await startRunningSubtaskStream();
mockLangGraphAPI(page, {
runStreamHandler: (route) => route.continue({ url: streamServer.url }),
});
try {
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("Run a subtask with a long status");
await textarea.press("Enter");
// The long `task_running` status pushes the right-hand cluster's
// max-content past the card width. The cluster must absorb that by
// ellipsizing (`min-w-0` on both wrappers + per-item `truncate`), not by
// pinning its width with `shrink-0` and overflowing the row; the title's
// `min-w-24` floor keeps it visible instead of collapsing to zero.
const status = page.getByText(LONG_RUNNING_STATUS, { exact: true });
await expect(status).toBeVisible({ timeout: 15_000 });
const title = page.getByTitle(LONG_TASK_PROMPT, { exact: true });
await expect(title).toBeVisible({ timeout: 15_000 });
await expectSingleLineEllipsis(title);
const pill = status.locator("xpath=..");
const row = status.locator(
"xpath=ancestor::div[contains(@class,'justify-between')][1]",
);
const metrics = await row.evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
}));
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth);
const pillMetrics = await pill.evaluate((el) => ({
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
}));
expect(pillMetrics.scrollWidth).toBeGreaterThan(pillMetrics.clientWidth);
} finally {
await streamServer.close();
}
});
});