Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions packages/web/e2e/composer-draft.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { TEAM_TITLE, expect, login, openBot, test, type Page } from "./fixtures";

const userTexts = (page: Page) => page.getByTestId("message-list").locator("[data-kind=user]");

const holdNextSend = async (page: Page) => {
let arrive = () => undefined as void;
const arrived = new Promise<void>((resolve) => (arrive = resolve));
let release = (_outcome: "pass" | "fail") => undefined as void;
const released = new Promise<"pass" | "fail">((resolve) => (release = resolve));
await page.route("**/api/groups/*/messages*", async (route) => {
if (route.request().method() !== "POST") return route.fallback();
arrive();
if ((await released) === "pass") return route.continue();
return route.fulfill({ status: 500, json: { error: "上游挂了" } });
});
return { arrived, release };
};

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("你好");
});

test("项目页发出去还没回音就进项目群,回音到了两边输入框都是空的", async ({ page }) => {
await login(page);
const send = await holdNextSend(page);
const project = page.locator("[data-testid=group-item]").first();
await project.click();
const projectInput = page.getByTestId("project-composer-input");
await projectInput.fill("这周多发两条小红书");
await page.getByTestId("project-composer-send").click();
await send.arrived;

await page.getByTestId("project-open-group").click();
const chatInput = page.getByTestId("composer-input");
await expect(chatInput).toBeVisible();
send.release("pass");
await expect(page.getByTestId("message-list"), "那句话落进了项目群").toContainText("这周多发两条小红书");
await expect(chatInput, "已经发出去的话不留成项目群的草稿").toHaveValue("");

await project.click();
await expect(projectInput, "项目页的输入框也是空的").toHaveValue("");
});

test("聊天里发出去还没回音就去团队页,发失败了回到原群草稿还在", async ({ page }) => {
await login(page);
const send = await holdNextSend(page);
await openBot(page, "小销");
const input = page.getByTestId("composer-input");
await input.fill("把 Globex 的报价发出去");
await input.press("Enter");
await send.arrived;
await expect(input).toHaveValue("");

await page.getByTestId("team-nav").click();
await expect(page.getByTestId("team-page")).toBeVisible();
const failed = page.waitForResponse(
(response) => response.request().method() === "POST" && response.status() === 500,
);
send.release("fail");
await failed;

await openBot(page, "小销");
await expect(input, "没发出去的话还回原群").toHaveValue("把 Globex 的报价发出去");
});

test("项目页的输入框也是多行的:Shift+Enter 换行,换行到项目群里还在,Enter 发送", async ({ page }) => {
await login(page);
await page.locator("[data-testid=group-item]").first().click();
const input = page.getByTestId("project-composer-input");
await input.fill("第一行");
await input.press("Shift+Enter");
await input.pressSequentially("第二行");
await expect(input, "Shift+Enter 只换行不发送").toHaveValue("第一行\n第二行");

await page.getByTestId("project-open-group").click();
await expect(page.getByTestId("composer-input"), "项目群里看到的草稿换行还在").toHaveValue("第一行\n第二行");
await page.locator("[data-testid=group-item]").first().click();

await input.press("Enter");
await expect(input, "Enter 发送后清空").toHaveValue("");
await expect(page.getByTestId("project-latest-turn"), "发出去的是整段两行").toContainText("第一行\n第二行");
});
31 changes: 18 additions & 13 deletions packages/web/src/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ 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 { setDraft, useComposerDraft } from "../state/composer-drafts.js";
import { ComposerTextarea } from "./ComposerTextarea.js";
import type { ContentBook, LoadStatus } from "../state/types.js";
import type { ContentActions } from "./messages/DraftCards.js";
import type { ProjectOfferActions } from "./messages/ProjectOfferCard.js";
Expand Down Expand Up @@ -123,13 +125,13 @@ export const ChatPanel = ({
botPanelOpen,
onToggleBotPanel,
}: Props) => {
const [draft, setDraft] = useState("");
const groupId = conversation.groupId;
const draft = useComposerDraft(groupId);
const [pickerOff, setPickerOff] = useState(false);
const thread = useRef<HTMLDivElement>(null);
const atBottom = useRef(true);
const [showScroll, setShowScroll] = useState(false);
const input = useRef<HTMLInputElement>(null);
const groupId = conversation.groupId;
const input = useRef<HTMLTextAreaElement>(null);
const team = conversation.kind === "team";
// 交接卡上的清单指针要指出那一项的正文。单 Bot 群没有任务卡,也就没有清单可指。
const checklist = conversation.kind === "team" ? conversation.task.checklist : NO_CHECKLIST;
Expand Down Expand Up @@ -165,10 +167,10 @@ 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]);

