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
1 change: 1 addition & 0 deletions studio/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions studio/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
94 changes: 88 additions & 6 deletions studio/frontend/src/components/assistant-ui/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
useAuiEvent,
useAuiState,
} from "@assistant-ui/react";
import { flushResourcesSync } from "@assistant-ui/tap";
import {
ArrowDownIcon,
ArrowUpIcon,
Expand All @@ -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,
Expand Down Expand Up @@ -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 = (
Expand All @@ -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} />
</>
);

Expand All @@ -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);
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -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"
>
Expand Down Expand Up @@ -903,6 +978,7 @@ const UserActionBar: FC = () => {

const EditComposer: FC = () => {
const aui = useAui();
const { inputProps, isComposingRef } = useImeComposerInputHandlers();
const resendAfterCancelRef = useRef(false);

useAuiEvent("thread.runEnd", () => {
Expand All @@ -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();

Expand Down
36 changes: 34 additions & 2 deletions studio/frontend/src/features/chat/shared-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Comment thread
Etherll marked this conversation as resolved.
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!busy) {
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down
Loading