Merge branch 'main' into ci/workflow-permissions-and-fewer-skips

This commit is contained in:
Daniel Han 2026-05-07 14:15:12 -07:00 committed by GitHub
commit 0823562525
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 124 additions and 8 deletions

View file

@ -12,6 +12,7 @@
"@assistant-ui/react": "0.12.28",
"@assistant-ui/react-markdown": "0.12.11",
"@assistant-ui/react-streamdown": "0.1.11",
"@assistant-ui/tap": "0.5.10",
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
"@dagrejs/graphlib": "^3.0.4",

View file

@ -20,6 +20,7 @@
"@assistant-ui/react": "0.12.28",
"@assistant-ui/react-markdown": "0.12.11",
"@assistant-ui/react-streamdown": "0.1.11",
"@assistant-ui/tap": "0.5.10",
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
"@dagrejs/graphlib": "^3.0.4",

View file

@ -51,6 +51,7 @@ import {
useAuiEvent,
useAuiState,
} from "@assistant-ui/react";
import { flushResourcesSync } from "@assistant-ui/tap";
import {
ArrowDownIcon,
ArrowUpIcon,
@ -72,6 +73,8 @@ import {
import { Copy01Icon, Delete02Icon, Edit03Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
type ChangeEvent,
type CompositionEvent,
type FC,
type FormEvent,
useCallback,
@ -282,13 +285,15 @@ const PendingAudioChip: FC = () => {
};
const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
const { inputProps, isComposing, isComposingRef } = useImeComposerInputHandlers();
const handleSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => {
if (disabled) {
if (disabled || isComposingRef.current) {
event.preventDefault();
}
},
[disabled],
[disabled, isComposingRef],
);
const composerContent = (
@ -304,8 +309,12 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
autoFocus={!disabled}
disabled={disabled}
aria-label="Message input"
{...inputProps}
/>
<ComposerAction
disabled={disabled || isComposing}
blockSend={() => isComposingRef.current}
/>
<ComposerAction disabled={disabled} />
</>
);
@ -330,6 +339,64 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
);
};
function isNativeComposing(event: Event) {
return "isComposing" in event && (event as InputEvent).isComposing === true;
}
function useImeComposerInputHandlers() {
const aui = useAui();
const composingRef = useRef(false);
const [isComposing, setIsComposing] = useState(false);
const setCompositionState = useCallback((next: boolean) => {
composingRef.current = next;
setIsComposing(next);
}, []);
const setComposerText = useCallback(
(value: string) => {
const composer = aui.composer();
if (!composer.getState().isEditing) {
return;
}
flushResourcesSync(() => {
composer.setText(value);
});
},
[aui],
);
const onCompositionStart = useCallback(() => {
setCompositionState(true);
}, [setCompositionState]);
const onCompositionEnd = useCallback(
(e: CompositionEvent<HTMLTextAreaElement>) => {
setCompositionState(false);
setComposerText(e.currentTarget.value);
},
[setComposerText, setCompositionState],
);
const onChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
setCompositionState(isNativeComposing(e.nativeEvent));
setComposerText(e.target.value);
},
[setComposerText, setCompositionState],
);
return {
inputProps: {
onCompositionStart,
onCompositionEnd,
onChange,
},
isComposing,
isComposingRef: composingRef,
};
}
const ComposerAudioUpload: FC = () => {
const audioInputRef = useRef<HTMLInputElement>(null);
const setPendingAudio = useChatRuntimeStore((s) => s.setPendingAudio);
@ -607,7 +674,10 @@ const ToolStatusDisplay: FC = () => {
);
};
const ComposerAction: FC<{ disabled?: boolean }> = ({ disabled }) => {
const ComposerAction: FC<{ disabled?: boolean; blockSend?: () => boolean }> = ({
disabled,
blockSend,
}) => {
return (
<div className="aui-composer-action-wrapper composer-action-wrapper">
<div className="flex items-center gap-1">
@ -650,6 +720,11 @@ const ComposerAction: FC<{ disabled?: boolean }> = ({ disabled }) => {
variant="default"
size="icon"
disabled={disabled}
onClick={(event) => {
if (blockSend?.()) {
event.preventDefault();
}
}}
className="aui-composer-send size-8 rounded-full"
aria-label="Send message"
>
@ -903,6 +978,7 @@ const UserActionBar: FC = () => {
const EditComposer: FC = () => {
const aui = useAui();
const { inputProps, isComposingRef } = useImeComposerInputHandlers();
const resendAfterCancelRef = useRef(false);
useAuiEvent("thread.runEnd", () => {
@ -919,16 +995,22 @@ const EditComposer: FC = () => {
<ComposerPrimitive.Input
className="aui-edit-composer-input min-h-14 w-full resize-none bg-transparent p-4 text-foreground text-sm font-[450] outline-none"
autoFocus={true}
{...inputProps}
/>
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
<ComposerPrimitive.Cancel asChild={true}>
<Button variant="ghost" size="sm">
<Button type="button" variant="ghost" size="sm">
Cancel
</Button>
</ComposerPrimitive.Cancel>
<Button
type="button"
size="sm"
onClick={() => {
onClick={(event) => {
if (isComposingRef.current) {
event.preventDefault();
return;
}
const newText = aui.composer().getState().text;
const originalText = aui.message().getCopyText();

View file

@ -20,6 +20,7 @@ import { toast } from "sonner";
import { loadModel, validateModel } from "./api/chat-api";
import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import {
type CompositionEvent,
type KeyboardEvent,
type MutableRefObject,
type ReactElement,
@ -52,6 +53,10 @@ export interface CompareHandle {
const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
const MAX_IMAGE_SIZE = 20 * 1024 * 1024;
function isNativeComposing(event: Event) {
return "isComposing" in event && (event as InputEvent).isComposing === true;
}
function fileToBase64DataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@ -238,7 +243,9 @@ export function SharedComposer({
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [pendingAudio, setPendingAudio] = useState<{ name: string; base64: string } | null>(null);
const [dragging, setDragging] = useState(false);
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const composingRef = useRef(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const audioInputRef = useRef<HTMLInputElement>(null);
@ -323,7 +330,13 @@ export function SharedComposer({
setPendingImages((prev) => prev.filter((p) => p.id !== id));
}, []);
function setCompositionState(next: boolean) {
composingRef.current = next;
setIsComposing(next);
}
async function send() {
if (composingRef.current) return;
const msg = text.trim();
if (!msg && pendingImages.length === 0 && !pendingAudio) return;
@ -482,6 +495,9 @@ export function SharedComposer({
const busy = running || comparing;
function onKeyDown(e: KeyboardEvent) {
// IME composition (Japanese/Chinese/Korean): Enter commits the candidate.
// Don't hijack it. See issue #5318.
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!busy) {
@ -490,7 +506,7 @@ export function SharedComposer({
}
}
const canSend = (text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null) && !busy;
const canSend = (text.trim().length > 0 || pendingImages.length > 0 || pendingAudio !== null) && !busy && !isComposing;
return (
<div
@ -538,7 +554,23 @@ export function SharedComposer({
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onChange={(e) => {
// ALWAYS mirror the DOM value into React state, even during IME
// composition. The controlled `value` prop must match the DOM at
// all times, otherwise any unrelated parent re-render reconciles
// the textarea back to the stored value mid-composition — wiping
// the IME preedit AND prior committed text (e.g. Tab cycling
// candidates erases earlier words). Issue #5318.
setCompositionState(isNativeComposing(e.nativeEvent));
setText(e.target.value);
}}
onCompositionStart={() => {
setCompositionState(true);
}}
onCompositionEnd={(e: CompositionEvent<HTMLTextAreaElement>) => {
setCompositionState(false);
setText(e.currentTarget.value);
}}
onKeyDown={onKeyDown}
placeholder="Send to both models..."
className="composer-input"