// 流式回复只让最后一条变长,条数不变;用户向上读历史时不能抢走滚动位置。
useEffect(() => {
Expand Down Expand Up @@ -198,19 +200,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,
Expand All @@ -220,9 +225,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 (
Expand Down Expand Up @@ -387,15 +392,15 @@ export const ChatPanel = ({
</div>
) : null}
<div className="composer">
<input
ref={input}
<ComposerTextarea
inputRef={input}
data-testid="composer-input"
value={draft}
placeholder={team ? `给 ${conversation.task.title} 发消息` : `给 ${lead?.name ?? "Bot"} 发消息`}
aria-label="发消息"
onChange={(e) => {
setPickerOff(false);
setDraft(e.target.value);
setDraft(groupId, e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Escape") setPickerOff(true);
Expand Down
32 changes: 32 additions & 0 deletions packages/web/src/components/ComposerTextarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useLayoutEffect } from "react";

type Props = Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "rows" | "className"> & {
value: string;
inputRef: React.RefObject<HTMLTextAreaElement>;
};

export const ComposerTextarea = ({ value, inputRef, onKeyDown, ...rest }: Props) => {
useLayoutEffect(() => {
const box = inputRef.current;
if (box === null) return;
box.style.height = "auto";
box.style.height = `${box.scrollHeight}px`;
}, [inputRef, value]);

return (
<textarea
{...rest}
ref={inputRef}
rows={1}
value={value}
className="composer-textarea"
onKeyDown={(e) => {
onKeyDown?.(e);
// 输入法组字时的回车是上屏。Safari 组字刚结束的那下回车 isComposing 已经是 false,只剩 keyCode 229 认得出。
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing || e.keyCode === 229) return;
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}}
/>
);
};
20 changes: 11 additions & 9 deletions packages/web/src/routes/ProjectPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { previewOf } from "../components/messages/preview.js";
import type { BotLookup } from "../components/messages/MessageView.js";
import type { Conversation } from "../state/types.js";
import { byCreatedAt } from "../state/order.js";
import { setDraft, useComposerDraft } from "../state/composer-drafts.js";
import { ComposerTextarea } from "../components/ComposerTextarea.js";
import { dueTextOf } from "../components/TaskCard.js";
import { goalBarValue, goalProgressOf, goalText, numberText, weekDeltaText, type Project } from "../state/project.js";
import { TodayPending, type Cards, type PendingProps } from "./TodayPage.js";
Expand Down Expand Up @@ -145,12 +147,12 @@ export const ProjectPage = ({
onArchive,
}: Props) => {
const { status, data, error, refresh } = project;
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const input = useRef<HTMLInputElement>(null);
const task = conversation.task;
const groupId = conversation.groupId;
const draft = useComposerDraft(groupId);
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const input = useRef<HTMLTextAreaElement>(null);
const members = conversation.botIds.flatMap((botId) => {
const bot = bots[botId];
return bot === undefined ? [] : [bot];
Expand Down Expand Up @@ -181,7 +183,7 @@ export const ProjectPage = ({
const working = data.working[0];
// 调整队伍就是跟主助理说一句:预填开头把话头递过去,已经打了字的不覆盖。
const adjustTeam = () => {
setDraft((current) => (current.trim() === "" ? ADJUST_TEAM_DRAFT : current));
if (draft.trim() === "") setDraft(groupId, ADJUST_TEAM_DRAFT);
input.current?.focus();
};
const submit = (event: React.FormEvent) => {
Expand All @@ -191,7 +193,7 @@ export const ProjectPage = ({
setSending(true);
setSendError(null);
void onSend(groupId, text)
.then(() => setDraft(""))
.then(() => setDraft(groupId, ""))
.catch(() => setSendError("没发出去,请重试"))
.finally(() => setSending(false));
};
Expand Down Expand Up @@ -253,13 +255,13 @@ export const ProjectPage = ({
</p>
)}
<form className="today-composer project-composer" onSubmit={submit}>
<input
ref={input}
<ComposerTextarea
inputRef={input}
data-testid="project-composer-input"
value={draft}
aria-label="对这个项目说一句"
placeholder="对这个项目说一句,比如「这周多发两条小红书」"
onChange={(event) => setDraft(event.target.value)}
onChange={(event) => setDraft(groupId, event.target.value)}
/>
<button
type="submit"
Expand Down
Loading
Loading