mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 21:49:37 +00:00
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:
parent
340bff1107
commit
fd22f1a31d
@ -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" : "",
|
||||
)}
|
||||
>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user