Skip to content

Commit 8477190

Browse files
authored
feat: chat.update_user_input() can submit and focus the input (#1851)
1 parent 3edb090 commit 8477190

File tree

9 files changed

+264
-37
lines changed

9 files changed

+264
-37
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
different ways (see #1845 for more details):
1616
* By adding a `.suggestion` CSS class to an HTML element (e.g., `<span class="suggestion">A suggestion</span>`)
1717
* Add a `data-suggestion` attribute to an HTML element, and set the value to the input suggestion text (e.g., `<span data-suggestion="Suggestion value">Suggestion link</span>`)
18+
* To auto-submit the suggestion when clicked by the user, include the `.submit` class or the `data-suggestion-submit="true"` attribute on the HTML element. Alternatively, use Cmd/Ctrl + click to auto-submit any suggestion or Alt/Opt + click to apply any suggestion to the chat input without submitting.
1819

1920
* Added a new `.add_sass_layer_file()` method to `ui.Theme` that supports reading a Sass file with layer boundary comments, e.g. `/*-- scss:defaults --*/`. This format [is supported by Quarto](https://quarto.org/docs/output-formats/html-themes-more.html#bootstrap-bootswatch-layering) and makes it easier to store Sass rules and declarations that need to be woven into Shiny's Sass Bootstrap files. (#1790)
2021

@@ -26,6 +27,8 @@ different ways (see #1845 for more details):
2627

2728
* Available `input` ids can now be listed via `dir(input)`. This also works on the new `session.clientdata` object. (#1832)
2829

30+
* The `ui.Chat()` component's `.update_user_input()` method gains `submit` and `focus` options that allow you to submit the input on behalf of the user and to choose whether the input receives focus after the update. (#1851)
31+
2932
### Bug fixes
3033

3134
* `ui.Chat()` now correctly handles new `ollama.chat()` return value introduced in `ollama` v0.4. (#1787)

js/chat/chat.ts

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type ShinyChatMessage = {
2626
type UpdateUserInput = {
2727
value?: string;
2828
placeholder?: string;
29+
submit?: false;
30+
focus?: false;
2931
};
3032

3133
// https://github.com/microsoft/TypeScript/issues/28357#issuecomment-748550734
@@ -113,6 +115,11 @@ class ChatMessages extends LightElement {
113115
}
114116
}
115117

118+
interface ChatInputSetInputOptions {
119+
submit?: boolean;
120+
focus?: boolean;
121+
}
122+
116123
class ChatInput extends LightElement {
117124
private _disabled = false;
118125

@@ -208,7 +215,7 @@ class ChatInput extends LightElement {
208215
this.#onInput();
209216
}
210217

211-
#sendInput(): void {
218+
#sendInput(focus = true): void {
212219
if (this.valueIsEmpty) return;
213220
if (this.disabled) return;
214221

@@ -225,18 +232,29 @@ class ChatInput extends LightElement {
225232
this.setInputValue("");
226233
this.disabled = true;
227234

228-
this.textarea.focus();
235+
if (focus) this.textarea.focus();
229236
}
230237

231-
setInputValue(value: string, submit = false): void {
238+
setInputValue(
239+
value: string,
240+
{ submit = false, focus = false }: ChatInputSetInputOptions = {}
241+
): void {
242+
// Store previous value to restore post-submit (if submitting)
243+
const oldValue = this.textarea.value;
244+
232245
this.textarea.value = value;
233246

234247
// Simulate an input event (to trigger the textarea autoresize)
235248
const inputEvent = new Event("input", { bubbles: true, cancelable: true });
236249
this.textarea.dispatchEvent(inputEvent);
237250

238251
if (submit) {
239-
this.#sendInput();
252+
this.#sendInput(false);
253+
if (oldValue) this.setInputValue(oldValue);
254+
}
255+
256+
if (focus) {
257+
this.textarea.focus();
240258
}
241259
}
242260
}
@@ -383,31 +401,40 @@ class ChatContainer extends LightElement {
383401
}
384402

385403
#onUpdateUserInput(event: CustomEvent<UpdateUserInput>): void {
386-
const { value, placeholder } = event.detail;
404+
const { value, placeholder, submit, focus } = event.detail;
387405
if (value !== undefined) {
388-
this.input.setInputValue(value);
406+
this.input.setInputValue(value, { submit, focus });
389407
}
390408
if (placeholder !== undefined) {
391409
this.input.placeholder = placeholder;
392410
}
393411
}
394412

395-
#onInputSuggestionClick(e: Event): void {
396-
const { suggestion, submit } = this.#getSuggestion(e.target);
397-
if (!suggestion) return;
398-
399-
e.preventDefault();
400-
this.input.setInputValue(suggestion, submit);
413+
#onInputSuggestionClick(e: MouseEvent): void {
414+
this.#onInputSuggestionEvent(e);
401415
}
402416

403417
#onInputSuggestionKeydown(e: KeyboardEvent): void {
404-
const isEnter = e.key === "Enter" || e.key === " ";
405-
if (!isEnter) return;
418+
const isEnterOrSpace = e.key === "Enter" || e.key === " ";
419+
if (!isEnterOrSpace) return;
420+
421+
this.#onInputSuggestionEvent(e);
422+
}
423+
424+
#onInputSuggestionEvent(e: MouseEvent | KeyboardEvent): void {
406425
const { suggestion, submit } = this.#getSuggestion(e.target);
407426
if (!suggestion) return;
408427

409428
e.preventDefault();
410-
this.input.setInputValue(suggestion, submit);
429+
// Cmd/Ctrl + (event) = force submitting
430+
// Alt/Opt + (event) = force setting without submitting
431+
const shouldSubmit =
432+
e.metaKey || e.ctrlKey ? true : e.altKey ? false : submit;
433+
434+
this.input.setInputValue(suggestion, {
435+
submit: shouldSubmit,
436+
focus: !shouldSubmit,
437+
});
411438
}
412439

413440
#getSuggestion(x: EventTarget | null): {
@@ -416,17 +443,22 @@ class ChatContainer extends LightElement {
416443
} {
417444
if (!(x instanceof HTMLElement)) return {};
418445

446+
const el = x.closest(".suggestion, [data-suggestion]");
447+
if (!(el instanceof HTMLElement)) return {};
448+
419449
const isSuggestion =
420-
x.classList.contains("suggestion") || x.dataset.suggestion !== undefined;
450+
el.classList.contains("suggestion") ||
451+
el.dataset.suggestion !== undefined;
421452
if (!isSuggestion) return {};
422453

423-
const suggestion = x.dataset.suggestion || x.textContent;
454+
const suggestion = el.dataset.suggestion || el.textContent;
424455

425456
return {
426457
suggestion: suggestion || undefined,
427458
submit:
428-
x.classList.contains("submit") ||
429-
["", "true"].includes(x.dataset.suggestionSubmit || "false"),
459+
el.classList.contains("submit") ||
460+
el.dataset.suggestionSubmit === "" ||
461+
el.dataset.suggestionSubmit === "true",
430462
};
431463
}
432464

shiny/ui/_chat.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -761,7 +761,6 @@ async def _transform_message(
761761
chunk: ChunkOption = False,
762762
chunk_content: str | None = None,
763763
) -> TransformedMessage | None:
764-
765764
res = as_transformed_message(message)
766765
key = res["transform_key"]
767766

@@ -791,7 +790,6 @@ def _store_message(
791790
chunk: ChunkOption = False,
792791
index: int | None = None,
793792
) -> None:
794-
795793
# Don't actually store chunks until the end
796794
if chunk is True or chunk == "start":
797795
return None
@@ -817,7 +815,6 @@ def _trim_messages(
817815
token_limits: tuple[int, int],
818816
format: MISSING_TYPE | ProviderMessageFormat,
819817
) -> tuple[TransformedMessage, ...]:
820-
821818
n_total, n_reserve = token_limits
822819
if n_total <= n_reserve:
823820
raise ValueError(
@@ -878,7 +875,6 @@ def _trim_anthropic_messages(
878875
self,
879876
messages: tuple[TransformedMessage, ...],
880877
) -> tuple[TransformedMessage, ...]:
881-
882878
if any(m["role"] == "system" for m in messages):
883879
raise ValueError(
884880
"Anthropic requires a system prompt to be specified in it's `.create()` method "
@@ -938,7 +934,12 @@ def _user_input(self) -> str:
938934
return cast(str, self._session.input[id]())
939935

940936
def update_user_input(
941-
self, *, value: str | None = None, placeholder: str | None = None
937+
self,
938+
*,
939+
value: str | None = None,
940+
placeholder: str | None = None,
941+
submit: bool = False,
942+
focus: bool = False,
942943
):
943944
"""
944945
Update the user input.
@@ -949,9 +950,25 @@ def update_user_input(
949950
The value to set the user input to.
950951
placeholder
951952
The placeholder text for the user input.
953+
submit
954+
Whether to automatically submit the text for the user. Requires `value`.
955+
focus
956+
Whether to move focus to the input element. Requires `value`.
952957
"""
953958

954-
obj = _utils.drop_none({"value": value, "placeholder": placeholder})
959+
if value is None and (submit or focus):
960+
raise ValueError(
961+
"An input `value` must be provided when `submit` or `focus` are `True`."
962+
)
963+
964+
obj = _utils.drop_none(
965+
{
966+
"value": value,
967+
"placeholder": placeholder,
968+
"submit": submit,
969+
"focus": focus,
970+
}
971+
)
955972

956973
_utils.run_coro_sync(
957974
self._session.send_custom_message(

0 commit comments

Comments
 (0)