fix: 修复研究助手首屏滚动与欢迎区布局

This commit is contained in:
yuxuanhui
2026-09-10 13:26:57 +08:00
parent 37e390a073
commit 1ba8d3ff3d
5 changed files with 98 additions and 25 deletions
+1
View File
@@ -33,6 +33,7 @@ export function AssistantDialogue({
timezone?: string; timezone?: string;
jobs: Job[]; jobs: Job[];
}) { }) {
if (!messages.length) return null;
const sources = new Map(messages.map((message) => [message.id, message])); const sources = new Map(messages.map((message) => [message.id, message]));
const chats: Message[] = messages.map((message) => ({ const chats: Message[] = messages.map((message) => ({
id: message.id, id: message.id,
+17 -15
View File
@@ -10,7 +10,7 @@ import {
IconPlus, IconPlus,
IconChevronRight, IconChevronRight,
} from "@douyinfe/semi-icons"; } from "@douyinfe/semi-icons";
import { contextLabel, contextReferences, researchPrompts } from "./workspace"; import { contextLabel, contextReferences } from "./workspace";
import { chatTransport } from "./transport"; import { chatTransport } from "./transport";
import { runLabels } from "./types"; import { runLabels } from "./types";
import type { import type {
@@ -58,7 +58,7 @@ export function ChatPanel({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const root = useRef<HTMLElement>(null); const root = useRef<HTMLElement>(null);
const input = useRef<HTMLDivElement>(null); const input = useRef<HTMLDivElement>(null);
const bottom = useRef<HTMLDivElement>(null); const messagesViewport = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement | null>(null); const previousFocus = useRef<HTMLElement | null>(null);
const seenChanges = useRef(new Set<string>()); const seenChanges = useRef(new Set<string>());
const current = useRef({ conversationId, context }); const current = useRef({ conversationId, context });
@@ -206,7 +206,7 @@ export function ChatPanel({
const editor = input.current?.querySelector<HTMLElement>( const editor = input.current?.querySelector<HTMLElement>(
'[contenteditable="true"]', '[contenteditable="true"]',
); );
if (editor) editor.focus(); if (editor) editor.focus({ preventScroll: true });
else else
root.current root.current
?.querySelector<HTMLButtonElement>('button[aria-label="收起研究助手"]') ?.querySelector<HTMLButtonElement>('button[aria-label="收起研究助手"]')
@@ -226,7 +226,11 @@ export function ChatPanel({
}; };
}, [open]); }, [open]);
useEffect(() => { 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]); }, [chat.messages, open]);
async function createConversation() { async function createConversation() {
@@ -409,7 +413,12 @@ export function ChatPanel({
/> />
)} )}
{failure && <Banner type="danger" description={failure} />} {failure && <Banner type="danger" description={failure} />}
<div className="ai-messages" aria-live="polite" aria-busy={streaming}> <div
ref={messagesViewport}
className="ai-messages"
aria-live="polite"
aria-busy={streaming}
>
{loading && <Spin />} {loading && <Spin />}
{!chat.messages.length && ( {!chat.messages.length && (
<div className="ai-empty"> <div className="ai-empty">
@@ -428,7 +437,9 @@ export function ChatPanel({
开始新会话 开始新会话
</Button> </Button>
)} )}
<span>修改和任务操作会先展示预览,确认后执行。</span> <p className="ai-empty-note">
修改和任务操作会先展示预览,确认后执行。
</p>
</div> </div>
)} )}
<AssistantDialogue <AssistantDialogue
@@ -460,7 +471,6 @@ export function ChatPanel({
)} )}
</div> </div>
)} )}
<div ref={bottom} />
</div> </div>
<footer className="ai-composer" ref={input}> <footer className="ai-composer" ref={input}>
<div className="ai-context">{contextLabel(context)}</div> <div className="ai-context">{contextLabel(context)}</div>
@@ -477,14 +487,6 @@ export function ChatPanel({
busy={!!activeRun || streaming} busy={!!activeRun || streaming}
onSubmit={() => void send()} onSubmit={() => void send()}
onStop={activeRun ? () => void stop() : undefined} onStop={activeRun ? () => void stop() : undefined}
prompts={researchPrompts(context)}
hint={
context.page === "datasets"
? context.unsaved_field_selection
? "字段选择尚未保存;请先保存为固定研究输入。未保存备注不会发送。"
: "引用随当前页面更新,未保存备注不会发送。"
: "引用随当前页面更新。修改和任务操作会先展示预览,确认后执行。"
}
/> />
</footer> </footer>
</aside> </aside>
+7 -3
View File
@@ -146,7 +146,7 @@
border-radius: 8px; border-radius: 8px;
} }
.ai-empty { .ai-empty {
margin: 32px 0; margin: 16px 0;
color: var(--muted); color: var(--muted);
line-height: 24px; line-height: 24px;
} }
@@ -159,8 +159,7 @@
.ai-empty .semi-button { .ai-empty .semi-button {
margin-top: var(--space-3); margin-top: var(--space-3);
} }
.ai-empty span { .ai-empty-note {
display: block;
font-size: 12px; font-size: 12px;
line-height: 20px; line-height: 20px;
margin-top: var(--space-4); margin-top: var(--space-4);
@@ -307,9 +306,14 @@
border-radius: 8px; border-radius: 8px;
background: var(--semi-color-ai-background-top); background: var(--semi-color-ai-background-top);
} }
/* The outer message region owns scrolling, including welcome and run status. */
.ai-messages .semi-ai-chat-dialogue { .ai-messages .semi-ai-chat-dialogue {
height: auto;
min-height: 0; min-height: 0;
} }
.ai-messages .semi-ai-chat-dialogue-list {
overflow: visible;
}
.ai-messages .semi-ai-chat-dialogue-inner { .ai-messages .semi-ai-chat-dialogue-inner {
min-width: 0; min-width: 0;
} }
+71
View File
@@ -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`,
});
});
}
+2 -7
View File
@@ -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("button", { name: "Alpha 管理", exact: true }).click();
await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0001"); await page.getByRole("textbox", { name: "搜索 Alpha" }).fill("TEST0001");
await page.getByRole("button", { name: "查询", exact: true }).click(); 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.locator(".alpha-link").click();
await page.getByRole("tab", { name: "研究信息" }).click(); await page.getByRole("tab", { name: "研究信息" }).click();
await page await page
.getByRole("textbox", { name: "研究备注" }) .getByRole("textbox", { name: "研究备注" })
.fill("尚未保存的人工研究草稿"); .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 研究助手" }); const chat = page.getByRole("complementary", { name: "AI 研究助手" });
await chat.getByRole("button", { name: "新会话", exact: true }).click(); await chat.getByRole("button", { name: "新会话", exact: true }).click();
await expect(chat.locator(".research-ai-reference")).toContainText( 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); 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.fill("SLOW <字段>");
await input.dispatchEvent("keydown", { await input.dispatchEvent("keydown", {
key: "Enter", key: "Enter",