mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 16:02:23 +00:00
* feat: refine ask-user-question chat display and keyboard nav Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style: use text-accent for ask-user-question icon Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: focus active choice when clicking ask-user-question card Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: disable chat input while an ask-user-question is pending Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: focus active choice on card click instead of pointerdown Preserves text selection on the question card; wired as a use: action so the non-interactive card needs no keyboard handler. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: extract isActiveUserQuestion shared predicate Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: cover isActiveUserQuestion predicate Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
194 lines
5.9 KiB
Svelte
194 lines
5.9 KiB
Svelte
<script lang="ts">
|
|
import { onMount, tick } from 'svelte'
|
|
import { CircleHelp, ArrowUp } from 'lucide-svelte'
|
|
import Button from '$lib/components/common/button/Button.svelte'
|
|
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
|
import { getAiChatManager } from './aiChatManagerContext'
|
|
import type { UserQuestionDisplay } from './shared'
|
|
|
|
// Sessions inject a per-pane `AIChatManager` via context; outside of
|
|
// sessions getAiChatManager falls back to the global singleton. Without
|
|
// this, answers clicked inside a session would dispatch to the singleton's
|
|
// pending callbacks map (which doesn't have the session manager's question
|
|
// callback), and the AI loop would stall.
|
|
const aiChatManager = getAiChatManager()
|
|
|
|
interface Props {
|
|
toolCallId: string
|
|
userQuestion: UserQuestionDisplay
|
|
}
|
|
|
|
let { toolCallId, userQuestion }: Props = $props()
|
|
|
|
let choiceButtons = $state<(HTMLButtonElement | undefined)[]>([])
|
|
let customAnswerInput = $state<TextInput | undefined>()
|
|
let customAnswer = $state('')
|
|
let canSubmitCustomAnswer = $derived(customAnswer.trim().length > 0)
|
|
|
|
// The custom-answer input is the last stop in the roving cursor, after all
|
|
// choices, so arrow navigation can reach it from the keyboard.
|
|
const customAnswerIndex = $derived(userQuestion.choices.length)
|
|
const itemCount = $derived(userQuestion.choices.length + 1)
|
|
// The item currently under the keyboard cursor. Mirrored onto the matching
|
|
// choice's `selected` prop so the highlight comes from the Button itself.
|
|
let activeIndex = $state(0)
|
|
|
|
onMount(() => {
|
|
void tick().then(() => {
|
|
focusIndex(0)
|
|
})
|
|
})
|
|
|
|
function focusIndex(index: number) {
|
|
activeIndex = index
|
|
if (index === customAnswerIndex) {
|
|
customAnswerInput?.focus()
|
|
} else {
|
|
choiceButtons[index]?.focus()
|
|
}
|
|
}
|
|
|
|
function selectChoice(choice: string) {
|
|
aiChatManager.handleUserQuestionAnswer(toolCallId, choice)
|
|
}
|
|
|
|
function submitCustomAnswer() {
|
|
const answer = customAnswer.trim()
|
|
if (!answer) {
|
|
return
|
|
}
|
|
|
|
aiChatManager.handleUserQuestionAnswer(toolCallId, answer)
|
|
}
|
|
|
|
function handleChoiceKeydown(event: KeyboardEvent, choice: string, index: number) {
|
|
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
focusIndex((index + 1) % itemCount)
|
|
return
|
|
}
|
|
|
|
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
focusIndex((index - 1 + itemCount) % itemCount)
|
|
return
|
|
}
|
|
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
selectChoice(choice)
|
|
}
|
|
}
|
|
|
|
// Clicking the card's empty area must pull focus to the active item; otherwise
|
|
// focus stays on the chat's scroll container and arrow keys scroll the
|
|
// conversation instead of moving the selection. Wired as an action rather than
|
|
// a declarative on:click so the non-interactive card needs no keyboard handler,
|
|
// and on `click` rather than `pointerdown` so click-dragging to select text
|
|
// still works.
|
|
function focusActiveOnBackgroundClick(node: HTMLElement) {
|
|
function onClick(event: MouseEvent) {
|
|
const interactive = (event.target as HTMLElement | null)?.closest(
|
|
'button, input, textarea, a, [contenteditable]'
|
|
)
|
|
// A control inside the card (a choice, the answer input, the send button)
|
|
// manages its own focus — leave it alone. `closest` can also match the
|
|
// message-row button the chat wraps every message in, which is an ancestor
|
|
// of the card, so only bail out when the match is actually inside the card.
|
|
if (interactive && node.contains(interactive)) {
|
|
return
|
|
}
|
|
event.stopPropagation()
|
|
focusIndex(activeIndex)
|
|
}
|
|
node.addEventListener('click', onClick)
|
|
return {
|
|
destroy() {
|
|
node.removeEventListener('click', onClick)
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleCustomAnswerKeydown(event: KeyboardEvent) {
|
|
// Only ArrowUp/ArrowDown roam out of the input — Left/Right stay free for
|
|
// moving the text caret within the answer.
|
|
if (event.key === 'ArrowUp') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
focusIndex((customAnswerIndex - 1 + itemCount) % itemCount)
|
|
return
|
|
}
|
|
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
focusIndex((customAnswerIndex + 1) % itemCount)
|
|
return
|
|
}
|
|
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
submitCustomAnswer()
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="rounded-md border border-border-light bg-surface p-3"
|
|
data-chat-keyboard-scope="ask-user-question"
|
|
use:focusActiveOnBackgroundClick
|
|
>
|
|
<div class="flex items-start gap-2">
|
|
<CircleHelp class="mt-0.5 h-4 w-4 shrink-0 text-accent" />
|
|
<p class="min-w-0 flex-1 whitespace-pre-wrap text-xs font-semibold text-emphasis"
|
|
>{userQuestion.question}</p
|
|
>
|
|
</div>
|
|
|
|
<div class="mt-3 flex flex-col gap-2">
|
|
{#each userQuestion.choices as choice, index (index)}
|
|
<Button
|
|
variant="default"
|
|
unifiedSize="sm"
|
|
selected={activeIndex === index}
|
|
bind:element={choiceButtons[index]}
|
|
onClick={() => selectChoice(choice)}
|
|
onkeydown={(event) => handleChoiceKeydown(event, choice, index)}
|
|
onfocus={() => (activeIndex = index)}
|
|
btnClasses="h-auto min-h-7 justify-start whitespace-normal py-1.5 text-left focus-visible:!ring-0 focus-visible:!outline-none"
|
|
>
|
|
<span class="break-words">{choice}</span>
|
|
</Button>
|
|
{/each}
|
|
|
|
<div class="flex min-w-0 gap-2 pt-1">
|
|
<TextInput
|
|
bind:this={customAnswerInput}
|
|
bind:value={customAnswer}
|
|
class="min-w-0 flex-1"
|
|
size="sm"
|
|
inputProps={{
|
|
'aria-label': 'Custom answer',
|
|
placeholder: 'Custom answer',
|
|
onkeydown: handleCustomAnswerKeydown,
|
|
onfocus: () => (activeIndex = customAnswerIndex)
|
|
}}
|
|
/>
|
|
<Button
|
|
variant="subtle"
|
|
unifiedSize="sm"
|
|
iconOnly
|
|
title="Send"
|
|
startIcon={{ icon: ArrowUp }}
|
|
disabled={!canSubmitCustomAnswer}
|
|
onClick={submitCustomAnswer}
|
|
btnClasses="shrink-0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|