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": "0.12.28",
"@assistant-ui/react-markdown": "0.12.11", "@assistant-ui/react-markdown": "0.12.11",
"@assistant-ui/react-streamdown": "0.1.11", "@assistant-ui/react-streamdown": "0.1.11",
"@assistant-ui/tap": "0.5.10",
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4", "@dagrejs/dagre": "^2.0.4",
"@dagrejs/graphlib": "^3.0.4", "@dagrejs/graphlib": "^3.0.4",

View file

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

View file

@ -51,6 +51,7 @@ import {
useAuiEvent, useAuiEvent,
useAuiState, useAuiState,
} from "@assistant-ui/react"; } from "@assistant-ui/react";
import { flushResourcesSync } from "@assistant-ui/tap";
import { import {
ArrowDownIcon, ArrowDownIcon,
ArrowUpIcon, ArrowUpIcon,
@ -72,6 +73,8 @@ import {
import { Copy01Icon, Delete02Icon, Edit03Icon, Tick02Icon } from "@hugeicons/core-free-icons"; import { Copy01Icon, Delete02Icon, Edit03Icon, Tick02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { import {
type ChangeEvent,
type CompositionEvent,
type FC, type FC,
type FormEvent, type FormEvent,
useCallback, useCallback,
@ -282,13 +285,15 @@ const PendingAudioChip: FC = () => {
}; };
const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => { const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
const { inputProps, isComposing, isComposingRef } = useImeComposerInputHandlers();
const handleSubmit = useCallback( const handleSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => { (event: FormEvent<HTMLFormElement>) => {
if (disabled) { if (disabled || isComposingRef.current) {
event.preventDefault(); event.preventDefault();
} }
}, },
[disabled], [disabled, isComposingRef],
); );
const composerContent = ( const composerContent = (
@ -304,8 +309,12 @@ const Composer: FC<{ disabled?: boolean }> = ({ disabled }) => {
autoFocus={!disabled} autoFocus={!disabled}
disabled={disabled} disabled={disabled}
aria-label="Message input" 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 ComposerAudioUpload: FC = () => {
const audioInputRef = useRef<HTMLInputElement>(null); const audioInputRef = useRef<HTMLInputElement>(null);
const setPendingAudio = useChatRuntimeStore((s) => s.setPendingAudio); 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 ( return (
<div className="aui-composer-action-wrapper composer-action-wrapper"> <div className="aui-composer-action-wrapper composer-action-wrapper">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -650,6 +720,11 @@ const ComposerAction: FC<{ disabled?: boolean }> = ({ disabled }) => {
variant="default" variant="default"
size="icon" size="icon"
disabled={disabled} disabled={disabled}
onClick={(event) => {
if (blockSend?.()) {
event.preventDefault();
}
}}
className="aui-composer-send size-8 rounded-full" className="aui-composer-send size-8 rounded-full"
aria-label="Send message" aria-label="Send message"
> >
@ -903,6 +978,7 @@ const UserActionBar: FC = () => {
const EditComposer: FC = () => { const EditComposer: FC = () => {
const aui = useAui(); const aui = useAui();
const { inputProps, isComposingRef } = useImeComposerInputHandlers();
const resendAfterCancelRef = useRef(false); const resendAfterCancelRef = useRef(false);
useAuiEvent("thread.runEnd", () => { useAuiEvent("thread.runEnd", () => {
@ -919,16 +995,22 @@ const EditComposer: FC = () => {
<ComposerPrimitive.Input <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" 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} autoFocus={true}
{...inputProps}
/> />
<div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end"> <div className="aui-edit-composer-footer mx-3 mb-3 flex items-center gap-2 self-end">
<ComposerPrimitive.Cancel asChild={true}> <ComposerPrimitive.Cancel asChild={true}>
<Button variant="ghost" size="sm"> <Button type="button" variant="ghost" size="sm">
Cancel Cancel
</Button> </Button>
</ComposerPrimitive.Cancel> </ComposerPrimitive.Cancel>
<Button <Button
type="button"
size="sm" size="sm"
onClick={() => { onClick={(event) => {
if (isComposingRef.current) {
event.preventDefault();
return;
}
const newText = aui.composer().getState().text; const newText = aui.composer().getState().text;
const originalText = aui.message().getCopyText(); const originalText = aui.message().getCopyText();

View file

@ -20,6 +20,7 @@ import { toast } from "sonner";
import { loadModel, validateModel } from "./api/chat-api"; import { loadModel, validateModel } from "./api/chat-api";
import { useChatRuntimeStore } from "./stores/chat-runtime-store"; import { useChatRuntimeStore } from "./stores/chat-runtime-store";
import { import {
type CompositionEvent,
type KeyboardEvent, type KeyboardEvent,
type MutableRefObject, type MutableRefObject,
type ReactElement, type ReactElement,
@ -52,6 +53,10 @@ export interface CompareHandle {
const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif"; const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
const MAX_IMAGE_SIZE = 20 * 1024 * 1024; 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> { function fileToBase64DataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@ -238,7 +243,9 @@ export function SharedComposer({
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]); const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
const [pendingAudio, setPendingAudio] = useState<{ name: string; base64: string } | null>(null); const [pendingAudio, setPendingAudio] = useState<{ name: string; base64: string } | null>(null);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const composingRef = useRef(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const audioInputRef = useRef<HTMLInputElement>(null); const audioInputRef = useRef<HTMLInputElement>(null);
@ -323,7 +330,13 @@ export function SharedComposer({
setPendingImages((prev) => prev.filter((p) => p.id !== id)); setPendingImages((prev) => prev.filter((p) => p.id !== id));
}, []); }, []);
function setCompositionState(next: boolean) {
composingRef.current = next;
setIsComposing(next);
}
async function send() { async function send() {
if (composingRef.current) return;
const msg = text.trim(); const msg = text.trim();
if (!msg && pendingImages.length === 0 && !pendingAudio) return; if (!msg && pendingImages.length === 0 && !pendingAudio) return;
@ -482,6 +495,9 @@ export function SharedComposer({
const busy = running || comparing; const busy = running || comparing;
function onKeyDown(e: KeyboardEvent) { 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) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
if (!busy) { 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 ( return (
<div <div
@ -538,7 +554,23 @@ export function SharedComposer({
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={text} 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} onKeyDown={onKeyDown}
placeholder="Send to both models..." placeholder="Send to both models..."
className="composer-input" className="composer-input"