Closes bd-3ny. Added mousedown listener that dismisses the dropdown when clicking outside both the dropdown and textarea. Uses early return to avoid registering listeners when dropdown is already closed.
251 lines
8.7 KiB
JavaScript
251 lines
8.7 KiB
JavaScript
import { html, useState, useRef, useCallback, useMemo, useEffect } from '../lib/preact.js';
|
|
import { getStatusMeta } from '../utils/status.js';
|
|
|
|
export function SimpleInput({ sessionId, status, onRespond, autocompleteConfig = null }) {
|
|
const [text, setText] = useState('');
|
|
const [focused, setFocused] = useState(false);
|
|
const [sending, setSending] = useState(false);
|
|
const [error, setError] = useState(null);
|
|
const [triggerInfo, setTriggerInfo] = useState(null);
|
|
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const textareaRef = useRef(null);
|
|
const autocompleteRef = useRef(null);
|
|
const meta = getStatusMeta(status);
|
|
|
|
// Detect if cursor is at a trigger position for autocomplete
|
|
const getTriggerInfo = useCallback((value, cursorPos) => {
|
|
// No config means no autocomplete
|
|
if (!autocompleteConfig) return null;
|
|
|
|
const { trigger } = autocompleteConfig;
|
|
|
|
// Find the start of the current "word" (after last whitespace before cursor)
|
|
let wordStart = cursorPos;
|
|
while (wordStart > 0 && !/\s/.test(value[wordStart - 1])) {
|
|
wordStart--;
|
|
}
|
|
|
|
// Check if word starts with this agent's trigger character
|
|
if (value[wordStart] === trigger) {
|
|
return {
|
|
trigger,
|
|
filterText: value.slice(wordStart + 1, cursorPos).toLowerCase(),
|
|
replaceStart: wordStart,
|
|
replaceEnd: cursorPos,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}, [autocompleteConfig]);
|
|
|
|
// Filter skills based on user input after trigger
|
|
const filteredSkills = useMemo(() => {
|
|
if (!autocompleteConfig || !triggerInfo) return [];
|
|
|
|
const { skills } = autocompleteConfig;
|
|
const { filterText } = triggerInfo;
|
|
|
|
let filtered = skills;
|
|
if (filterText) {
|
|
filtered = skills.filter(s =>
|
|
s.name.toLowerCase().includes(filterText)
|
|
);
|
|
}
|
|
|
|
// Server pre-sorts, but re-sort after filtering for stability
|
|
return filtered.sort((a, b) => a.name.localeCompare(b.name));
|
|
}, [autocompleteConfig, triggerInfo]);
|
|
|
|
// Show/hide autocomplete based on trigger detection
|
|
useEffect(() => {
|
|
const shouldShow = triggerInfo !== null;
|
|
setShowAutocomplete(shouldShow);
|
|
// Reset selection when dropdown opens
|
|
if (shouldShow) {
|
|
setSelectedIndex(0);
|
|
}
|
|
}, [triggerInfo]);
|
|
|
|
// Clamp selectedIndex when filtered list changes
|
|
useEffect(() => {
|
|
if (filteredSkills.length > 0 && selectedIndex >= filteredSkills.length) {
|
|
setSelectedIndex(filteredSkills.length - 1);
|
|
}
|
|
}, [filteredSkills.length, selectedIndex]);
|
|
|
|
// Click outside dismisses dropdown
|
|
useEffect(() => {
|
|
if (!showAutocomplete) return;
|
|
|
|
const handleClickOutside = (e) => {
|
|
if (autocompleteRef.current && !autocompleteRef.current.contains(e.target) &&
|
|
textareaRef.current && !textareaRef.current.contains(e.target)) {
|
|
setShowAutocomplete(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, [showAutocomplete]);
|
|
|
|
// Insert a selected skill into the text
|
|
const insertSkill = useCallback((skill) => {
|
|
if (!triggerInfo || !autocompleteConfig) return;
|
|
|
|
const { trigger } = autocompleteConfig;
|
|
const { replaceStart, replaceEnd } = triggerInfo;
|
|
|
|
const before = text.slice(0, replaceStart);
|
|
const after = text.slice(replaceEnd);
|
|
const inserted = `${trigger}${skill.name} `;
|
|
|
|
setText(before + inserted + after);
|
|
setShowAutocomplete(false);
|
|
setTriggerInfo(null);
|
|
|
|
// Move cursor after inserted text
|
|
const newCursorPos = replaceStart + inserted.length;
|
|
setTimeout(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.selectionStart = newCursorPos;
|
|
textareaRef.current.selectionEnd = newCursorPos;
|
|
textareaRef.current.focus();
|
|
}
|
|
}, 0);
|
|
}, [text, triggerInfo, autocompleteConfig]);
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (text.trim() && !sending) {
|
|
setSending(true);
|
|
setError(null);
|
|
try {
|
|
await onRespond(sessionId, text.trim(), true, 0);
|
|
setText('');
|
|
} catch (err) {
|
|
setError('Failed to send message');
|
|
console.error('SimpleInput send error:', err);
|
|
} finally {
|
|
setSending(false);
|
|
// Refocus the textarea after submission
|
|
// Use setTimeout to ensure React has re-rendered with disabled=false
|
|
setTimeout(() => {
|
|
textareaRef.current?.focus();
|
|
}, 0);
|
|
}
|
|
}
|
|
};
|
|
|
|
return html`
|
|
<form onSubmit=${handleSubmit} class="flex flex-col gap-2" onClick=${(e) => e.stopPropagation()}>
|
|
${error && html`
|
|
<div class="rounded-lg border border-attention/40 bg-attention/12 px-3 py-1.5 text-sm text-attention">
|
|
${error}
|
|
</div>
|
|
`}
|
|
<div class="flex items-end gap-2.5">
|
|
<div class="relative flex-1">
|
|
<textarea
|
|
ref=${textareaRef}
|
|
value=${text}
|
|
onInput=${(e) => {
|
|
const value = e.target.value;
|
|
const cursorPos = e.target.selectionStart;
|
|
setText(value);
|
|
setTriggerInfo(getTriggerInfo(value, cursorPos));
|
|
e.target.style.height = 'auto';
|
|
e.target.style.height = e.target.scrollHeight + 'px';
|
|
}}
|
|
onKeyDown=${(e) => {
|
|
if (showAutocomplete) {
|
|
// Escape dismisses dropdown
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
setShowAutocomplete(false);
|
|
return;
|
|
}
|
|
|
|
// Enter/Tab: select if matches exist, otherwise dismiss
|
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
e.preventDefault();
|
|
if (filteredSkills.length > 0 && filteredSkills[selectedIndex]) {
|
|
insertSkill(filteredSkills[selectedIndex]);
|
|
} else {
|
|
setShowAutocomplete(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Arrow navigation
|
|
if (filteredSkills.length > 0) {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setSelectedIndex(i => Math.min(i + 1, filteredSkills.length - 1));
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setSelectedIndex(i => Math.max(i - 1, 0));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Normal Enter-to-submit (only when dropdown is closed)
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSubmit(e);
|
|
}
|
|
}}
|
|
onFocus=${() => setFocused(true)}
|
|
onBlur=${() => setFocused(false)}
|
|
placeholder="Send a message..."
|
|
rows="1"
|
|
class="w-full resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
|
|
style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }}
|
|
disabled=${sending}
|
|
/>
|
|
${showAutocomplete && html`
|
|
<div
|
|
ref=${autocompleteRef}
|
|
class="absolute left-0 bottom-full mb-1 w-full max-h-48 overflow-y-auto rounded-lg border border-selection/75 bg-surface shadow-lg z-50"
|
|
>
|
|
${autocompleteConfig.skills.length === 0 ? html`
|
|
<div class="px-3 py-2 text-sm text-dim">No skills available</div>
|
|
` : filteredSkills.length === 0 ? html`
|
|
<div class="px-3 py-2 text-sm text-dim">No matching skills</div>
|
|
` : filteredSkills.map((skill, i) => html`
|
|
<div
|
|
key=${i}
|
|
class="px-3 py-2 cursor-pointer text-sm transition-colors ${
|
|
i === selectedIndex
|
|
? 'bg-selection/50 text-bright'
|
|
: 'text-fg hover:bg-selection/25'
|
|
}"
|
|
onClick=${() => insertSkill(skill)}
|
|
onMouseEnter=${() => setSelectedIndex(i)}
|
|
>
|
|
<div class="font-medium font-mono text-bright">
|
|
${autocompleteConfig.trigger}${skill.name}
|
|
</div>
|
|
<div class="text-micro text-dim truncate">${skill.description}</div>
|
|
</div>
|
|
`)}
|
|
</div>
|
|
`}
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
|
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
|
|
disabled=${sending || !text.trim()}
|
|
>
|
|
${sending ? 'Sending...' : 'Send'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
`;
|
|
}
|