From f8b966a738181e15c3a34aacc1b4c5cdecc0572c Mon Sep 17 00:00:00 2001 From: zouyanjian Date: Fri, 23 Oct 2026 20:38:13 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=A1=86=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E6=8C=89=E7=BE=A4=E5=88=86=E5=AD=98=EF=BC=8C=E5=88=87?= =?UTF-8?q?=E7=BE=A4=E4=B8=8D=E5=B8=A6=E8=B5=B0=EF=BC=9B=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=A1=86=E6=94=B9=E6=88=90=E8=87=AA=E5=A2=9E=E9=AB=98=E5=A4=9A?= =?UTF-8?q?=E8=A1=8C=EF=BC=8CEnter=20=E5=8F=91=E9=80=81=E3=80=81Shift+Ente?= =?UTF-8?q?r=20=E6=8D=A2=E8=A1=8C=E3=80=81=E7=BB=84=E5=AD=97=E5=9B=9E?= =?UTF-8?q?=E8=BD=A6=E4=B8=8D=E5=8F=91=20(#48=20#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Be3SZps2JjqvyrJ1vbLmni --- packages/web/e2e/composer-draft.spec.ts | 124 ++++++++++++++++++++++ packages/web/src/components/ChatPanel.tsx | 42 +++++--- packages/web/src/routes/ProjectPage.tsx | 13 +-- packages/web/src/state/composer-drafts.ts | 23 ++++ packages/web/src/styles/10-composer.css | 10 +- 5 files changed, 190 insertions(+), 22 deletions(-) create mode 100644 packages/web/e2e/composer-draft.spec.ts create mode 100644 packages/web/src/state/composer-drafts.ts diff --git a/packages/web/e2e/composer-draft.spec.ts b/packages/web/e2e/composer-draft.spec.ts new file mode 100644 index 00000000..bcf11022 --- /dev/null +++ b/packages/web/e2e/composer-draft.spec.ts @@ -0,0 +1,124 @@ +import { TEAM_TITLE, expect, login, openBot, test, type Page } from "./fixtures"; + +const userTexts = (page: Page) => page.getByTestId("message-list").locator("[data-kind=user]"); + +const openAssistant = async (page: Page) => { + await page.getByTestId("assistant-nav").click(); + await expect(page.getByTestId("assistant-nav")).toHaveAttribute("aria-current", "page"); +}; + +test("输入框草稿按群分存:切到别的群是空的,回车不会把上一个群的草稿发过去,切回来草稿还在", async ({ page }) => { + await login(page); + await openBot(page, "小销"); + const input = page.getByTestId("composer-input"); + await input.fill("给 Globex 报价写一半"); + + await openAssistant(page); + await expect(input, "主助理的输入框是它自己的,空着").toHaveValue(""); + const before = await userTexts(page).count(); + await input.press("Enter"); + await input.fill("主助理这边的草稿"); + await openBot(page, "小销"); + await expect(input, "切回小销,草稿还在").toHaveValue("给 Globex 报价写一半"); + + await openAssistant(page); + await expect(input, "主助理的草稿也各自留着").toHaveValue("主助理这边的草稿"); + await expect(userTexts(page), "空输入框回车什么都没发出去").toHaveCount(before); + await expect(page.getByTestId("message-list"), "小销的草稿没进主助理的群").not.toContainText("给 Globex 报价写一半"); +}); + +test("经过团队页和项目页再回来,草稿还在", async ({ page }) => { + await login(page); + await openBot(page, "小销"); + const input = page.getByTestId("composer-input"); + await input.fill("草稿要熬过团队页"); + + await page.getByTestId("team-nav").click(); + await expect(page.getByTestId("team-page")).toBeVisible(); + await openBot(page, "小销"); + await expect(input, "从团队页点回来").toHaveValue("草稿要熬过团队页"); + + await page.locator("[data-testid=group-item]").first().click(); + await expect(page.getByTestId("project-page")).toBeVisible(); + await openBot(page, "小销"); + await expect(input, "从项目页绕回来").toHaveValue("草稿要熬过团队页"); +}); + +test("项目页的输入框也按群分存,换到别的项目不带过去", async ({ page }) => { + await login(page); + await page.getByTestId("new-project").click(); + await page.getByTestId("new-project-input").fill("推广白泽的开源仓库,年底 1000 星"); + await page.getByTestId("new-project-submit").click(); + const offer = page.getByTestId("message-list").getByTestId("project-offer"); + await expect(offer, "主助理回了方案卡").toHaveAttribute("data-status", "offered"); + await offer.getByTestId("project-offer-start").click(); + await expect(page.getByTestId("group-item"), "开出了第二个项目").toHaveCount(2); + + const input = page.getByTestId("project-composer-input"); + await input.fill("新项目写一半"); + await page.locator(`[data-testid=group-item][data-group-title="${TEAM_TITLE}"]`).click(); + await expect(page.getByRole("heading", { name: TEAM_TITLE })).toBeVisible(); + await expect(input, "另一个项目的输入框是空的").toHaveValue(""); + await expect(page.getByTestId("project-composer-send"), "空着就发不出去").toBeDisabled(); + + await page.getByTestId("group-item").filter({ hasNotText: TEAM_TITLE }).click(); + await expect(input, "回到新项目,草稿还在").toHaveValue("新项目写一半"); +}); + +test("输入框是多行的:Shift+Enter 换行,Enter 发送,越写越高", async ({ page }) => { + await login(page); + await openBot(page, "小销"); + const input = page.getByTestId("composer-input"); + await input.fill("第一行"); + const oneLine = (await input.boundingBox())?.height ?? 0; + + await input.press("Shift+Enter"); + await input.pressSequentially("第二行"); + await input.press("Shift+Enter"); + await input.pressSequentially("第三行"); + await expect(input, "Shift+Enter 只换行不发送").toHaveValue("第一行\n第二行\n第三行"); + expect((await input.boundingBox())?.height ?? 0, "三行比一行高").toBeGreaterThan(oneLine); + + const before = await userTexts(page).count(); + await input.press("Enter"); + await expect(input, "Enter 发送后清空").toHaveValue(""); + await expect(userTexts(page)).toHaveCount(before + 1); + await expect(userTexts(page).last(), "发出去的是整段三行").toHaveText("第一行\n第二行\n第三行"); + expect((await input.boundingBox())?.height ?? 0, "清空后缩回一行").toBe(oneLine); +}); + +test("输入法组字时按回车只是上屏,不发送", async ({ page }) => { + await login(page); + await openBot(page, "小销"); + const input = page.getByTestId("composer-input"); + await input.focus(); + const before = await userTexts(page).count(); + + const cdp = await page.context().newCDPSession(page); + // 真输入法会把组字时的回车吃掉,页面只看到 keydown,不会多出换行。这里只发 keydown 和 keyup 来模拟。 + const enterKey = async (keyCode: number) => { + await cdp.send("Input.dispatchKeyEvent", { + type: "rawKeyDown", + key: "Enter", + code: "Enter", + windowsVirtualKeyCode: keyCode, + }); + await cdp.send("Input.dispatchKeyEvent", { + type: "keyUp", + key: "Enter", + code: "Enter", + windowsVirtualKeyCode: keyCode, + }); + }; + await cdp.send("Input.imeSetComposition", { text: "ni", selectionStart: 2, selectionEnd: 2 }); + await enterKey(13); + await cdp.send("Input.insertText", { text: "你" }); + await enterKey(229); + await expect(input, "组字中的回车没把字发走").toHaveValue("你"); + + await input.pressSequentially("好"); + await input.press("Enter"); + await expect(input).toHaveValue(""); + await expect(userTexts(page), "只发出了上屏后真按回车的那一条").toHaveCount(before + 1); + await expect(userTexts(page).last()).toHaveText("你好"); +}); diff --git a/packages/web/src/components/ChatPanel.tsx b/packages/web/src/components/ChatPanel.tsx index 58b67d38..c1a49ef0 100644 --- a/packages/web/src/components/ChatPanel.tsx +++ b/packages/web/src/components/ChatPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { ArrowDown, SendHorizontal, Sparkles, Users } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; import type { Bot, BotId, ChecklistItem, GroupId, Message, MessageId, Skill } from "@baize/shared"; @@ -19,6 +19,7 @@ import { TeamComputerNotes, TeamComputers, type ComputerChoicesView } from "./Co import { TaskCard } from "./TaskCard.js"; import { memberIdsOf, type Conversation, type GroupTurn } from "../state/app.js"; import { prefillOf } from "./routines/prefill.js"; +import { useComposerDraft } from "../state/composer-drafts.js"; import type { ContentBook, LoadStatus } from "../state/types.js"; import type { ContentActions } from "./messages/DraftCards.js"; import type { ProjectOfferActions } from "./messages/ProjectOfferCard.js"; @@ -123,13 +124,13 @@ export const ChatPanel = ({ botPanelOpen, onToggleBotPanel, }: Props) => { - const [draft, setDraft] = useState(""); + const groupId = conversation.groupId; + const [draft, setDraft] = useComposerDraft(groupId); const [pickerOff, setPickerOff] = useState(false); const thread = useRef(null); const atBottom = useRef(true); const [showScroll, setShowScroll] = useState(false); - const input = useRef(null); - const groupId = conversation.groupId; + const input = useRef(null); const team = conversation.kind === "team"; // 交接卡上的清单指针要指出那一项的正文。单 Bot 群没有任务卡,也就没有清单可指。 const checklist = conversation.kind === "team" ? conversation.task.checklist : NO_CHECKLIST; @@ -165,10 +166,17 @@ export const ChatPanel = ({ const prefill = prefillOf(location.state); useEffect(() => { if (prefill === null) return; - setDraft(prefill); + setDraft(groupId, prefill); input.current?.focus(); navigate(location.pathname, { replace: true, state: null }); - }, [prefill, location.pathname, navigate]); + }, [prefill, groupId, location.pathname, navigate, setDraft]); + + useLayoutEffect(() => { + const box = input.current; + if (box === null) return; + box.style.height = "auto"; + box.style.height = `${box.scrollHeight}px`; + }, [draft]); // 流式回复只让最后一条变长,条数不变;用户向上读历史时不能抢走滚动位置。 useEffect(() => { @@ -198,19 +206,22 @@ export const ChatPanel = ({ : []; const mention = (name: string) => { - setDraft(draft.replace(MENTION, `@${name} `)); + setDraft(groupId, draft.replace(MENTION, `@${name} `)); input.current?.focus(); }; const insertSkill = (name: string) => { - setDraft(draft.replace(SLASH, (_full, head: string) => `${head}/${name} `)); + setDraft( + groupId, + draft.replace(SLASH, (_full, head: string) => `${head}/${name} `), + ); input.current?.focus(); }; const projectOfferActions: ProjectOfferActions = { decide: onDecideProjectOffer, revise: () => { - setDraft("改一下:"); + setDraft(groupId, "改一下:"); input.current?.focus(); }, open: onOpenProject, @@ -220,9 +231,9 @@ export const ChatPanel = ({ event.preventDefault(); const text = draft.trim(); if (text.length === 0) return; - setDraft(""); + setDraft(groupId, ""); // 发失败了把字还回去,别让用户重打一遍。 - void onSend(groupId, text).catch(() => setDraft(text)); + void onSend(groupId, text).catch(() => setDraft(groupId, text)); }; return ( @@ -387,18 +398,23 @@ export const ChatPanel = ({ ) : null}
- { setPickerOff(false); - setDraft(e.target.value); + setDraft(groupId, e.target.value); }} onKeyDown={(e) => { if (e.key === "Escape") setPickerOff(true); + // 输入法组字时的回车是上屏。Safari 组字刚结束的那下回车 isComposing 已经是 false,只剩 keyCode 229 认得出。 + if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing || e.keyCode === 229) return; + e.preventDefault(); + e.currentTarget.form?.requestSubmit(); }} />
) : null}
-