diff --git a/frontend/src/components/workspace/messages/subtask-card.tsx b/frontend/src/components/workspace/messages/subtask-card.tsx index bced00578..9b42ffce6 100644 --- a/frontend/src/components/workspace/messages/subtask-card.tsx +++ b/frontend/src/components/workspace/messages/subtask-card.tsx @@ -138,23 +138,30 @@ export function SubtaskCard({ >
- {task.description} - - ) : ( - task.description - ) + + {task.status === "in_progress" ? ( + + {task.description} + + ) : ( + task.description + )} + } icon={} > -
+
{collapsed && (
diff --git a/frontend/tests/e2e/subtask-card.spec.ts b/frontend/tests/e2e/subtask-card.spec.ts index e8c7ae9c8..e3e434e95 100644 --- a/frontend/tests/e2e/subtask-card.spec.ts +++ b/frontend/tests/e2e/subtask-card.spec.ts @@ -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((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((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

, 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(); + } + }); });