From 1ba8d3ff3de55805e7342dd4b13a79efda6ff6df Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Thu, 10 Sep 2026 13:26:57 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=A0=94=E7=A9=B6?= =?UTF-8?q?=E5=8A=A9=E6=89=8B=E9=A6=96=E5=B1=8F=E6=BB=9A=E5=8A=A8=E4=B8=8E?= =?UTF-8?q?=E6=AC=A2=E8=BF=8E=E5=8C=BA=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/ai/AssistantDialogue.tsx | 1 + frontend/src/ai/ChatPanel.tsx | 32 ++++++------ frontend/src/ai/style.css | 10 ++-- frontend/tests/ai-layout.spec.ts | 71 +++++++++++++++++++++++++++ frontend/tests/ai.spec.ts | 9 +--- 5 files changed, 98 insertions(+), 25 deletions(-) create mode 100644 frontend/tests/ai-layout.spec.ts diff --git a/frontend/src/ai/AssistantDialogue.tsx b/frontend/src/ai/AssistantDialogue.tsx index b078169..990482a 100644 --- a/frontend/src/ai/AssistantDialogue.tsx +++ b/frontend/src/ai/AssistantDialogue.tsx @@ -33,6 +33,7 @@ export function AssistantDialogue({ timezone?: string; jobs: Job[]; }) { + if (!messages.length) return null; const sources = new Map(messages.map((message) => [message.id, message])); const chats: Message[] = messages.map((message) => ({ id: message.id, diff --git a/frontend/src/ai/ChatPanel.tsx b/frontend/src/ai/ChatPanel.tsx index eab640e..25c8a6d 100644 --- a/frontend/src/ai/ChatPanel.tsx +++ b/frontend/src/ai/ChatPanel.tsx @@ -10,7 +10,7 @@ import { IconPlus, IconChevronRight, } from "@douyinfe/semi-icons"; -import { contextLabel, contextReferences, researchPrompts } from "./workspace"; +import { contextLabel, contextReferences } from "./workspace"; import { chatTransport } from "./transport"; import { runLabels } from "./types"; import type { @@ -58,7 +58,7 @@ export function ChatPanel({ const [loading, setLoading] = useState(false); const root = useRef(null); const input = useRef(null); - const bottom = useRef(null); + const messagesViewport = useRef(null); const previousFocus = useRef(null); const seenChanges = useRef(new Set()); const current = useRef({ conversationId, context }); @@ -206,7 +206,7 @@ export function ChatPanel({ const editor = input.current?.querySelector( '[contenteditable="true"]', ); - if (editor) editor.focus(); + if (editor) editor.focus({ preventScroll: true }); else root.current ?.querySelector('button[aria-label="收起研究助手"]') @@ -226,7 +226,11 @@ export function ChatPanel({ }; }, [open]); useEffect(() => { - if (open) bottom.current?.scrollIntoView({ block: "nearest" }); + if (!open || !messagesViewport.current) return; + // Scroll only the message region; empty conversations always start at the welcome. + messagesViewport.current.scrollTop = chat.messages.length + ? messagesViewport.current.scrollHeight + : 0; }, [chat.messages, open]); async function createConversation() { @@ -409,7 +413,12 @@ export function ChatPanel({ /> )} {failure && } -
+
{loading && } {!chat.messages.length && (
@@ -428,7 +437,9 @@ export function ChatPanel({ 开始新会话 )} - 修改和任务操作会先展示预览,确认后执行。 +

+ 修改和任务操作会先展示预览,确认后执行。 +

)} )} -
diff --git a/frontend/src/ai/style.css b/frontend/src/ai/style.css index a45737d..41525dc 100644 --- a/frontend/src/ai/style.css +++ b/frontend/src/ai/style.css @@ -146,7 +146,7 @@ border-radius: 8px; } .ai-empty { - margin: 32px 0; + margin: 16px 0; color: var(--muted); line-height: 24px; } @@ -159,8 +159,7 @@ .ai-empty .semi-button { margin-top: var(--space-3); } -.ai-empty span { - display: block; +.ai-empty-note { font-size: 12px; line-height: 20px; margin-top: var(--space-4); @@ -307,9 +306,14 @@ border-radius: 8px; background: var(--semi-color-ai-background-top); } +/* The outer message region owns scrolling, including welcome and run status. */ .ai-messages .semi-ai-chat-dialogue { + height: auto; min-height: 0; } +.ai-messages .semi-ai-chat-dialogue-list { + overflow: visible; +} .ai-messages .semi-ai-chat-dialogue-inner { min-width: 0; } diff --git a/frontend/tests/ai-layout.spec.ts b/frontend/tests/ai-layout.spec.ts new file mode 100644 index 0000000..d9b8a09 --- /dev/null +++ b/frontend/tests/ai-layout.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from "@playwright/test"; + +for (const viewport of [ + { width: 1440, height: 850 }, + { width: 390, height: 650 }, +]) { + test(`assistant opens with welcome and composer in view at ${viewport.width}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await page.route("**/api/v1/ai/conversations", (route) => + route.fulfill({ json: [] }), + ); + await page.route("**/api/v1/account", async (route) => { + const response = await route.fetch(); + await route.fulfill({ + json: { ...(await response.json()), theme: "dark" }, + }); + }); + if (viewport.width === 1440) + await page.route("**/api/v1/ai/settings", (route) => + route.fulfill({ json: { enabled: true, ready: true } }), + ); + await page.goto("/"); + await page + .getByLabel("密码", { exact: true }) + .fill("browser-test-password"); + await page.getByRole("button", { name: "进入工作空间" }).click(); + await page.evaluate(() => document.body.setAttribute("theme-mode", "dark")); + await page.getByRole("button", { name: "切换研究助手" }).click(); + const chat = page.getByRole("complementary", { name: "AI 研究助手" }); + + await expect( + chat.getByRole("heading", { name: "从当前研究出发" }), + ).toBeInViewport(); + await expect( + chat.getByRole("button", { name: "发送", exact: true }), + ).toBeInViewport(); + await expect(page.locator("body")).toHaveAttribute("theme-mode", "dark"); + await page.screenshot({ + path: `../output/playwright/ai-layout-${viewport.width}.png`, + }); + const start = chat.getByRole("button", { name: "开始新会话", exact: true }); + expect( + await start.evaluate((button) => { + const outer = button.getBoundingClientRect(); + return [...button.querySelectorAll("span")].every((span) => { + const inner = span.getBoundingClientRect(); + return inner.top >= outer.top && inner.bottom <= outer.bottom; + }); + }), + ).toBe(true); + await expect( + chat.locator(".research-ai-prompts, .research-ai-hint"), + ).toHaveCount(0); + expect( + await chat.evaluate((el) => el.scrollHeight <= el.clientHeight), + ).toBe(true); + await chat.getByRole("button", { name: "收起研究助手" }).click(); + await page.getByRole("button", { name: "切换研究助手" }).click(); + await expect( + chat.getByRole("heading", { name: "从当前研究出发" }), + ).toBeInViewport(); + await page.evaluate(() => + document.body.setAttribute("theme-mode", "light"), + ); + await page.screenshot({ + path: `../output/playwright/ai-layout-${viewport.width}-light.png`, + }); + }); +} diff --git a/frontend/tests/ai.spec.ts b/frontend/tests/ai.spec.ts index db49721..cf10257 100644 --- a/frontend/tests/ai.spec.ts +++ b/frontend/tests/ai.spec.ts @@ -84,13 +84,13 @@ test("model settings, text stream, contextual query, confirmation and preserved await page.getByRole("button", { name: "Alpha 管理", exact: true }).click(); await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0001"); await page.getByRole("button", { name: "查询", exact: true }).click(); + // Global tools sit below drawers; open the assistant before the Alpha detail. + await page.getByRole("button", { name: "切换研究助手" }).click(); await page.locator(".alpha-link").click(); await page.getByRole("tab", { name: "研究信息" }).click(); await page .getByRole("textbox", { name: "研究备注" }) .fill("尚未保存的人工研究草稿"); - // A global launcher must remain reachable when the detail mask is open. - await page.getByRole("button", { name: "切换研究助手" }).click(); const chat = page.getByRole("complementary", { name: "AI 研究助手" }); await chat.getByRole("button", { name: "新会话", exact: true }).click(); await expect(chat.locator(".research-ai-reference")).toContainText( @@ -430,11 +430,6 @@ test("AI composer preserves literal text, IME input and drafts while generating" ) sent.push(request.postDataJSON().message); }); - await chat - .getByRole("button", { name: "查询当前研究对象与已有结果", exact: true }) - .click(); - await expect(input).toHaveText("查询当前研究对象与已有结果"); - expect(sent).toEqual([]); await input.fill("SLOW <字段>"); await input.dispatchEvent("keydown", { key: "Enter",