Spaces:
Sleeping
Sleeping
File size: 3,766 Bytes
20abf65 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 | import { useEffect, useRef, useState } from 'preact/hooks';
import { askAgent, explainSelectionQuestion } from '../api.js';
import { ExplainStrip, SparkIcon } from './ExplainButton.jsx';
// Never trigger inside input fields or either chat surface (MiniChatDock,
// ChatPanel) or the explain popover itself (FINAL_SPEC scope note).
function inExcludedZone(node) {
const el = node && (node.nodeType === 3 ? node.parentElement : node);
if (!el || typeof el.closest !== 'function') return false;
return !!el.closest(
'input, textarea, select, [contenteditable="true"], .chat-panel, .mini-dock, .explain-strip, .selection-explain',
);
}
/**
* Selection-explain (operator request #4): highlight any text in the main
* app area -> a floating "Explain with AI" chip appears near the selection
* -> click posts `/api/agent/ask` with the tab-scoped prefix and renders the
* grounded answer in the same inline popover style as the panel explain
* affordance. Debounced; dismissed on scroll or click-away; inert over
* inputs and both chat surfaces.
*/
export default function SelectionExplain({ tabLabel }) {
const [chip, setChip] = useState(null); // {x, y, text}
const [popover, setPopover] = useState(null);
const debounceRef = useRef(null);
useEffect(() => {
const clear = () => {
setChip(null);
setPopover(null);
};
const onSelectionChange = () => {
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const sel = typeof window.getSelection === 'function' ? window.getSelection() : null;
const text = sel ? sel.toString().trim() : '';
if (!sel || !text || text.length < 2 || sel.rangeCount === 0) {
clear();
return;
}
if (inExcludedZone(sel.anchorNode) || inExcludedZone(sel.focusNode)) {
clear();
return;
}
const main = document.querySelector('.app-main');
const range = sel.getRangeAt(0);
if (!main || !main.contains(range.commonAncestorContainer)) {
clear();
return;
}
const rect = range.getBoundingClientRect();
if (!rect || (rect.width === 0 && rect.height === 0)) {
clear();
return;
}
setChip({ x: rect.left + rect.width / 2, y: rect.bottom, text });
setPopover(null);
}, 220);
};
const onMouseDown = (e) => {
if (e.target && e.target.closest && e.target.closest('.selection-explain')) return;
clear();
};
const onScroll = () => clear();
document.addEventListener('selectionchange', onSelectionChange);
document.addEventListener('mousedown', onMouseDown);
window.addEventListener('scroll', onScroll, true);
return () => {
document.removeEventListener('selectionchange', onSelectionChange);
document.removeEventListener('mousedown', onMouseDown);
window.removeEventListener('scroll', onScroll, true);
clearTimeout(debounceRef.current);
};
}, []);
const ask = async () => {
if (!chip) return;
setPopover({ status: 'loading' });
try {
const question = explainSelectionQuestion(tabLabel, chip.text);
const res = await askAgent(question);
setPopover({ status: 'done', ...res });
} catch (err) {
setPopover({ status: 'network-error', message: err.message });
}
};
if (!chip) return null;
return (
<div class="selection-explain" style={{ left: `${chip.x}px`, top: `${chip.y + 8}px` }}>
{!popover && (
<button type="button" class="selection-chip" onClick={ask}>
<SparkIcon size={12} />
Explain with AI
</button>
)}
{popover && <ExplainStrip state={popover} floating />}
</div>
);
}
|