From d09ab78fcec93a4c4bacc75ce2692a7c62804de7 Mon Sep 17 00:00:00 2001 From: Classic298 <27028174+Classic298@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:49:49 +0200 Subject: [PATCH] fix: typing with an input method breaks in the empty chat input while a follow-up suggestion is shown (#31393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a reply, the first suggested follow-up question sits as grey ghost text in the empty chat input. Typing into it with an input method (Chinese, Japanese or Korean) broke the word being composed: on iOS the input lost focus after the first character, and in Chromium the first letter was left behind, so typing "ni" and picking "你" gave "n你". Clearing the ghost text rebuilt the input's line of text while the keyboard was still composing the word. The ghost text is now drawn over the empty input without being written into it, so input methods work again, and Tab to accept it and the follow-up buttons under the reply behave as before. Fixes #31372 --- .../components/common/RichTextInput.svelte | 20 +------- .../common/RichTextInput/AutoCompletion.js | 46 ++++++++++++++++++- 2 files changed, 47 insertions(+), 19 deletions(-) diff --git a/src/lib/components/common/RichTextInput.svelte b/src/lib/components/common/RichTextInput.svelte index ce298ef837..b854e342dd 100644 --- a/src/lib/components/common/RichTextInput.svelte +++ b/src/lib/components/common/RichTextInput.svelte @@ -143,7 +143,7 @@ import { Decoration, DecorationSet } from 'prosemirror-view'; import { Editor, Extension, markInputRule, mergeAttributes } from '@tiptap/core'; - import { AIAutocompletion } from './RichTextInput/AutoCompletion.js'; + import { AIAutocompletion, setFollowUpSuggestion } from './RichTextInput/AutoCompletion.js'; import StarterKit from '@tiptap/starter-kit'; @@ -322,23 +322,7 @@ export let followUpSuggestion = ''; $: if (editor && !editor.isDestroyed) { - const { doc } = editor.state; - const node = doc.firstChild; - if (node?.type.name === 'paragraph' && !node.attrs['data-prompt']) { - const suggestion = doc.childCount === 1 && node.content.size === 0 ? followUpSuggestion : ''; - if ((node.attrs['data-suggestion'] ?? '') !== suggestion) { - editor.view.dispatch( - editor.state.tr - .setNodeMarkup(0, null, { - ...node.attrs, - class: suggestion ? 'ai-autocompletion' : null, - 'data-prompt': suggestion ? '' : null, - 'data-suggestion': suggestion || null - }) - .setMeta('addToHistory', false) - ); - } - } + setFollowUpSuggestion(editor.view, followUpSuggestion); } export let messageInput = false; diff --git a/src/lib/components/common/RichTextInput/AutoCompletion.js b/src/lib/components/common/RichTextInput/AutoCompletion.js index 89cff891ea..025ee554cc 100644 --- a/src/lib/components/common/RichTextInput/AutoCompletion.js +++ b/src/lib/components/common/RichTextInput/AutoCompletion.js @@ -13,6 +13,31 @@ Intelligently reset suggestions on new input. import { Extension } from '@tiptap/core'; import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; + +const aiAutocompletionKey = new PluginKey('aiAutocompletion'); + +const getFollowUpSuggestion = (state) => { + const { doc } = state; + const isEmpty = + doc.childCount === 1 && + doc.firstChild.type.name === 'paragraph' && + doc.firstChild.content.size === 0; + return isEmpty ? aiAutocompletionKey.getState(state) : ''; +}; + +export const setFollowUpSuggestion = (view, suggestion) => { + const current = aiAutocompletionKey.getState(view.state); + if (current === undefined || current === suggestion) return; + view.dispatch(view.state.tr.setMeta(aiAutocompletionKey, suggestion)); +}; + +const acceptFollowUpSuggestion = (view) => { + const suggestion = getFollowUpSuggestion(view.state); + if (!suggestion) return false; + view.dispatch(view.state.tr.insertText(suggestion)); + return true; +}; export const AIAutocompletion = Extension.create({ name: 'aiAutocompletion', @@ -131,8 +156,23 @@ export const AIAutocompletion = Extension.create({ return [ new Plugin({ - key: new PluginKey('aiAutocompletion'), + key: aiAutocompletionKey, + state: { + init: () => '', + apply: (tr, suggestion) => tr.getMeta(aiAutocompletionKey) ?? suggestion + }, props: { + // A decoration is patched in place, so removing it cannot break an IME composition + decorations: (state) => { + const suggestion = getFollowUpSuggestion(state); + if (!suggestion) return null; + return DecorationSet.create(state.doc, [ + Decoration.node(0, state.doc.firstChild.nodeSize, { + class: 'ai-autocompletion', + 'data-suggestion': suggestion + }) + ]); + }, handleKeyDown: (view, event) => { if (isComposing || event.isComposing || (event.key === 'Tab' && event.shiftKey)) return false; @@ -145,6 +185,8 @@ export const AIAutocompletion = Extension.create({ const node = $head.parent; if (event.key === 'Tab') { + if (acceptFollowUpSuggestion(view)) return true; + // if (!node.attrs['data-suggestion']) { // // Generate completion // if (loading) return true @@ -219,6 +261,8 @@ export const AIAutocompletion = Extension.create({ // Check if the swipe was primarily horizontal and to the right if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX > 50) { + if (acceptFollowUpSuggestion(view)) return true; + const { state, dispatch } = view; const { selection } = state; const { $head } = selection;