Skip to content

Commit 6dca709

Browse files
zqjzqj
authored andcommitted
Fix Control UI IME composition draft sync
1 parent 1a3ce7c commit 6dca709

2 files changed

Lines changed: 91 additions & 3 deletions

File tree

ui/src/ui/views/chat.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1650,6 +1650,66 @@ describe("chat slash menu accessibility", () => {
16501650
});
16511651
});
16521652

1653+
describe("chat composer IME composition", () => {
1654+
it("defers draft sync while IME composition is active", () => {
1655+
const onDraftChange = vi.fn();
1656+
const container = renderChatView({ onDraftChange });
1657+
const textarea = requireElement(
1658+
container,
1659+
".agent-chat__composer-combobox > textarea",
1660+
"composer textarea",
1661+
) as HTMLTextAreaElement;
1662+
1663+
textarea.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
1664+
textarea.value = "dangqian";
1665+
textarea.dispatchEvent(new InputEvent("input", { bubbles: true, isComposing: true }));
1666+
1667+
expect(onDraftChange).not.toHaveBeenCalled();
1668+
1669+
textarea.value = "当前";
1670+
textarea.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
1671+
1672+
expect(onDraftChange).toHaveBeenCalledTimes(1);
1673+
expect(onDraftChange).toHaveBeenLastCalledWith("当前");
1674+
});
1675+
1676+
it("preserves composing text across host rerenders with stale draft props", () => {
1677+
const onDraftChange = vi.fn();
1678+
const onRequestUpdate = vi.fn();
1679+
const container = document.createElement("div");
1680+
const props = createChatProps({ draft: "", onDraftChange, onRequestUpdate });
1681+
1682+
render(renderChat(props), container);
1683+
const textarea = requireElement(
1684+
container,
1685+
".agent-chat__composer-combobox > textarea",
1686+
"composer textarea",
1687+
) as HTMLTextAreaElement;
1688+
1689+
textarea.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
1690+
textarea.value = "dangqian";
1691+
textarea.dispatchEvent(new InputEvent("input", { bubbles: true, isComposing: true }));
1692+
1693+
expect(onDraftChange).not.toHaveBeenCalled();
1694+
expect(onRequestUpdate).not.toHaveBeenCalled();
1695+
1696+
render(renderChat({ ...props, draft: "" }), container);
1697+
1698+
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("dangqian");
1699+
1700+
const rerenderedTextarea = requireElement(
1701+
container,
1702+
".agent-chat__composer-combobox > textarea",
1703+
"composer textarea",
1704+
) as HTMLTextAreaElement;
1705+
rerenderedTextarea.value = "当前";
1706+
rerenderedTextarea.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
1707+
1708+
expect(onDraftChange).toHaveBeenCalledTimes(1);
1709+
expect(onDraftChange).toHaveBeenLastCalledWith("当前");
1710+
});
1711+
});
1712+
16531713
describe("chat attachment picker", () => {
16541714
it("converts pasted data image text into an attachment", () => {
16551715
const onAttachmentsChange = vi.fn();

ui/src/ui/views/chat.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@ interface ChatEphemeralState {
457457
searchOpen: boolean;
458458
searchQuery: string;
459459
pinnedExpanded: boolean;
460+
composerComposing: boolean;
460461
historyRenderSessionKey: string | null;
461462
historyRenderMessagesRef: unknown[] | null;
462463
historyRenderMessageCount: number;
@@ -482,6 +483,7 @@ function createChatEphemeralState(): ChatEphemeralState {
482483
searchOpen: false,
483484
searchQuery: "",
484485
pinnedExpanded: false,
486+
composerComposing: false,
485487
historyRenderSessionKey: null,
486488
historyRenderMessagesRef: null,
487489
historyRenderMessageCount: 0,
@@ -1933,12 +1935,19 @@ export function renderChat(props: ChatProps) {
19331935
}
19341936
};
19351937

1936-
const handleInput = (e: Event) => {
1937-
const target = e.target as HTMLTextAreaElement;
1938+
const syncComposerValue = (
1939+
target: HTMLTextAreaElement,
1940+
options: { forceCommit?: boolean } = {},
1941+
) => {
19381942
adjustTextareaHeight(target);
19391943
draftMirror.value = target.value;
19401944
const hostDraftNeeded = isBusy || showAbortableUi || props.queue.length > 0;
1941-
if (hostDraftNeeded || target.value.startsWith("/") || hasVisibleSlashMenuState()) {
1945+
if (
1946+
options.forceCommit ||
1947+
hostDraftNeeded ||
1948+
target.value.startsWith("/") ||
1949+
hasVisibleSlashMenuState()
1950+
) {
19421951
commitComposerDraft(props, target.value);
19431952
}
19441953
updateSlashMenu(target.value, requestUpdate);
@@ -1952,6 +1961,21 @@ export function renderChat(props: ChatProps) {
19521961
props.onSend();
19531962
syncComposerDraftAfterSend(composerTextarea);
19541963
};
1964+
1965+
const handleInput = (e: InputEvent) => {
1966+
const target = e.target as HTMLTextAreaElement;
1967+
if (vs.composerComposing || e.isComposing) {
1968+
adjustTextareaHeight(target);
1969+
draftMirror.value = target.value;
1970+
return;
1971+
}
1972+
syncComposerValue(target);
1973+
};
1974+
1975+
const handleCompositionEnd = (e: CompositionEvent) => {
1976+
vs.composerComposing = false;
1977+
syncComposerValue(e.target as HTMLTextAreaElement, { forceCommit: true });
1978+
};
19551979
const slashMenuVisible = isSlashMenuVisible();
19561980
const activeSlashMenuOptionId = getActiveSlashMenuOptionId();
19571981
const activeSlashMenuOptionLabel = getActiveSlashMenuOptionLabel();
@@ -2114,6 +2138,10 @@ export function renderChat(props: ChatProps) {
21142138
aria-describedby=${SLASH_MENU_ACTIVE_ANNOUNCEMENT_ID}
21152139
@keydown=${handleKeyDown}
21162140
@input=${handleInput}
2141+
@compositionstart=${() => {
2142+
vs.composerComposing = true;
2143+
}}
2144+
@compositionend=${handleCompositionEnd}
21172145
@blur=${handleBlur}
21182146
@paste=${(e: ClipboardEvent) => handlePaste(e, props)}
21192147
placeholder=${placeholder}

0 commit comments

Comments
 (0)