mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 14:38:38 +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">
|
<div className="flex w-full items-center justify-between">
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
className="font-normal"
|
className="min-w-24 flex-1 font-normal"
|
||||||
label={
|
label={
|
||||||
task.status === "in_progress" ? (
|
<span className="block truncate" title={task.description}>
|
||||||
<Shimmer duration={3} spread={3}>
|
{task.status === "in_progress" ? (
|
||||||
{task.description}
|
<Shimmer
|
||||||
</Shimmer>
|
as="span"
|
||||||
) : (
|
className="inline"
|
||||||
task.description
|
duration={3}
|
||||||
)
|
spread={3}
|
||||||
|
>
|
||||||
|
{task.description}
|
||||||
|
</Shimmer>
|
||||||
|
) : (
|
||||||
|
task.description
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
icon={<ClipboardListIcon />}
|
icon={<ClipboardListIcon />}
|
||||||
></ChainOfThoughtStep>
|
></ChainOfThoughtStep>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
{collapsed && (
|
{collapsed && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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" : "",
|
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 =
|
const STOPPED_TASK_PROMPT =
|
||||||
"Investigate why the stopped subtask card should not remain running after reload.";
|
"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 = [
|
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.describe("Subtask card", () => {
|
||||||
test("shows failed after a stopped task thread is reloaded", async ({
|
test("shows failed after a stopped task thread is reloaded", async ({
|
||||||
page,
|
page,
|
||||||
@ -61,4 +241,117 @@ test.describe("Subtask card", () => {
|
|||||||
await expect(page.getByText("Subtask failed")).toBeVisible();
|
await expect(page.getByText("Subtask failed")).toBeVisible();
|
||||||
await expect(page.getByText("Running subtask")).toHaveCount(0);
|
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