Skip to content

Commit d9dd329

Browse files
committed
improvements in chat input mutliline (#506)
1 parent bb31e60 commit d9dd329

2 files changed

Lines changed: 263 additions & 37 deletions

File tree

mercury/chat/chatinput.py

Lines changed: 133 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)