mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 00:09:35 +00:00
Merged via squash.
Prepared head SHA: 32e5fd9cc3
Co-authored-by: zhangguiping-xydt <275915537+zhangguiping-xydt@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
This commit is contained in:
@@ -252,6 +252,57 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the composer clear when a stale native input replay arrives after send", async () => {
|
||||
const context = await newBrowserContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page, {
|
||||
historyMessages: [
|
||||
{
|
||||
content: [{ text: "Ready for stale replay check.", type: "text" }],
|
||||
role: "assistant",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${server.baseUrl}chat`);
|
||||
await page.getByText("Ready for stale replay check.").waitFor({ timeout: 10_000 });
|
||||
|
||||
const prompt = "submitted message";
|
||||
const composer = page.locator(".agent-chat__composer-combobox textarea");
|
||||
await composer.fill(prompt);
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
await gateway.waitForRequest("chat.send");
|
||||
expect(await composer.inputValue()).toBe("");
|
||||
|
||||
const afterReplay = await composer.evaluate((element, submitted) => {
|
||||
const textarea = element as HTMLTextAreaElement;
|
||||
textarea.value = submitted;
|
||||
textarea.dispatchEvent(
|
||||
new InputEvent("input", {
|
||||
bubbles: true,
|
||||
data: submitted,
|
||||
inputType: "insertText",
|
||||
}),
|
||||
);
|
||||
return textarea.value;
|
||||
}, prompt);
|
||||
|
||||
expect(afterReplay).toBe("");
|
||||
expect(await composer.inputValue()).toBe("");
|
||||
|
||||
await composer.pressSequentially(prompt);
|
||||
expect(await composer.inputValue()).toBe(prompt);
|
||||
} finally {
|
||||
await closeBrowserContext(context);
|
||||
}
|
||||
});
|
||||
|
||||
it("copies a code block over a non-secure context via the execCommand fallback", async () => {
|
||||
const context = await newBrowserContext({
|
||||
locale: "en-US",
|
||||
|
||||
@@ -1973,6 +1973,212 @@ describe("chat slash menu accessibility", () => {
|
||||
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("");
|
||||
});
|
||||
|
||||
it("ignores a stale native InputEvent replay after send clears the host draft", () => {
|
||||
let draft = "";
|
||||
const container = document.createElement("div");
|
||||
const onDraftChange = vi.fn((next: string) => {
|
||||
draft = next;
|
||||
});
|
||||
const onSend = vi.fn(() => {
|
||||
draft = "";
|
||||
});
|
||||
const renderWithDraft = () => {
|
||||
render(
|
||||
renderChat(createChatProps({ draft, getDraft: () => draft, onDraftChange, onSend })),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
renderWithDraft();
|
||||
inputDraft(container, "submitted message");
|
||||
container.querySelector<HTMLButtonElement>(".chat-send-btn")!.click();
|
||||
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
expect(textarea?.value).toBe("");
|
||||
|
||||
textarea!.value = "submitted message";
|
||||
textarea!.dispatchEvent(
|
||||
new InputEvent("input", {
|
||||
bubbles: true,
|
||||
data: "submitted message",
|
||||
inputType: "insertText",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(textarea?.value).toBe("");
|
||||
expect(onDraftChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps a new same-session draft when a delayed stale replay arrives", () => {
|
||||
let draft = "";
|
||||
const container = document.createElement("div");
|
||||
const onDraftChange = vi.fn((next: string) => {
|
||||
draft = next;
|
||||
});
|
||||
const onSend = vi.fn(() => {
|
||||
draft = "";
|
||||
});
|
||||
const renderWithDraft = () => {
|
||||
render(
|
||||
renderChat(createChatProps({ draft, getDraft: () => draft, onDraftChange, onSend })),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
renderWithDraft();
|
||||
inputDraft(container, "submitted message");
|
||||
container.querySelector<HTMLButtonElement>(".chat-send-btn")!.click();
|
||||
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
expect(textarea?.value).toBe("");
|
||||
|
||||
textarea!.dispatchEvent(
|
||||
new InputEvent("beforeinput", {
|
||||
bubbles: true,
|
||||
data: "new draft",
|
||||
inputType: "insertText",
|
||||
}),
|
||||
);
|
||||
textarea!.value = "new draft";
|
||||
textarea!.dispatchEvent(
|
||||
new InputEvent("input", {
|
||||
bubbles: true,
|
||||
data: "new draft",
|
||||
inputType: "insertText",
|
||||
}),
|
||||
);
|
||||
expect(textarea?.value).toBe("new draft");
|
||||
|
||||
textarea!.value = "submitted message";
|
||||
textarea!.dispatchEvent(
|
||||
new InputEvent("input", {
|
||||
bubbles: true,
|
||||
data: "submitted message",
|
||||
inputType: "insertText",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(textarea?.value).toBe("new draft");
|
||||
expect(onDraftChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not apply a stale submitted draft replay to another session", () => {
|
||||
const drafts: Record<string, string> = {
|
||||
"stale-replay-a": "",
|
||||
"stale-replay-b": "",
|
||||
};
|
||||
const onDraftChange = vi.fn((sessionKey: string, next: string) => {
|
||||
drafts[sessionKey] = next;
|
||||
});
|
||||
const container = document.createElement("div");
|
||||
const renderSession = (sessionKey: string) => {
|
||||
render(
|
||||
renderChat(
|
||||
createChatProps({
|
||||
currentAgentId: "stale-replay-agent",
|
||||
draft: drafts[sessionKey],
|
||||
getDraft: () => drafts[sessionKey],
|
||||
onDraftChange: (next) => onDraftChange(sessionKey, next),
|
||||
onSend: () => {
|
||||
drafts[sessionKey] = "";
|
||||
},
|
||||
sessionKey,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
renderSession("stale-replay-a");
|
||||
inputDraft(container, "submitted message");
|
||||
container.querySelector<HTMLButtonElement>(".chat-send-btn")!.click();
|
||||
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("");
|
||||
|
||||
renderSession("stale-replay-b");
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
expect(textarea?.value).toBe("");
|
||||
|
||||
textarea!.value = "submitted message";
|
||||
textarea!.dispatchEvent(
|
||||
new InputEvent("input", {
|
||||
bubbles: true,
|
||||
data: "submitted message",
|
||||
inputType: "insertText",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(textarea?.value).toBe("");
|
||||
expect(drafts["stale-replay-b"]).toBe("");
|
||||
expect(onDraftChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps an intervening session draft when a delayed stale replay arrives", () => {
|
||||
const drafts: Record<string, string> = {
|
||||
"delayed-replay-a": "",
|
||||
"delayed-replay-b": "",
|
||||
};
|
||||
const onDraftChange = vi.fn((sessionKey: string, next: string) => {
|
||||
drafts[sessionKey] = next;
|
||||
});
|
||||
const container = document.createElement("div");
|
||||
const renderSession = (sessionKey: string) => {
|
||||
render(
|
||||
renderChat(
|
||||
createChatProps({
|
||||
currentAgentId: "delayed-replay-agent",
|
||||
draft: drafts[sessionKey],
|
||||
getDraft: () => drafts[sessionKey],
|
||||
onDraftChange: (next) => onDraftChange(sessionKey, next),
|
||||
onSend: () => {
|
||||
drafts[sessionKey] = "";
|
||||
},
|
||||
sessionKey,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
};
|
||||
|
||||
renderSession("delayed-replay-a");
|
||||
inputDraft(container, "submitted message");
|
||||
container.querySelector<HTMLButtonElement>(".chat-send-btn")!.click();
|
||||
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("");
|
||||
|
||||
renderSession("delayed-replay-b");
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
expect(textarea?.value).toBe("");
|
||||
|
||||
textarea!.dispatchEvent(
|
||||
new InputEvent("beforeinput", {
|
||||
bubbles: true,
|
||||
data: "session b draft",
|
||||
inputType: "insertText",
|
||||
}),
|
||||
);
|
||||
textarea!.value = "session b draft";
|
||||
textarea!.dispatchEvent(
|
||||
new InputEvent("input", {
|
||||
bubbles: true,
|
||||
data: "session b draft",
|
||||
inputType: "insertText",
|
||||
}),
|
||||
);
|
||||
expect(textarea?.value).toBe("session b draft");
|
||||
|
||||
textarea!.value = "submitted message";
|
||||
textarea!.dispatchEvent(
|
||||
new InputEvent("input", {
|
||||
bubbles: true,
|
||||
data: "submitted message",
|
||||
inputType: "insertText",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(textarea?.value).toBe("session b draft");
|
||||
expect(drafts["delayed-replay-b"]).toBe("");
|
||||
expect(onDraftChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("commits local draft input before Enter sends", () => {
|
||||
const onDraftChange = vi.fn();
|
||||
const onSend = vi.fn();
|
||||
|
||||
@@ -33,13 +33,13 @@ import { CHAT_HISTORY_RENDER_LIMIT } from "../chat/history-limits.ts";
|
||||
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "../chat/input-history.ts";
|
||||
import { PinnedMessages } from "../chat/pinned-messages.ts";
|
||||
import { getPinnedMessageSummary } from "../chat/pinned-summary.ts";
|
||||
import type { RealtimeTalkConversationEntry } from "../chat/realtime-talk-conversation.ts";
|
||||
import {
|
||||
REALTIME_TALK_FALLBACK_PROVIDERS,
|
||||
listSelectableRealtimeTalkProviders,
|
||||
resolveControlUiRealtimeTalkProviderTransports,
|
||||
type RealtimeTalkCatalogProvider,
|
||||
} from "../chat/realtime-talk-catalog.ts";
|
||||
import type { RealtimeTalkConversationEntry } from "../chat/realtime-talk-conversation.ts";
|
||||
import type { RealtimeTalkStatus } from "../chat/realtime-talk.ts";
|
||||
import { renderChatRunControls } from "../chat/run-controls.ts";
|
||||
import type { ChatRunUiStatus } from "../chat/run-lifecycle.ts";
|
||||
@@ -506,6 +506,11 @@ function renderRealtimeTalkConversation(props: ChatProps) {
|
||||
`;
|
||||
}
|
||||
|
||||
type PendingClearedSubmittedDraft = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
interface ChatEphemeralState {
|
||||
slashMenuOpen: boolean;
|
||||
slashMenuItems: SlashCommandDef[];
|
||||
@@ -519,6 +524,8 @@ interface ChatEphemeralState {
|
||||
searchQuery: string;
|
||||
pinnedExpanded: boolean;
|
||||
composerComposing: boolean;
|
||||
composerInputIntentKey: string | null;
|
||||
pendingClearedSubmittedDraft: PendingClearedSubmittedDraft | null;
|
||||
historyRenderSessionKey: string | null;
|
||||
historyRenderMessagesRef: unknown[] | null;
|
||||
historyRenderMessageCount: number;
|
||||
@@ -546,6 +553,8 @@ function createChatEphemeralState(): ChatEphemeralState {
|
||||
searchQuery: "",
|
||||
pinnedExpanded: false,
|
||||
composerComposing: false,
|
||||
composerInputIntentKey: null,
|
||||
pendingClearedSubmittedDraft: null,
|
||||
historyRenderSessionKey: null,
|
||||
historyRenderMessagesRef: null,
|
||||
historyRenderMessageCount: 0,
|
||||
@@ -602,6 +611,47 @@ function commitComposerDraft(props: ChatProps, value: string): void {
|
||||
props.onDraftChange(value);
|
||||
}
|
||||
|
||||
function markComposerInputIntent(key: string): void {
|
||||
vs.composerInputIntentKey = key;
|
||||
}
|
||||
|
||||
function consumeComposerInputIntent(key: string): boolean {
|
||||
if (vs.composerInputIntentKey !== key) {
|
||||
return false;
|
||||
}
|
||||
vs.composerInputIntentKey = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearPendingClearedSubmittedDraft(key: string): void {
|
||||
if (vs.pendingClearedSubmittedDraft?.key === key) {
|
||||
vs.pendingClearedSubmittedDraft = null;
|
||||
}
|
||||
}
|
||||
|
||||
function isExplicitComposerInsertion(event: InputEvent): boolean {
|
||||
return event.inputType === "insertFromPaste" || event.inputType === "insertFromDrop";
|
||||
}
|
||||
|
||||
function suppressStaleSubmittedDraftReplay(
|
||||
target: HTMLTextAreaElement,
|
||||
event: InputEvent,
|
||||
draftMirror: ComposerDraftMirror,
|
||||
hasInputIntent: boolean,
|
||||
): boolean {
|
||||
const pending = vs.pendingClearedSubmittedDraft;
|
||||
if (!pending) {
|
||||
return false;
|
||||
}
|
||||
if (target.value !== pending.value || hasInputIntent || isExplicitComposerInsertion(event)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
target.value = draftMirror.value;
|
||||
adjustTextareaHeight(target);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sameChatItemsInput(previous: BuildChatItemsProps, next: BuildChatItemsProps): boolean {
|
||||
return (
|
||||
previous.sessionKey === next.sessionKey &&
|
||||
@@ -2263,10 +2313,22 @@ export function renderChat(props: ChatProps) {
|
||||
if (typeof hostDraft !== "string") {
|
||||
return;
|
||||
}
|
||||
const mirrorKey = composerDraftMirrorKey(props);
|
||||
const submittedDraft = draftMirror.value;
|
||||
const clearedSubmittedDraft =
|
||||
hostDraft === "" && submittedDraft !== "" && target?.value === submittedDraft;
|
||||
// Sends can clear the host draft synchronously before Lit rerenders; keep
|
||||
// the local mirror aligned so the submitted text does not stay editable.
|
||||
draftMirror.hostDraft = hostDraft;
|
||||
draftMirror.value = hostDraft;
|
||||
if (clearedSubmittedDraft) {
|
||||
vs.pendingClearedSubmittedDraft = {
|
||||
key: mirrorKey,
|
||||
value: submittedDraft,
|
||||
};
|
||||
} else {
|
||||
clearPendingClearedSubmittedDraft(mirrorKey);
|
||||
}
|
||||
if (target && target.value !== hostDraft) {
|
||||
target.value = hostDraft;
|
||||
adjustTextareaHeight(target);
|
||||
@@ -2417,8 +2479,15 @@ export function renderChat(props: ChatProps) {
|
||||
}
|
||||
updateSlashMenu(target.value, requestUpdate, props, {}, () => target.value);
|
||||
};
|
||||
const handleBeforeInput = (e: InputEvent) => {
|
||||
if (!vs.composerComposing && !e.isComposing) {
|
||||
markComposerInputIntent(composerDraftMirrorKey(props));
|
||||
}
|
||||
};
|
||||
const handleInput = (e: InputEvent) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
const mirrorKey = composerDraftMirrorKey(props);
|
||||
const hasInputIntent = consumeComposerInputIntent(mirrorKey);
|
||||
if (vs.composerComposing || e.isComposing) {
|
||||
// Skip adjustTextareaHeight during IME composition — each pinyin
|
||||
// keystroke fires `input` and the height read/write forces a
|
||||
@@ -2427,6 +2496,9 @@ export function renderChat(props: ChatProps) {
|
||||
draftMirror.value = target.value;
|
||||
return;
|
||||
}
|
||||
if (suppressStaleSubmittedDraftReplay(target, e, draftMirror, hasInputIntent)) {
|
||||
return;
|
||||
}
|
||||
syncComposerValue(target);
|
||||
};
|
||||
const handleCompositionEnd = (e: CompositionEvent) => {
|
||||
@@ -2538,6 +2610,7 @@ export function renderChat(props: ChatProps) {
|
||||
aria-activedescendant=${ifDefined(activeSlashMenuOptionId ?? undefined)}
|
||||
aria-describedby=${SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID}
|
||||
@keydown=${handleKeyDown}
|
||||
@beforeinput=${handleBeforeInput}
|
||||
@input=${handleInput}
|
||||
@compositionstart=${() => {
|
||||
vs.composerComposing = true;
|
||||
|
||||
Reference in New Issue
Block a user