@@ -122,47 +122,84 @@ class ChatInputWidget(anywidget.AnyWidget):
122122 input.value = model.get("value") || "";
123123 input.classList.add("mljar-chatinput-input");
124124
125+ const measureInput = document.createElement("textarea");
126+ measureInput.rows = 1;
127+ measureInput.classList.add("mljar-chatinput-input", "mljar-chatinput-measure");
128+ measureInput.setAttribute("aria-hidden", "true");
129+ measureInput.tabIndex = -1;
130+
125131 const btn = document.createElement("button");
126132 btn.type = "button";
127133 btn.classList.add("mljar-chatinput-button");
128- btn.textContent = model.get("button_icon") || " ➤ " ;
134+ btn.innerHTML = `<span class="mljar-chatinput-send-icon" aria-hidden="true">${ model.get("button_icon") || "➤"}</span>` ;
129135 btn.setAttribute("aria-label", "Send message");
130136
131137 container.appendChild(input);
132138 container.appendChild(btn);
139+ container.appendChild(measureInput);
133140 el.appendChild(container);
134141
135142 let lastModelValue = model.get("value") ?? "";
136143 let isGenerating = !!window.__mercuryExecutionRunning;
137144 let lastHeight = 0;
138145 let resizeNotifyFrame = null;
146+ let resizeNotifyTimeout = null;
147+ let resizeSeq = 0;
148+ const resizeSource = `chatinput-${Math.random().toString(36).slice(2)}`;
149+ let suppressNextValueSync = false;
139150
140151 const notifyResize = () => {
141- if (resizeNotifyFrame !== null) return;
142- resizeNotifyFrame = requestAnimationFrame(() => {
143- resizeNotifyFrame = null;
144- window.dispatchEvent(new CustomEvent("mercury:bottom-resize-requested"));
145- });
152+ if (resizeNotifyTimeout !== null) {
153+ window.clearTimeout(resizeNotifyTimeout);
154+ }
155+ resizeNotifyTimeout = window.setTimeout(() => {
156+ resizeNotifyTimeout = null;
157+ if (resizeNotifyFrame !== null) return;
158+ const seq = ++resizeSeq;
159+ resizeNotifyFrame = requestAnimationFrame(() => {
160+ resizeNotifyFrame = null;
161+ const detail = {
162+ height: container.getBoundingClientRect().height,
163+ source: resizeSource,
164+ seq
165+ };
166+ window.dispatchEvent(new CustomEvent("mercury:bottom-resize-requested", { detail }));
167+ });
168+ }, 90);
146169 };
147170
148- const resizeInput = () => {
149- input.style.height = "auto";
150- const nextHeight = input.scrollHeight;
171+ const resizeInput = (notify = true) => {
172+ const styles = window.getComputedStyle(input);
173+ const maxHeight = Math.min(160, Math.max(80, window.innerHeight * 0.3));
174+ const borderY =
175+ parseFloat(styles.borderTopWidth || "0") +
176+ parseFloat(styles.borderBottomWidth || "0");
177+ const minHeight = parseFloat(styles.minHeight || "0") || 0;
178+ measureInput.style.width = `${input.getBoundingClientRect().width}px`;
179+ measureInput.value = input.value || input.placeholder || "";
180+ const nextHeight = Math.max(
181+ minHeight,
182+ Math.min(measureInput.scrollHeight + borderY, maxHeight)
183+ );
151184 input.style.height = `${nextHeight}px`;
185+ input.style.overflowY =
186+ measureInput.scrollHeight + borderY > maxHeight + 1 ? "auto" : "hidden";
152187 if (nextHeight !== lastHeight) {
153188 lastHeight = nextHeight;
154- notifyResize();
189+ if (notify) {
190+ notifyResize();
191+ }
155192 }
156193 };
157194
158195 const renderButtonState = () => {
159196 if (isGenerating) {
160- btn.textContent = "Stop" ;
161- btn.classList.add ("mljar-chatinput-button-stop");
197+ btn.innerHTML = '<span class="mljar-chatinput-stop-icon" aria-hidden="true"></span>' ;
198+ btn.classList.remove ("mljar-chatinput-button-stop");
162199 btn.setAttribute("aria-label", "Stop response generation");
163200 btn.title = "Stop response generation";
164201 } else {
165- btn.textContent = model.get("button_icon") || " ➤ " ;
202+ btn.innerHTML = `<span class="mljar-chatinput-send-icon" aria-hidden="true">${ model.get("button_icon") || "➤"}</span>` ;
166203 btn.classList.remove("mljar-chatinput-button-stop");
167204 btn.setAttribute("aria-label", "Send message");
168205 btn.title = "";
@@ -188,6 +225,11 @@ class ChatInputWidget(anywidget.AnyWidget):
188225 model.on("change:value", () => {
189226 const newVal = model.get("value") ?? "";
190227
228+ if (suppressNextValueSync && newVal === lastModelValue) {
229+ suppressNextValueSync = false;
230+ return;
231+ }
232+
191233 // Only update the visible input if the user hasn't typed since
192234 // the last time we applied a model value.
193235 const userHasTyped = input.value !== lastModelValue;
@@ -212,30 +254,52 @@ class ChatInputWidget(anywidget.AnyWidget):
212254 // After submission we clear the input, but the model value becomes msg.
213255 // Track it so subsequent model changes don't clobber a new draft.
214256 lastModelValue = msg;
257+ suppressNextValueSync = true;
215258 input.value = "";
216- resizeInput();
259+ resizeInput(true );
217260 model.save_changes();
218261 };
219262
263+ const insertNewlineAtCursor = () => {
264+ const start = input.selectionStart ?? input.value.length;
265+ const end = input.selectionEnd ?? input.value.length;
266+ const before = input.value.slice(0, start);
267+ const after = input.value.slice(end);
268+ input.value = `${before}\n ${after}`;
269+ const cursor = start + 1;
270+ input.selectionStart = cursor;
271+ input.selectionEnd = cursor;
272+ resizeInput(false);
273+ };
274+
220275 btn.addEventListener("click", sendMessage);
221276
222277 model.on("change:button_icon", renderButtonState);
223278
224- input.addEventListener("input", resizeInput);
279+ input.addEventListener("input", () => resizeInput(false));
280+ input.addEventListener("blur", () => resizeInput(true));
225281
226282 input.addEventListener("keydown", (ev) => {
227283 if (isGenerating) return;
228284 const sendOnEnter = !!model.get("send_on_enter");
229285 if (!sendOnEnter) return;
286+ if (ev.key === "Enter" && ev.shiftKey) {
287+ ev.preventDefault();
288+ insertNewlineAtCursor();
289+ return;
290+ }
230291 if (ev.key === "Enter" && !ev.shiftKey) {
231292 ev.preventDefault();
232293 sendMessage();
233294 }
234295 });
235296
236- resizeInput();
297+ resizeInput(true );
237298
238299 return () => {
300+ if (resizeNotifyTimeout !== null) {
301+ window.clearTimeout(resizeNotifyTimeout);
302+ }
239303 if (resizeNotifyFrame !== null) {
240304 cancelAnimationFrame(resizeNotifyFrame);
241305 }
@@ -248,13 +312,11 @@ class ChatInputWidget(anywidget.AnyWidget):
248312
249313 _css = f"""
250314 .mljar-chatinput-container {{
251- display: flex;
252- flex-direction: row;
253- align-items: center;
315+ position: relative;
316+ display: block;
254317 width: 100%;
255318 min-width: 160px;
256319 box-sizing: border-box;
257- gap: 8px;
258320 font-family: { THEME .get ('font_family' , 'Arial, sans-serif' )} ;
259321 font-size: { THEME .get ('font_size' , '14px' )} ;
260322 color: { THEME .get ('text_color' , '#222' )} ;
@@ -263,19 +325,18 @@ class ChatInputWidget(anywidget.AnyWidget):
263325 }}
264326
265327 .mljar-chatinput-input {{
266- flex: 1 1 auto ;
328+ display: block ;
267329 width: 100%;
268330 resize: none;
269331 border: { '1px solid ' + THEME .get ('border_color' , '#ccc' ) if THEME .get ('border_visible' , True ) else 'none' } ;
270332 border-radius: { THEME .get ('border_radius' , '6px' )} ;
271- padding: 6px 10px;
272- min-height: 1.6em;
333+ min-height: calc(1.4em + 24px);
273334 max-height: min(160px, 30vh);
274- overflow-y: auto ;
335+ overflow-y: hidden ;
275336 background: { THEME .get ('widget_background_color' , '#fff' )} ;
276337 color: { THEME .get ('text_color' , '#222' )} ;
277338 box-sizing: border-box;
278- padding: 10px;
339+ padding: 10px 72px 10px 12px ;
279340 font-size: 0.9rem;
280341 line-height: 1.4;
281342 }}
@@ -285,26 +346,65 @@ class ChatInputWidget(anywidget.AnyWidget):
285346 border-color: { THEME .get ('primary_color' , '#007bff' )} ;
286347 }}
287348
349+ .mljar-chatinput-measure {{
350+ position: absolute;
351+ left: -9999px;
352+ top: 0;
353+ visibility: hidden;
354+ pointer-events: none;
355+ height: auto;
356+ min-height: 0;
357+ max-height: none;
358+ overflow-y: hidden;
359+ z-index: -1;
360+ }}
361+
288362 .mljar-chatinput-button {{
289- flex: 0 0 auto;
363+ position: absolute;
364+ right: 0;
365+ top: 8px;
366+ bottom: 8px;
367+ display: inline-flex;
368+ align-items: center;
369+ justify-content: center;
290370 border: none;
291- border-radius: { THEME .get ('border_radius' , '6px' )} !important;
292- min-height: 1.6em ;
371+ border-radius: 0 { THEME .get ('border_radius' , '6px' )} { THEME . get ( 'border_radius' , '6px' ) } 0 !important;
372+ width: 60px ;
293373 cursor: pointer;
294374 background: { THEME .get ('primary_color' , '#007bff' )} ;
295375 color: { THEME .get ('button_text_color' , '#fff' )} ;
296376 font-weight: bold;
297- padding: 11px;
298- padding-left: 18px;
299- padding-right: 18px;
377+ padding: 0;
378+ line-height: 1;
379+ box-shadow: 0 2px 8px rgb(0 0 0 / 12%);
380+ transition: filter 120ms ease, transform 120ms ease, box-shadow 120ms ease;
300381 }}
301382
302383 .mljar-chatinput-button:hover {{
303384 filter: brightness(0.95);
385+ box-shadow: 0 3px 10px rgb(0 0 0 / 16%);
386+ }}
387+
388+ .mljar-chatinput-button:active {{
389+ transform: translateY(1px);
390+ box-shadow: 0 1px 4px rgb(0 0 0 / 14%);
391+ }}
392+
393+ .mljar-chatinput-send-icon {{
394+ display: inline-flex;
395+ align-items: center;
396+ justify-content: center;
397+ transform: translateX(1px);
398+ font-size: 16px;
399+ line-height: 1;
304400 }}
305401
306- .mljar-chatinput-button-stop {{
307- background: #dc2626;
402+ .mljar-chatinput-stop-icon {{
403+ display: inline-block;
404+ width: 10px;
405+ height: 10px;
406+ background: currentColor;
407+ border-radius: 2px;
308408 }}
309409 """
310410
0 commit comments