Release exp-v0.52.198: Refine selected transcript text in composer (#6410, @rodboev)
Ships PR #6410 with a maintainer UX fix folded in (authorship preserved). Adds a 'Refine' action to the message text-selection toolbar: select text in a chat message -> Refine seeds it into the composer as an editable quoted draft (marker-free, one-way, existing draft + attachments preserved, 44x44 mobile icon buttons, all 15 locales). ChatGPT/Notion-style quote-to-edit flow. Maintainer fix (Fable UX gate): French instruction string now uses the space-before-colon convention (Instruction d'affinage :). Gate: Codex SAFE TO SHIP (marker-free, no XSS, one-way Range, Reply/drafts preserved), Fable SHIP-WITH-UX-FIXES (fix applied), tests 19/19, ESLint clean. Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
1 parent
728ec10e66
commit
8b873c55eb
6 files changed
+981
-34
No files matched your search
@@ -5,6 +5,8 @@
|
||||
|
||||
### Changed
|
||||
|
||||
- **Refine selected transcript text directly in the composer.** Selecting text inside a chat message now shows a "Refine" action alongside "Reply with selection" in the floating selection toolbar. Refine seeds the selected text into the composer as an editable draft (a quoted block plus a localized "Refine instruction:" line, caret ready) so you can shape a follow-up before sending — the ChatGPT/Notion-style quote-to-edit flow. The transfer is one-way and marker-free (no internal sentinel reaches the composer or the sent message), preserves any existing draft and attachments, collapses to 44×44 icon-only buttons on phones, and covers all 15 locales. Thanks @rodboev. (#6410)
|
||||
|
||||
- **Transparent Stream copy buttons now confirm success in place.** The tool-event and thinking copy controls flash a check for ~1.5s after a successful copy — the same feedback the normal chat copy buttons already give — then revert to the copy glyph. The confirmation is now clearly visible without hover (important on touch), stays correct across live-row reconciliation, cleans up if its row is torn down mid-feedback, and is never serialized into a restored session snapshot. Thanks @Stacey2911. (#6037)
|
||||
|
||||
- **Kanban boards can now set a default workspace path.** The board settings modal gains a "Default workspace path" field (a type-or-pick combobox sourced from your saved workspaces, so you can choose a known path or type a custom one — validated server-side). Tasks on the board without an explicit workspace path inherit this default. The board switcher chip is now always shown (even with a single board) and its menu item is now "Board settings…", enabled for the default board. Thanks @rodboev. (#6055, #5932)
|
||||
|
||||
@@ -51,6 +51,9 @@ const LOCALES = {
|
||||
copy_failed: 'Copy failed',
|
||||
selected_text_reply: 'Reply with selection',
|
||||
selected_text_reply_title: 'Append selected chat text as quoted context',
|
||||
selected_text_refine: 'Refine',
|
||||
selected_text_refine_title: 'Start an editable refinement draft from the selection',
|
||||
selected_text_refine_instruction: 'Refine instruction:',
|
||||
selected_text_reply_appended: 'Selected text added to composer',
|
||||
saved_prompts_empty: 'No saved prompts yet.',
|
||||
saved_prompts_save_current: 'Save current input as prompt',
|
||||
@@ -1825,6 +1828,9 @@ const LOCALES = {
|
||||
copy_failed: 'Copia fallita',
|
||||
selected_text_reply: 'Rispondi con selezione',
|
||||
selected_text_reply_title: 'Aggiungi il testo della chat selezionato come contesto citato',
|
||||
selected_text_refine: 'Affina',
|
||||
selected_text_refine_title: 'Inizia una bozza di affinamento modificabile dalla selezione',
|
||||
selected_text_refine_instruction: 'Istruzione di affinamento:',
|
||||
selected_text_reply_appended: 'Testo selezionato aggiunto al compositore',
|
||||
saved_prompts_empty: 'Nessun prompt salvato.',
|
||||
saved_prompts_save_current: 'Salva input corrente come prompt',
|
||||
@@ -3581,6 +3587,9 @@ const LOCALES = {
|
||||
copy_failed: 'コピー失敗',
|
||||
selected_text_reply: '選択範囲で返信',
|
||||
selected_text_reply_title: '選択したチャットテキストを引用コンテキストとして追加',
|
||||
selected_text_refine: '推敲',
|
||||
selected_text_refine_title: '選択範囲から編集可能な推敲ドラフトを開始',
|
||||
selected_text_refine_instruction: '推敲指示:',
|
||||
selected_text_reply_appended: '選択したテキストを入力欄に追加しました',
|
||||
saved_prompts_empty: '保存済みプロンプトはありません。',
|
||||
saved_prompts_save_current: '現在の入力をプロンプトとして保存',
|
||||
@@ -5339,6 +5348,9 @@ const LOCALES = {
|
||||
copy_failed: '\u041e\u0448\u0438\u0431\u043a\u0430 \u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f',
|
||||
selected_text_reply: 'Ответить с выделенным',
|
||||
selected_text_reply_title: 'Добавить выделенный текст чата как цитируемый контекст',
|
||||
selected_text_refine: 'Доработать',
|
||||
selected_text_refine_title: 'Начать редактируемый черновик доработки из выделенного текста',
|
||||
selected_text_refine_instruction: 'Инструкция по доработке:',
|
||||
selected_text_reply_appended: 'Выделенный текст добавлен в поле ввода',
|
||||
saved_prompts_empty: 'Сохранённых подсказок пока нет.',
|
||||
saved_prompts_save_current: 'Сохранить текущий ввод как подсказку',
|
||||
@@ -7062,6 +7074,9 @@ const LOCALES = {
|
||||
copy_failed: 'Error al copiar',
|
||||
selected_text_reply: 'Responder con selección',
|
||||
selected_text_reply_title: 'Añadir el texto del chat seleccionado como contexto citado',
|
||||
selected_text_refine: 'Refinar',
|
||||
selected_text_refine_title: 'Iniciar un borrador de refinado editable a partir de la selección',
|
||||
selected_text_refine_instruction: 'Instrucción de refinado:',
|
||||
selected_text_reply_appended: 'Texto seleccionado añadido al compositor',
|
||||
saved_prompts_empty: 'No hay indicaciones guardadas todavía.',
|
||||
saved_prompts_save_current: 'Guardar entrada actual como indicación',
|
||||
@@ -8764,6 +8779,9 @@ const LOCALES = {
|
||||
copy_failed: 'Kopieren fehlgeschlagen',
|
||||
selected_text_reply: 'Mit Auswahl antworten',
|
||||
selected_text_reply_title: 'Ausgewählten Chattext als Zitatkontext anhängen',
|
||||
selected_text_refine: 'Überarbeiten',
|
||||
selected_text_refine_title: 'Einen bearbeitbaren Überarbeitungsentwurf aus der Auswahl starten',
|
||||
selected_text_refine_instruction: 'Anweisung zur Überarbeitung:',
|
||||
selected_text_reply_appended: 'Auswahl zum Composer hinzugefügt',
|
||||
saved_prompts_empty: 'Noch keine gespeicherten Eingabeaufforderungen.',
|
||||
saved_prompts_save_current: 'Aktuelle Eingabe als Eingabeaufforderung speichern',
|
||||
@@ -10460,6 +10478,9 @@ const LOCALES = {
|
||||
copy_failed: '复制失败',
|
||||
selected_text_reply: '用所选内容回复',
|
||||
selected_text_reply_title: '将所选聊天文本作为引用上下文追加',
|
||||
selected_text_refine: '润色',
|
||||
selected_text_refine_title: '根据所选内容开始可编辑的润色草稿',
|
||||
selected_text_refine_instruction: '润色说明:',
|
||||
selected_text_reply_appended: '已将所选文本添加到输入框',
|
||||
saved_prompts_empty: '暂无保存的提示。',
|
||||
saved_prompts_save_current: '将当前输入保存为提示',
|
||||
@@ -12166,6 +12187,9 @@ const LOCALES = {
|
||||
copy_failed: '複製失敗',
|
||||
selected_text_reply: '用所選內容回覆',
|
||||
selected_text_reply_title: '將所選聊天文字附加為引用脈絡',
|
||||
selected_text_refine: '潤飾',
|
||||
selected_text_refine_title: '根據所選內容開始可編輯的潤飾草稿',
|
||||
selected_text_refine_instruction: '潤飾指示:',
|
||||
selected_text_reply_appended: '已將所選文字加入輸入框',
|
||||
saved_prompts_empty: '尚無已儲存的提示。',
|
||||
saved_prompts_save_current: '將目前輸入儲存為提示',
|
||||
@@ -13909,6 +13933,9 @@ const LOCALES = {
|
||||
copy_failed: 'Falha ao copiar',
|
||||
selected_text_reply: 'Responder com seleção',
|
||||
selected_text_reply_title: 'Anexar o texto selecionado do chat como contexto citado',
|
||||
selected_text_refine: 'Refinar',
|
||||
selected_text_refine_title: 'Iniciar um rascunho de refinamento editável a partir da seleção',
|
||||
selected_text_refine_instruction: 'Instrução de refinamento:',
|
||||
selected_text_reply_appended: 'Texto selecionado adicionado ao compositor',
|
||||
saved_prompts_empty: 'Nenhum prompt salvo ainda.',
|
||||
saved_prompts_save_current: 'Salvar entrada atual como prompt',
|
||||
@@ -15484,6 +15511,9 @@ const LOCALES = {
|
||||
copy_failed: '복사 실패',
|
||||
selected_text_reply: '선택 항목으로 답장',
|
||||
selected_text_reply_title: '선택한 채팅 텍스트를 인용 컨텍스트로 추가',
|
||||
selected_text_refine: '다듬기',
|
||||
selected_text_refine_title: '선택한 내용으로 편집 가능한 다듬기 초안을 시작',
|
||||
selected_text_refine_instruction: '다듬기 지시:',
|
||||
selected_text_reply_appended: '선택한 텍스트가 입력창에 추가되었습니다',
|
||||
saved_prompts_empty: '저장된 프롬프트가 없습니다.',
|
||||
saved_prompts_save_current: '현재 입력을 프롬프트로 저장',
|
||||
@@ -17241,6 +17271,9 @@ const LOCALES = {
|
||||
copy_failed: 'Échec de la copie',
|
||||
selected_text_reply: 'Répondre avec la sélection',
|
||||
selected_text_reply_title: 'Ajouter le texte sélectionné du chat comme contexte cité',
|
||||
selected_text_refine: 'Affiner',
|
||||
selected_text_refine_title: 'Commencer un brouillon d\'affinage modifiable à partir de la sélection',
|
||||
selected_text_refine_instruction: 'Instruction d\'affinage :',
|
||||
selected_text_reply_appended: 'Texte sélectionné ajouté au compositeur',
|
||||
saved_prompts_empty: 'Aucune invite sauvegardée pour l\'instant.',
|
||||
saved_prompts_save_current: 'Sauvegarder la saisie actuelle comme invite',
|
||||
@@ -20450,6 +20483,9 @@ const LOCALES = {
|
||||
selected_text_reply: 'Odpovědět výběrem',
|
||||
selected_text_reply_appended: 'Vybraný text přidán do editoru zprávy',
|
||||
selected_text_reply_title: 'Připojit vybraný text chatu jako citovaný kontext',
|
||||
selected_text_refine: 'Upravit',
|
||||
selected_text_refine_title: 'Zahájit upravitelný návrh úpravy z výběru',
|
||||
selected_text_refine_instruction: 'Pokyn k úpravě:',
|
||||
session_attention_approval: (n) => n === 1 ? 'Schválení' : `${n} schválení`,
|
||||
session_attention_approval_title: 'Čekání na rozhodnutí o povolení',
|
||||
session_attention_clarify: (n) => n === 1 ? 'Otázka' : `${n} ${_i18nCsPlural(n,'otázka','otázky','otázek')}`,
|
||||
@@ -20691,6 +20727,9 @@ const LOCALES = {
|
||||
copy_failed: 'Kopyalama başarısız oldu',
|
||||
selected_text_reply: 'Seçimle yanıtla',
|
||||
selected_text_reply_title: 'Seçilen sohbet metnini alıntılanan bağlam olarak ekle',
|
||||
selected_text_refine: 'İyileştir',
|
||||
selected_text_refine_title: 'Seçimden düzenlenebilir bir iyileştirme taslağı başlat',
|
||||
selected_text_refine_instruction: 'İyileştirme talimatı:',
|
||||
selected_text_reply_appended: 'Seçilen metin besteciye eklendi',
|
||||
saved_prompts_empty: 'Henüz kaydedilmiş istem yok.',
|
||||
saved_prompts_save_current: 'Mevcut girişi istem olarak kaydet',
|
||||
@@ -22440,6 +22479,9 @@ const LOCALES = {
|
||||
copy_failed: 'Kopiowanie nie powiodło się',
|
||||
selected_text_reply: 'Odpowiedz z zaznaczeniem',
|
||||
selected_text_reply_title: 'Dodaj zaznaczony tekst czatu jako cytowany kontekst',
|
||||
selected_text_refine: 'Dopracuj',
|
||||
selected_text_refine_title: 'Rozpocznij edytowalny szkic dopracowania na podstawie zaznaczenia',
|
||||
selected_text_refine_instruction: 'Instrukcja dopracowania:',
|
||||
selected_text_reply_appended: 'Dodano zaznaczony tekst do pola wprowadzania',
|
||||
saved_prompts_empty: 'Brak zapisanych promptów.',
|
||||
saved_prompts_save_current: 'Zapisz bieżący tekst jako prompt',
|
||||
@@ -24189,6 +24231,9 @@ const LOCALES = {
|
||||
copy_failed: 'Sao chép thất bại',
|
||||
selected_text_reply: 'Trả lời với đoạn đã chọn',
|
||||
selected_text_reply_title: 'Thêm đoạn chat đã chọn làm ngữ cảnh trích dẫn',
|
||||
selected_text_refine: 'Tinh chỉnh',
|
||||
selected_text_refine_title: 'Bắt đầu bản nháp tinh chỉnh có thể chỉnh sửa từ đoạn đã chọn',
|
||||
selected_text_refine_instruction: 'Hướng dẫn tinh chỉnh:',
|
||||
selected_text_reply_appended: 'Đã thêm đoạn đã chọn vào ô nhập',
|
||||
saved_prompts_empty: 'Chưa có prompt đã lưu.',
|
||||
saved_prompts_save_current: 'Lưu nội dung hiện tại thành prompt',
|
||||
|
||||
+71
-20
@@ -166,6 +166,8 @@ if(_msgEl) _msgEl.addEventListener('focus', ()=>{ if('speechSynthesis' in window
|
||||
if(_msgEl) _msgEl.addEventListener('blur', ()=>{ if('speechSynthesis' in window && speechSynthesis.paused) speechSynthesis.resume(); });
|
||||
|
||||
let _selectedTextReplyBtn=null;
|
||||
let _selectedTextRefineBtn=null;
|
||||
let _selectedTextReplyGroup=null;
|
||||
let _selectedTextReplyText='';
|
||||
let _pendingSelections=[]; // [{id, name, text}] — named context blocks
|
||||
let _selectionIdCounter=0;
|
||||
@@ -788,23 +790,49 @@ function _selectedTextReplySelection(){
|
||||
return {text, rect};
|
||||
}
|
||||
|
||||
function _formatSelectedTextReplyQuote(text){
|
||||
function _formatSelectedTextReplyQuote(text, includeMarker=true){
|
||||
const normalized=String(text||'').replace(/\r\n?/g,'\n').replace(/\n{3,}/g,'\n\n').trim();
|
||||
if(!normalized)return '';
|
||||
return `<!-- hermes-selected-context -->\n${normalized.split('\n').map(line=>`> ${line}`).join('\n')}`;
|
||||
const quote=normalized.split('\n').map(line=>`> ${line}`).join('\n');
|
||||
return includeMarker?`<!-- hermes-selected-context -->\n${quote}`:quote;
|
||||
}
|
||||
|
||||
function insertSavedPromptIntoComposer(text){
|
||||
function _appendComposerText(text){
|
||||
const composer=(typeof $==='function'&&$('msg'))||document.getElementById('msg');
|
||||
if(!composer||!text)return;
|
||||
const current=String(composer.value||'');
|
||||
composer.value=current.trim()?`${current.replace(/\s+$/,'')}\n\n${text}\n\n`:`${text}\n\n`;
|
||||
composer.value=current.trim()?`${current.replace(/\s+$/,'')}\n\n${text}`:String(text);
|
||||
composer.focus();
|
||||
try{composer.setSelectionRange(composer.value.length, composer.value.length);}catch(_e){}
|
||||
composer.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
if(typeof autoResize==='function') autoResize();
|
||||
}
|
||||
|
||||
function insertSavedPromptIntoComposer(text){
|
||||
if(!text)return;
|
||||
_appendComposerText(`${text}\n\n`);
|
||||
}
|
||||
|
||||
function _seedSelectedTextRefineDraft(text){
|
||||
const quote=_formatSelectedTextReplyQuote(text, false);
|
||||
const instruction=_selectedTextReplyT('selected_text_refine_instruction','Refine instruction:');
|
||||
if(!quote||!instruction)return;
|
||||
_appendComposerText(`${quote}\n\n${instruction} `);
|
||||
}
|
||||
|
||||
function _consumeSelectedTextReplySelection(){
|
||||
const info=_selectedTextReplySelection();
|
||||
if(!info){
|
||||
_hideSelectedTextReplyButton();
|
||||
return '';
|
||||
}
|
||||
const text=info.text;
|
||||
_hideSelectedTextReplyButton();
|
||||
const selection=window.getSelection&&window.getSelection();
|
||||
if(selection&&selection.removeAllRanges)selection.removeAllRanges();
|
||||
return text;
|
||||
}
|
||||
|
||||
let _savedPromptsCache=null;
|
||||
|
||||
async function _loadSavedPrompts(){
|
||||
@@ -1053,49 +1081,72 @@ function _flushSelectionBlocksToComposer(){
|
||||
|
||||
function _selectedTextReplyButton(){
|
||||
if(_selectedTextReplyBtn)return _selectedTextReplyBtn;
|
||||
const group=document.createElement('div');
|
||||
group.id='selectedTextActionGroup';
|
||||
group.className='selected-text-action-group';
|
||||
const btn=document.createElement('button');
|
||||
btn.type='button';
|
||||
btn.id='selectedTextReplyBtn';
|
||||
btn.className='selected-text-reply-btn';
|
||||
btn.setAttribute('data-i18n', 'selected_text_reply');
|
||||
btn.setAttribute('data-i18n-title', 'selected_text_reply_title');
|
||||
btn.setAttribute('data-i18n-aria-label', 'selected_text_reply_title');
|
||||
btn.setAttribute('data-i18n-aria-label', 'selected_text_reply');
|
||||
btn.textContent=_selectedTextReplyT('selected_text_reply', 'Reply with selection');
|
||||
btn.title=_selectedTextReplyT('selected_text_reply_title', 'Append selected chat text as quoted context');
|
||||
btn.setAttribute('aria-label', btn.title);
|
||||
btn.setAttribute('aria-label', btn.textContent);
|
||||
btn.addEventListener('mousedown', e=>e.preventDefault());
|
||||
btn.addEventListener('click', e=>{
|
||||
e.preventDefault();
|
||||
if(_selectedTextReplyText){
|
||||
_addNamedContextBlock(_selectedTextReplyText);
|
||||
_hideSelectedTextReplyButton();
|
||||
const selection=window.getSelection&&window.getSelection();
|
||||
if(selection&&selection.removeAllRanges)selection.removeAllRanges();
|
||||
const text=_consumeSelectedTextReplySelection();
|
||||
if(text){
|
||||
_addNamedContextBlock(text);
|
||||
}
|
||||
});
|
||||
document.body.appendChild(btn);
|
||||
const refine=document.createElement('button');
|
||||
refine.type='button';
|
||||
refine.id='selectedTextRefineBtn';
|
||||
refine.className='selected-text-refine-btn';
|
||||
refine.setAttribute('data-i18n', 'selected_text_refine');
|
||||
refine.setAttribute('data-i18n-title', 'selected_text_refine_title');
|
||||
refine.setAttribute('data-i18n-aria-label', 'selected_text_refine');
|
||||
refine.textContent=_selectedTextReplyT('selected_text_refine', 'Refine');
|
||||
refine.title=_selectedTextReplyT('selected_text_refine_title', 'Start an editable refinement draft from the selection');
|
||||
refine.setAttribute('aria-label', refine.textContent);
|
||||
refine.addEventListener('mousedown', e=>e.preventDefault());
|
||||
refine.addEventListener('click', e=>{
|
||||
e.preventDefault();
|
||||
const text=_consumeSelectedTextReplySelection();
|
||||
if(text){
|
||||
_seedSelectedTextRefineDraft(text);
|
||||
}
|
||||
});
|
||||
group.appendChild(btn);
|
||||
group.appendChild(refine);
|
||||
document.body.appendChild(group);
|
||||
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
|
||||
_selectedTextReplyBtn=btn;
|
||||
_selectedTextRefineBtn=refine;
|
||||
_selectedTextReplyGroup=group;
|
||||
return btn;
|
||||
}
|
||||
|
||||
function _hideSelectedTextReplyButton(){
|
||||
_selectedTextReplyText='';
|
||||
if(_selectedTextReplyBtn)_selectedTextReplyBtn.classList.remove('visible');
|
||||
if(_selectedTextReplyGroup)_selectedTextReplyGroup.classList.remove('visible');
|
||||
}
|
||||
|
||||
function _positionSelectedTextReplyButton(info){
|
||||
const btn=_selectedTextReplyButton();
|
||||
_selectedTextReplyText=info.text;
|
||||
btn.classList.add('visible');
|
||||
_selectedTextReplyGroup.classList.add('visible');
|
||||
const gap=8;
|
||||
const btnRect=btn.getBoundingClientRect();
|
||||
const width=btnRect.width||150;
|
||||
const height=btnRect.height||32;
|
||||
const groupRect=_selectedTextReplyGroup.getBoundingClientRect();
|
||||
const width=groupRect.width||250;
|
||||
const height=groupRect.height||40;
|
||||
const left=Math.min(Math.max(gap, info.rect.left+(info.rect.width/2)-(width/2)), Math.max(gap, window.innerWidth-width-gap));
|
||||
const top=Math.max(gap, info.rect.top-height-gap);
|
||||
btn.style.left=`${left}px`;
|
||||
btn.style.top=`${top}px`;
|
||||
_selectedTextReplyGroup.style.left=`${left}px`;
|
||||
_selectedTextReplyGroup.style.top=`${top}px`;
|
||||
}
|
||||
|
||||
function _updateSelectedTextReplyButton(){
|
||||
@@ -1114,7 +1165,7 @@ function _updateSelectedTextReplyButton(){
|
||||
if(typeof document!=='undefined'){
|
||||
document.addEventListener('selectionchange', _updateSelectedTextReplyButton);
|
||||
document.addEventListener('mouseup', e=>{
|
||||
if(e.target&&e.target.closest&&e.target.closest('.selected-text-reply-btn'))return;
|
||||
if(e.target&&e.target.closest&&e.target.closest('.selected-text-action-group'))return;
|
||||
_updateSelectedTextReplyButton();
|
||||
});
|
||||
document.addEventListener('keyup', e=>{
|
||||
|
||||
+7
-4
@@ -3433,10 +3433,13 @@
|
||||
.vscroll-measuring .msg-time{transition:none !important;}
|
||||
.msg-action-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:5px;transition:color .12s,background .12s;line-height:1;}
|
||||
.msg-action-btn:hover{color:var(--accent-text);background:var(--accent-bg);}
|
||||
.selected-text-reply-btn{position:fixed;z-index:1200;display:inline-flex;align-items:center;gap:6px;padding:7px 11px;border:2px solid var(--accent);border-radius:999px;background:var(--bg);color:var(--text);box-shadow:0 8px 24px rgba(0,0,0,.26),0 0 0 1px var(--surface);font-size:12px;font-weight:700;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transform:translateY(4px);transition:opacity .12s ease,transform .12s ease,background .12s ease,border-color .12s ease;user-select:none;}
|
||||
.selected-text-reply-btn.visible{opacity:1;pointer-events:auto;transform:translateY(0);}
|
||||
.selected-text-reply-btn:hover{background:var(--accent-bg);border-color:var(--accent-hover);}
|
||||
.selected-text-reply-btn:focus-visible{background:var(--accent-bg);border-color:var(--accent-hover);outline:2px solid var(--focus-ring);outline-offset:2px;}
|
||||
.selected-text-action-group{position:fixed;z-index:1200;display:inline-flex;align-items:center;gap:2px;padding:3px;border:1px solid var(--border2);border-radius:999px;background:var(--bg);box-shadow:0 8px 24px rgba(0,0,0,.26),0 0 0 1px var(--surface);visibility:hidden;opacity:0;pointer-events:none;transform:translateY(4px);transition:visibility 0s linear .12s,opacity .12s ease,transform .12s ease;user-select:none;}
|
||||
.selected-text-action-group.visible{visibility:visible;opacity:1;pointer-events:auto;transform:translateY(0);transition-delay:0s;}
|
||||
.selected-text-action-group button{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:32px;padding:7px 11px;border:1px solid transparent;border-radius:999px;background:transparent;color:var(--text);font-family:inherit;font-size:12px;font-weight:700;line-height:1;cursor:pointer;white-space:nowrap;}
|
||||
.selected-text-action-group button:hover,.selected-text-action-group button:focus-visible{background:var(--accent-bg);border-color:var(--accent-hover);}
|
||||
.selected-text-action-group button:focus-visible{outline:2px solid var(--focus-ring);outline-offset:2px;}
|
||||
@media (pointer:coarse){.selected-text-action-group button{min-width:44px;min-height:44px;}}
|
||||
@media (max-width:560px){.selected-text-action-group button{width:44px;min-width:44px;padding:7px;font-size:0;}.selected-text-action-group button::before{font-size:16px;}.selected-text-reply-btn::before{content:'↩';}.selected-text-refine-btn::before{content:'✦';}}
|
||||
/* TTS speaker button: hidden by default, shown when TTS is enabled.
|
||||
* Use body-class selector instead of JS inline-style so the rule survives
|
||||
* subsequent renderMd() passes and is not subject to inline-style cascade
|
||||
|
||||
@@ -0,0 +1,817 @@
|
||||
"""Runtime regression coverage for refine-from-selection (#1255)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.test_selected_context_user_render_runtime import _run_user_renderer
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MESSAGES_JS = Path(os.environ.get("HERMES_ISSUE1255_MESSAGES_JS") or (ROOT / "static" / "messages.js"))
|
||||
I18N = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(NODE is None, reason="node is required")
|
||||
|
||||
|
||||
def _locale_blocks(src: str) -> dict[str, str]:
|
||||
matches = list(
|
||||
re.finditer(
|
||||
r"\n (?:(['\"])([A-Za-z][A-Za-z0-9-]*)\1|([A-Za-z][A-Za-z0-9-]*)): \{",
|
||||
src,
|
||||
)
|
||||
)
|
||||
blocks: dict[str, str] = {}
|
||||
for idx, match in enumerate(matches):
|
||||
start = match.end()
|
||||
end = matches[idx + 1].start() if idx + 1 < len(matches) else src.rfind("\n};")
|
||||
blocks[match.group(2) or match.group(3)] = src[start:end]
|
||||
return blocks
|
||||
|
||||
|
||||
def _instruction_values(src: str) -> list[str]:
|
||||
blocks = _locale_blocks(src)
|
||||
values: list[str] = []
|
||||
pattern = re.compile(r"^\s{4}selected_text_refine_instruction:\s*(['\"])(.*?)\1,", re.MULTILINE)
|
||||
for block in blocks.values():
|
||||
match = pattern.search(block)
|
||||
assert match, "selected_text_refine_instruction missing from locale block"
|
||||
values.append(match.group(2))
|
||||
return values
|
||||
|
||||
|
||||
_DRIVER = r"""
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync(process.argv[1], 'utf8');
|
||||
const scenario = process.argv[2];
|
||||
const scenarioArg = process.argv[3] || '';
|
||||
|
||||
function extractFunc(name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
const braceStart = src.indexOf('{', start);
|
||||
let depth = 0;
|
||||
let inString = null;
|
||||
let escaped = false;
|
||||
let inLineComment = false;
|
||||
let inBlockComment = false;
|
||||
for (let i = braceStart; i < src.length; i++) {
|
||||
const ch = src[i];
|
||||
const next = i + 1 < src.length ? src[i + 1] : '';
|
||||
if (inLineComment) {
|
||||
if (ch === '\n') inLineComment = false;
|
||||
continue;
|
||||
}
|
||||
if (inBlockComment) {
|
||||
if (ch === '*' && next === '/') {
|
||||
inBlockComment = false;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inString) {
|
||||
if (escaped) escaped = false;
|
||||
else if (ch === '\\') escaped = true;
|
||||
else if (ch === inString) inString = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '/') {
|
||||
inLineComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
inBlockComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"' || ch === '`') {
|
||||
inString = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return src.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error(name + ' brace scan failed');
|
||||
}
|
||||
|
||||
function hasFunc(name) {
|
||||
return new RegExp('function\\s+' + name + '\\s*\\(').test(src);
|
||||
}
|
||||
|
||||
const liveSelectionHelper = hasFunc('_consumeSelectedTextReplySelection')
|
||||
? '_consumeSelectedTextReplySelection'
|
||||
: '_selectedTextReplyLiveText';
|
||||
|
||||
let _selectedTextReplyBtn = null;
|
||||
let _selectedTextRefineBtn = null;
|
||||
let _selectedTextReplyGroup = null;
|
||||
let _selectedTextReplyText = '';
|
||||
let _selectedTextReplyRaf = 0;
|
||||
let _pendingSelections = [];
|
||||
let _selectionIdCounter = 0;
|
||||
let S = {pendingFiles: ['keep.bin']};
|
||||
|
||||
const i18n = {
|
||||
selected_text_reply: 'Reply with selection',
|
||||
selected_text_reply_title: 'Append selected chat text as quoted context',
|
||||
selected_text_refine: 'Refine',
|
||||
selected_text_refine_title: 'Start an editable refinement draft from the selection',
|
||||
selected_text_refine_instruction: 'Refine instruction:',
|
||||
};
|
||||
|
||||
global.__log = [];
|
||||
global.__replyCalls = [];
|
||||
global.__sendCalls = 0;
|
||||
global.__autoResizeCalls = 0;
|
||||
global.__selectionClears = 0;
|
||||
|
||||
function pushLog(entry) {
|
||||
global.__log.push(entry);
|
||||
}
|
||||
|
||||
function makeClassList(owner) {
|
||||
return {
|
||||
add(name) { owner._classes.add(name); },
|
||||
remove(name) { owner._classes.delete(name); },
|
||||
contains(name) { return owner._classes.has(name); },
|
||||
};
|
||||
}
|
||||
|
||||
function makeElement(tagName) {
|
||||
let value = '';
|
||||
const el = {
|
||||
nodeType: 1,
|
||||
tagName: String(tagName || 'div').toUpperCase(),
|
||||
children: [],
|
||||
parentElement: null,
|
||||
parentNode: null,
|
||||
style: {},
|
||||
_classes: new Set(),
|
||||
_attrs: {},
|
||||
_listeners: {},
|
||||
_events: [],
|
||||
textContent: '',
|
||||
title: '',
|
||||
id: '',
|
||||
className: '',
|
||||
selectionRange: null,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0,
|
||||
focused: false,
|
||||
classList: null,
|
||||
appendChild(child) {
|
||||
child.parentElement = el;
|
||||
child.parentNode = el;
|
||||
el.children.push(child);
|
||||
return child;
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
el._attrs[name] = String(value);
|
||||
},
|
||||
getAttribute(name) {
|
||||
return Object.prototype.hasOwnProperty.call(el._attrs, name) ? el._attrs[name] : null;
|
||||
},
|
||||
addEventListener(type, handler) {
|
||||
(el._listeners[type] ||= []).push(handler);
|
||||
},
|
||||
dispatchEvent(event) {
|
||||
event.target = event.target || el;
|
||||
event.preventDefault ||= (() => {});
|
||||
el._events.push(event.type);
|
||||
if (event.type === 'input') pushLog(`input:${el.id || el.tagName.toLowerCase()}`);
|
||||
for (const handler of el._listeners[event.type] || []) handler(event);
|
||||
return true;
|
||||
},
|
||||
focus() {
|
||||
el.focused = true;
|
||||
pushLog(`focus:${el.id || el.tagName.toLowerCase()}`);
|
||||
},
|
||||
setSelectionRange(start, end) {
|
||||
el.selectionRange = [start, end];
|
||||
el.selectionStart = start;
|
||||
el.selectionEnd = end;
|
||||
pushLog(`setSelectionRange:${el.id || el.tagName.toLowerCase()}:${start}:${end}`);
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return el._rect || {left: 0, top: 0, width: 180, height: 36};
|
||||
},
|
||||
contains(node) {
|
||||
let cur = node;
|
||||
while (cur) {
|
||||
if (cur === el) return true;
|
||||
cur = cur.parentElement || cur.parentNode || null;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
closest(selector) {
|
||||
if (!selector.startsWith('.')) return null;
|
||||
const want = selector.slice(1);
|
||||
let cur = el;
|
||||
while (cur) {
|
||||
if (cur._classes && cur._classes.has(want)) return cur;
|
||||
cur = cur.parentElement || null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(el, 'value', {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get() {
|
||||
return value;
|
||||
},
|
||||
set(next) {
|
||||
value = String(next);
|
||||
pushLog(`value:${el.id || el.tagName.toLowerCase()}:${value.length}`);
|
||||
},
|
||||
});
|
||||
el.classList = makeClassList(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function makeText(text) {
|
||||
return {nodeType: 3, textContent: String(text), parentElement: null, parentNode: null};
|
||||
}
|
||||
|
||||
const body = makeElement('body');
|
||||
const docListeners = {};
|
||||
|
||||
function findById(node, id) {
|
||||
if (!node) return null;
|
||||
if (node.id === id) return node;
|
||||
for (const child of node.children || []) {
|
||||
const found = findById(child, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
global.Node = {ELEMENT_NODE: 1};
|
||||
global.Event = function Event(type, opts) {
|
||||
this.type = type;
|
||||
Object.assign(this, opts || {});
|
||||
};
|
||||
global.document = {
|
||||
body,
|
||||
createElement: makeElement,
|
||||
getElementById(id) {
|
||||
return findById(body, id);
|
||||
},
|
||||
addEventListener(type, handler) {
|
||||
(docListeners[type] ||= []).push(handler);
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
let currentSelection = null;
|
||||
global.window = {
|
||||
innerWidth: 1024,
|
||||
getSelection() {
|
||||
return currentSelection;
|
||||
},
|
||||
requestAnimationFrame(cb) {
|
||||
cb();
|
||||
return 0;
|
||||
},
|
||||
};
|
||||
global.$ = (id) => document.getElementById(id);
|
||||
global.t = (key) => i18n[key];
|
||||
global.autoResize = () => {
|
||||
global.__autoResizeCalls += 1;
|
||||
pushLog('autoResize');
|
||||
};
|
||||
global.applyLocaleToDOM = () => {};
|
||||
global._addNamedContextBlock = (text) => {
|
||||
pushLog('addNamedContextBlock');
|
||||
global.__replyCalls.push(text);
|
||||
};
|
||||
global.send = () => {
|
||||
global.__sendCalls += 1;
|
||||
};
|
||||
|
||||
eval(extractFunc('_selectedTextReplyT'));
|
||||
eval(extractFunc('_selectedTextReplyRoot'));
|
||||
eval(extractFunc('_selectedTextReplyNodeInChat'));
|
||||
eval(extractFunc('_selectedTextReplySelection'));
|
||||
eval(extractFunc('_formatSelectedTextReplyQuote'));
|
||||
if (hasFunc('_appendComposerText')) eval(extractFunc('_appendComposerText'));
|
||||
eval(extractFunc('insertSavedPromptIntoComposer'));
|
||||
eval(extractFunc('_seedSelectedTextRefineDraft'));
|
||||
eval(extractFunc(liveSelectionHelper));
|
||||
eval(extractFunc('_selectedTextReplyButton'));
|
||||
eval(extractFunc('_hideSelectedTextReplyButton'));
|
||||
eval(extractFunc('_positionSelectedTextReplyButton'));
|
||||
eval(extractFunc('_updateSelectedTextReplyButton'));
|
||||
|
||||
if (scenario === 'browser_fixture') {
|
||||
process.stdout.write(JSON.stringify({
|
||||
helpers: [
|
||||
extractFunc('_selectedTextReplyT'),
|
||||
extractFunc('_selectedTextReplyRoot'),
|
||||
extractFunc('_selectedTextReplyNodeInChat'),
|
||||
extractFunc('_selectedTextReplySelection'),
|
||||
extractFunc('_formatSelectedTextReplyQuote'),
|
||||
...(hasFunc('_appendComposerText') ? [extractFunc('_appendComposerText')] : []),
|
||||
extractFunc('insertSavedPromptIntoComposer'),
|
||||
extractFunc('_seedSelectedTextRefineDraft'),
|
||||
extractFunc(liveSelectionHelper),
|
||||
extractFunc('_selectedTextReplyButton'),
|
||||
extractFunc('_hideSelectedTextReplyButton'),
|
||||
extractFunc('_positionSelectedTextReplyButton'),
|
||||
extractFunc('_updateSelectedTextReplyButton'),
|
||||
].join('\n'),
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function resetSignals() {
|
||||
global.__log = [];
|
||||
global.__replyCalls = [];
|
||||
global.__sendCalls = 0;
|
||||
global.__autoResizeCalls = 0;
|
||||
global.__selectionClears = 0;
|
||||
}
|
||||
|
||||
function buildShell() {
|
||||
_selectedTextReplyBtn = null;
|
||||
_selectedTextRefineBtn = null;
|
||||
_selectedTextReplyGroup = null;
|
||||
_selectedTextReplyText = '';
|
||||
_pendingSelections = [];
|
||||
_selectionIdCounter = 0;
|
||||
S = {pendingFiles: ['keep.bin']};
|
||||
body.children = [];
|
||||
const messages = makeElement('div');
|
||||
messages.id = 'messages';
|
||||
const bubble = makeElement('div');
|
||||
bubble.className = 'msg-body';
|
||||
messages.appendChild(bubble);
|
||||
const textNode = makeText('Alpha\nBeta');
|
||||
textNode.parentElement = bubble;
|
||||
textNode.parentNode = bubble;
|
||||
bubble.children.push(textNode);
|
||||
const outside = makeElement('div');
|
||||
outside.id = 'outside';
|
||||
const outsideText = makeText('Outside');
|
||||
outsideText.parentElement = outside;
|
||||
outsideText.parentNode = outside;
|
||||
outside.children.push(outsideText);
|
||||
const composer = makeElement('textarea');
|
||||
composer.id = 'msg';
|
||||
body.appendChild(messages);
|
||||
body.appendChild(outside);
|
||||
body.appendChild(composer);
|
||||
return {messages, bubble, textNode, outside, outsideText, composer};
|
||||
}
|
||||
|
||||
function selectionFor(startNode, endNode, text, rect, collapsed = false) {
|
||||
return {
|
||||
isCollapsed: collapsed,
|
||||
rangeCount: 1,
|
||||
getRangeAt() {
|
||||
return {
|
||||
startContainer: startNode,
|
||||
endContainer: endNode,
|
||||
getBoundingClientRect() {
|
||||
return rect;
|
||||
},
|
||||
};
|
||||
},
|
||||
toString() {
|
||||
return text;
|
||||
},
|
||||
removeAllRanges() {
|
||||
global.__selectionClears += 1;
|
||||
pushLog('removeAllRanges');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runVisibilityScenario() {
|
||||
const {textNode} = buildShell();
|
||||
currentSelection = selectionFor(textNode, textNode, 'Alpha\nBeta', {left: 160, top: 120, width: 90, height: 18});
|
||||
_updateSelectedTextReplyButton();
|
||||
const group = document.getElementById('selectedTextActionGroup');
|
||||
const reply = document.getElementById('selectedTextReplyBtn');
|
||||
const refine = document.getElementById('selectedTextRefineBtn');
|
||||
const valid = {
|
||||
visible: group.classList.contains('visible'),
|
||||
replyText: reply.textContent,
|
||||
refineText: refine.textContent,
|
||||
selectedText: _selectedTextReplyText,
|
||||
left: group.style.left,
|
||||
top: group.style.top,
|
||||
};
|
||||
currentSelection = selectionFor(textNode, textNode, 'Alpha\nBeta', {left: 160, top: 120, width: 90, height: 18}, true);
|
||||
_updateSelectedTextReplyButton();
|
||||
return {
|
||||
valid,
|
||||
invalidVisible: group.classList.contains('visible'),
|
||||
};
|
||||
}
|
||||
|
||||
function runReplyScenario() {
|
||||
const {textNode, composer} = buildShell();
|
||||
composer.value = 'Existing draft';
|
||||
currentSelection = selectionFor(textNode, textNode, 'Quoted context', {left: 120, top: 100, width: 90, height: 18});
|
||||
const reply = _selectedTextReplyButton();
|
||||
_selectedTextReplyText = 'Quoted context';
|
||||
_selectedTextReplyGroup.classList.add('visible');
|
||||
resetSignals();
|
||||
reply.dispatchEvent({type: 'click', preventDefault() {}});
|
||||
return {
|
||||
replyCalls: global.__replyCalls,
|
||||
composerValue: composer.value,
|
||||
composerFocused: !!composer.focused,
|
||||
hidden: !_selectedTextReplyGroup.classList.contains('visible'),
|
||||
selectionClears: global.__selectionClears,
|
||||
sendCalls: global.__sendCalls,
|
||||
autoResizeCalls: global.__autoResizeCalls,
|
||||
pendingFiles: S.pendingFiles.slice(),
|
||||
log: global.__log.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
function runRefineScenario(instructionOverride) {
|
||||
const {textNode, composer} = buildShell();
|
||||
composer.value = 'Existing draft';
|
||||
if (instructionOverride) i18n.selected_text_refine_instruction = instructionOverride;
|
||||
currentSelection = selectionFor(textNode, textNode, 'Quoted context\nSecond line', {left: 120, top: 100, width: 120, height: 18});
|
||||
_selectedTextReplyButton();
|
||||
const refine = document.getElementById('selectedTextRefineBtn');
|
||||
_selectedTextReplyText = 'Quoted context\nSecond line';
|
||||
_selectedTextReplyGroup.classList.add('visible');
|
||||
resetSignals();
|
||||
refine.dispatchEvent({type: 'click', preventDefault() {}});
|
||||
return {
|
||||
composerValue: composer.value,
|
||||
selectionRange: composer.selectionRange,
|
||||
selectionStart: composer.selectionStart,
|
||||
selectionEnd: composer.selectionEnd,
|
||||
inputEvents: composer._events.filter((event) => event === 'input').length,
|
||||
focused: !!composer.focused,
|
||||
hidden: !_selectedTextReplyGroup.classList.contains('visible'),
|
||||
selectionClears: global.__selectionClears,
|
||||
autoResizeCalls: global.__autoResizeCalls,
|
||||
pendingFiles: S.pendingFiles.slice(),
|
||||
pendingSelections: _pendingSelections.slice(),
|
||||
sendCalls: global.__sendCalls,
|
||||
log: global.__log.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
function runInvalidMatrixScenario() {
|
||||
const cases = {};
|
||||
|
||||
{
|
||||
const {textNode, composer} = buildShell();
|
||||
composer.value = 'Untouched draft';
|
||||
_selectedTextReplyButton();
|
||||
const refine = document.getElementById('selectedTextRefineBtn');
|
||||
_selectedTextReplyText = 'Stale text';
|
||||
_selectedTextReplyGroup.classList.add('visible');
|
||||
currentSelection = selectionFor(textNode, textNode, 'Quoted context', {left: 120, top: 100, width: 90, height: 18}, true);
|
||||
resetSignals();
|
||||
refine.dispatchEvent({type: 'click', preventDefault() {}});
|
||||
cases.collapsed = {
|
||||
composerValue: composer.value,
|
||||
hidden: !_selectedTextReplyGroup.classList.contains('visible'),
|
||||
selectionClears: global.__selectionClears,
|
||||
autoResizeCalls: global.__autoResizeCalls,
|
||||
sendCalls: global.__sendCalls,
|
||||
pendingSelections: _pendingSelections.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
const {textNode, outsideText, composer} = buildShell();
|
||||
composer.value = 'Untouched draft';
|
||||
_selectedTextReplyButton();
|
||||
const refine = document.getElementById('selectedTextRefineBtn');
|
||||
_selectedTextReplyText = 'Stale text';
|
||||
_selectedTextReplyGroup.classList.add('visible');
|
||||
currentSelection = selectionFor(textNode, outsideText, 'Mixed context', {left: 120, top: 100, width: 90, height: 18});
|
||||
resetSignals();
|
||||
refine.dispatchEvent({type: 'click', preventDefault() {}});
|
||||
cases.outside = {
|
||||
composerValue: composer.value,
|
||||
hidden: !_selectedTextReplyGroup.classList.contains('visible'),
|
||||
selectionClears: global.__selectionClears,
|
||||
autoResizeCalls: global.__autoResizeCalls,
|
||||
sendCalls: global.__sendCalls,
|
||||
pendingSelections: _pendingSelections.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
const {textNode, composer} = buildShell();
|
||||
composer.value = 'Untouched draft';
|
||||
_selectedTextReplyButton();
|
||||
const refine = document.getElementById('selectedTextRefineBtn');
|
||||
_selectedTextReplyText = 'Stale text';
|
||||
_selectedTextReplyGroup.classList.add('visible');
|
||||
currentSelection = selectionFor(textNode, textNode, 'Quoted context', {left: 120, top: 100, width: 0, height: 0});
|
||||
resetSignals();
|
||||
refine.dispatchEvent({type: 'click', preventDefault() {}});
|
||||
cases.zero_rect = {
|
||||
composerValue: composer.value,
|
||||
hidden: !_selectedTextReplyGroup.classList.contains('visible'),
|
||||
selectionClears: global.__selectionClears,
|
||||
autoResizeCalls: global.__autoResizeCalls,
|
||||
sendCalls: global.__sendCalls,
|
||||
pendingSelections: _pendingSelections.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
{
|
||||
const {composer} = buildShell();
|
||||
composer.value = 'Untouched draft';
|
||||
_selectedTextReplyButton();
|
||||
const refine = document.getElementById('selectedTextRefineBtn');
|
||||
_selectedTextReplyText = 'Stale text';
|
||||
_selectedTextReplyGroup.classList.add('visible');
|
||||
currentSelection = null;
|
||||
resetSignals();
|
||||
refine.dispatchEvent({type: 'click', preventDefault() {}});
|
||||
cases.stale = {
|
||||
composerValue: composer.value,
|
||||
hidden: !_selectedTextReplyGroup.classList.contains('visible'),
|
||||
selectionClears: global.__selectionClears,
|
||||
autoResizeCalls: global.__autoResizeCalls,
|
||||
sendCalls: global.__sendCalls,
|
||||
pendingSelections: _pendingSelections.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
return cases;
|
||||
}
|
||||
|
||||
function runSavedPromptScenario() {
|
||||
const {composer} = buildShell();
|
||||
composer.value = 'Draft tail \n\t';
|
||||
resetSignals();
|
||||
insertSavedPromptIntoComposer('Saved prompt');
|
||||
return {
|
||||
composerValue: composer.value,
|
||||
selectionStart: composer.selectionStart,
|
||||
selectionEnd: composer.selectionEnd,
|
||||
inputEvents: composer._events.filter((event) => event === 'input').length,
|
||||
focused: !!composer.focused,
|
||||
autoResizeCalls: global.__autoResizeCalls,
|
||||
pendingFiles: S.pendingFiles.slice(),
|
||||
sendCalls: global.__sendCalls,
|
||||
};
|
||||
}
|
||||
|
||||
const out =
|
||||
scenario === 'visibility' ? runVisibilityScenario() :
|
||||
scenario === 'reply' ? runReplyScenario() :
|
||||
scenario === 'refine' ? runRefineScenario('') :
|
||||
scenario === 'refine_with_instruction' ? runRefineScenario(scenarioArg) :
|
||||
scenario === 'invalid_matrix' ? runInvalidMatrixScenario() :
|
||||
scenario === 'saved_prompt' ? runSavedPromptScenario() :
|
||||
(() => { throw new Error('unknown scenario: ' + scenario); })();
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _run_js(scenario: str, arg: str = "") -> dict:
|
||||
completed = subprocess.run(
|
||||
[NODE, "-e", _DRIVER, str(MESSAGES_JS), scenario, arg],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def _log_index(log: list[str], prefix: str) -> int:
|
||||
return next(idx for idx, entry in enumerate(log) if entry.startswith(prefix))
|
||||
|
||||
|
||||
def test_valid_in_chat_selection_exposes_reply_and_refine_actions():
|
||||
out = _run_js("visibility")
|
||||
assert out["valid"]["visible"] is True
|
||||
assert out["valid"]["replyText"] == "Reply with selection"
|
||||
assert out["valid"]["refineText"] == "Refine"
|
||||
assert out["valid"]["selectedText"] == "Alpha\nBeta"
|
||||
assert out["valid"]["left"].endswith("px")
|
||||
assert out["valid"]["top"].endswith("px")
|
||||
assert out["invalidVisible"] is False
|
||||
|
||||
|
||||
def test_reply_action_still_creates_one_named_context_block_without_touching_composer():
|
||||
out = _run_js("reply")
|
||||
assert out["replyCalls"] == ["Quoted context"]
|
||||
assert out["composerValue"] == "Existing draft"
|
||||
assert out["composerFocused"] is False
|
||||
assert out["hidden"] is True
|
||||
assert out["selectionClears"] == 1
|
||||
assert out["sendCalls"] == 0
|
||||
assert out["autoResizeCalls"] == 0
|
||||
assert out["pendingFiles"] == ["keep.bin"]
|
||||
|
||||
|
||||
def test_transcript_selection_is_consumed_before_composer_focus():
|
||||
reply = _run_js("reply")
|
||||
refine = _run_js("refine")
|
||||
|
||||
assert _log_index(reply["log"], "removeAllRanges") < _log_index(reply["log"], "addNamedContextBlock")
|
||||
assert _log_index(refine["log"], "removeAllRanges") < _log_index(refine["log"], "value:msg:")
|
||||
assert _log_index(refine["log"], "removeAllRanges") < _log_index(refine["log"], "focus:msg")
|
||||
assert _log_index(refine["log"], "removeAllRanges") < _log_index(refine["log"], "setSelectionRange:msg:")
|
||||
|
||||
|
||||
def test_refine_seeds_marker_free_draft_ending_in_localized_instruction_and_space_without_send():
|
||||
out = _run_js("refine")
|
||||
assert out["composerValue"] == (
|
||||
"Existing draft\n\n"
|
||||
"> Quoted context\n"
|
||||
"> Second line\n\n"
|
||||
"Refine instruction: "
|
||||
)
|
||||
assert "hermes-selected-context" not in out["composerValue"]
|
||||
assert out["selectionRange"] == [len(out["composerValue"]), len(out["composerValue"])]
|
||||
assert out["selectionStart"] == len(out["composerValue"])
|
||||
assert out["selectionEnd"] == len(out["composerValue"])
|
||||
assert out["inputEvents"] == 1
|
||||
assert out["focused"] is True
|
||||
assert out["hidden"] is True
|
||||
assert out["selectionClears"] == 1
|
||||
assert out["autoResizeCalls"] == 1
|
||||
assert out["pendingFiles"] == ["keep.bin"]
|
||||
assert out["pendingSelections"] == []
|
||||
assert out["sendCalls"] == 0
|
||||
|
||||
|
||||
def test_refine_preserves_existing_draft_and_pending_files():
|
||||
out = _run_js("refine")
|
||||
assert out["composerValue"].startswith("Existing draft\n\n> Quoted context")
|
||||
assert out["pendingFiles"] == ["keep.bin"]
|
||||
assert out["sendCalls"] == 0
|
||||
|
||||
|
||||
def test_invalid_selection_keeps_refine_inert():
|
||||
out = _run_js("invalid_matrix")
|
||||
for case in out.values():
|
||||
assert case["composerValue"] == "Untouched draft"
|
||||
assert case["hidden"] is True
|
||||
assert case["selectionClears"] == 0
|
||||
assert case["autoResizeCalls"] == 0
|
||||
assert case["sendCalls"] == 0
|
||||
assert case["pendingSelections"] == []
|
||||
|
||||
|
||||
def test_refine_output_stays_marker_free_in_sent_user_rendering():
|
||||
out = _run_js("refine")
|
||||
rendered = _run_user_renderer(out["composerValue"])
|
||||
|
||||
assert "hermes-selected-context" not in out["composerValue"]
|
||||
assert "hermes-selected-context" not in rendered
|
||||
assert 'class="sent-selection-context"' not in rendered
|
||||
assert "<!-- hermes-selected-context -->" not in rendered
|
||||
assert "> Quoted context" in rendered
|
||||
assert "Refine instruction: " in rendered
|
||||
|
||||
|
||||
def test_refine_draft_ends_with_localized_instruction_and_one_space_across_all_locales():
|
||||
values = _instruction_values(I18N)
|
||||
assert len(values) == 15
|
||||
for instruction in values:
|
||||
out = _run_js("refine_with_instruction", instruction)
|
||||
assert out["composerValue"].endswith(f"{instruction} ")
|
||||
|
||||
|
||||
def test_saved_prompt_wrapper_preserves_existing_output_shape_and_effects():
|
||||
out = _run_js("saved_prompt")
|
||||
assert out["composerValue"] == "Draft tail\n\nSaved prompt\n\n"
|
||||
assert out["selectionStart"] == len(out["composerValue"])
|
||||
assert out["selectionEnd"] == len(out["composerValue"])
|
||||
assert out["inputEvents"] == 1
|
||||
assert out["focused"] is True
|
||||
assert out["autoResizeCalls"] == 1
|
||||
assert out["pendingFiles"] == ["keep.bin"]
|
||||
assert out["sendCalls"] == 0
|
||||
|
||||
|
||||
def test_refine_click_transfers_native_caret_after_consuming_selection():
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except Exception: # pragma: no cover - dependency missing path
|
||||
pytest.skip("playwright is unavailable; run the selected-text browser test")
|
||||
|
||||
fixture = _run_js("browser_fixture")["helpers"]
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(
|
||||
headless=True,
|
||||
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
)
|
||||
page = browser.new_page()
|
||||
page.set_content(
|
||||
"<!doctype html><html><body>"
|
||||
'<div id="messages"><p id="transcript">Alpha Beta</p></div>'
|
||||
'<textarea id="msg"></textarea>'
|
||||
"</body></html>"
|
||||
)
|
||||
page.add_script_tag(
|
||||
content=(
|
||||
"let _selectedTextReplyBtn = null;"
|
||||
"let _selectedTextRefineBtn = null;"
|
||||
"let _selectedTextReplyGroup = null;"
|
||||
"let _selectedTextReplyText = '';"
|
||||
"let _selectedTextReplyRaf = 0;"
|
||||
"let _pendingSelections = [];"
|
||||
"let _selectionIdCounter = 0;"
|
||||
"let S = {pendingFiles:['keep.bin']};"
|
||||
+ fixture
|
||||
)
|
||||
)
|
||||
page.evaluate(
|
||||
"""
|
||||
window.$ = id => document.getElementById(id);
|
||||
window.t = key => ({
|
||||
selected_text_reply: 'Reply with selection',
|
||||
selected_text_reply_title: 'Reply',
|
||||
selected_text_refine: 'Refine',
|
||||
selected_text_refine_title: 'Refine',
|
||||
selected_text_refine_instruction: 'Refine instruction:',
|
||||
}[key] || key);
|
||||
window.autoResize = () => {};
|
||||
window.applyLocaleToDOM = () => {};
|
||||
const range = document.createRange();
|
||||
const text = document.getElementById('transcript').firstChild;
|
||||
range.setStart(text, 0);
|
||||
range.setEnd(text, text.length);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
const group = _selectedTextReplyButton().parentElement;
|
||||
group.classList.add('visible');
|
||||
"""
|
||||
)
|
||||
refine = page.locator("#selectedTextRefineBtn")
|
||||
refine.hover()
|
||||
page.mouse.down()
|
||||
selection_after_mousedown = page.evaluate("window.getSelection().toString()")
|
||||
page.mouse.up()
|
||||
immediate = page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const msg = document.getElementById('msg');
|
||||
return {
|
||||
draft: msg.value,
|
||||
activeElement: document.activeElement && document.activeElement.id,
|
||||
selectionStart: msg.selectionStart,
|
||||
selectionEnd: msg.selectionEnd,
|
||||
valueLength: msg.value.length,
|
||||
selection: window.getSelection().toString(),
|
||||
};
|
||||
}
|
||||
"""
|
||||
)
|
||||
next_frame = page.evaluate(
|
||||
"""
|
||||
() => new Promise(resolve => {
|
||||
requestAnimationFrame(() => {
|
||||
const msg = document.getElementById('msg');
|
||||
resolve({
|
||||
selectionStart: msg.selectionStart,
|
||||
selectionEnd: msg.selectionEnd,
|
||||
valueLength: msg.value.length,
|
||||
selection: window.getSelection().toString(),
|
||||
});
|
||||
});
|
||||
})
|
||||
"""
|
||||
)
|
||||
browser.close()
|
||||
|
||||
assert selection_after_mousedown == "Alpha Beta"
|
||||
assert immediate == {
|
||||
"draft": "> Alpha Beta\n\nRefine instruction: ",
|
||||
"activeElement": "msg",
|
||||
"selectionStart": len("> Alpha Beta\n\nRefine instruction: "),
|
||||
"selectionEnd": len("> Alpha Beta\n\nRefine instruction: "),
|
||||
"valueLength": len("> Alpha Beta\n\nRefine instruction: "),
|
||||
"selection": "",
|
||||
}
|
||||
assert next_frame == {
|
||||
"selectionStart": len("> Alpha Beta\n\nRefine instruction: "),
|
||||
"selectionEnd": len("> Alpha Beta\n\nRefine instruction: "),
|
||||
"valueLength": len("> Alpha Beta\n\nRefine instruction: "),
|
||||
"selection": "",
|
||||
}
|
||||
@@ -35,16 +35,22 @@ def test_selected_text_reply_button_is_selection_scoped_and_frontend_only():
|
||||
assert "root.contains(el)" in js
|
||||
assert "document.addEventListener('selectionchange', _updateSelectedTextReplyButton)" in js
|
||||
|
||||
assert "id='selectedTextReplyBtn'" in js
|
||||
assert "group.id='selectedTextActionGroup'" in js
|
||||
assert "btn.id='selectedTextReplyBtn'" in js
|
||||
assert "refine.id='selectedTextRefineBtn'" in js
|
||||
assert "selected-text-action-group" in js
|
||||
assert "selected-text-reply-btn" in js
|
||||
assert "selected-text-refine-btn" in js
|
||||
assert "data-i18n', 'selected_text_reply'" in js
|
||||
assert "data-i18n-title', 'selected_text_reply_title'" in js
|
||||
assert "data-i18n-aria-label', 'selected_text_reply_title'" in js
|
||||
assert "data-i18n-aria-label', 'selected_text_reply'" in js
|
||||
assert "data-i18n', 'selected_text_refine'" in js
|
||||
assert "data-i18n-title', 'selected_text_refine_title'" in js
|
||||
assert "data-i18n-aria-label', 'selected_text_refine'" in js
|
||||
|
||||
# MVP contract: selected text reply is entirely static/frontend; do not add
|
||||
# backend endpoints or change send payload routing.
|
||||
assert "/api/selected" not in js
|
||||
assert "selected_text" not in js.replace("selected_text_reply", "")
|
||||
|
||||
|
||||
def test_selected_text_reply_collects_named_context_blocks_without_dumping_into_composer():
|
||||
@@ -59,7 +65,8 @@ def test_selected_text_reply_collects_named_context_blocks_without_dumping_into_
|
||||
assert "function _renderSelectionChips()" in js
|
||||
assert "function _flushSelectionBlocksToComposer()" in js
|
||||
assert "_pendingSelections.push({id, name, text})" in js
|
||||
assert "_addNamedContextBlock(_selectedTextReplyText)" in js
|
||||
assert "const text=_consumeSelectedTextReplySelection();" in js
|
||||
assert "_addNamedContextBlock(text)" in js
|
||||
assert "**${s.name}:**\\n${_formatSelectedTextReplyQuote(s.text)}" in js
|
||||
assert "composer.dispatchEvent(new Event('input',{bubbles:true}))" in js
|
||||
assert "if(typeof autoResize==='function') autoResize()" in js
|
||||
@@ -91,8 +98,11 @@ def test_selected_text_reply_styles_and_i18n_exist_for_all_locales():
|
||||
css = read("static/style.css")
|
||||
i18n = read("static/i18n.js")
|
||||
|
||||
assert ".selected-text-reply-btn" in css
|
||||
assert ".selected-text-reply-btn.visible" in css
|
||||
assert ".selected-text-action-group" in css
|
||||
assert ".selected-text-action-group.visible" in css
|
||||
assert ".selected-text-action-group button" in css
|
||||
assert ".selected-text-reply-btn::before" in css
|
||||
assert ".selected-text-refine-btn::before" in css
|
||||
assert ".selection-context-card" in css
|
||||
assert ".selection-context-accent" in css
|
||||
assert ".selection-context-quote" in css
|
||||
@@ -117,9 +127,11 @@ def test_selected_text_reply_styles_and_i18n_exist_for_all_locales():
|
||||
assert "<!-- hermes-selected-context -->" in ui
|
||||
assert "only blocks carrying the internal marker get custom treatment" in ui
|
||||
assert "position:fixed" in css
|
||||
assert "visibility:hidden" in css
|
||||
assert "visibility:visible" in css
|
||||
assert "pointer-events:none" in css
|
||||
assert "pointer-events:auto" in css
|
||||
assert "border:2px solid var(--accent)" in css
|
||||
assert "border:1px solid var(--border2)" in css
|
||||
assert "background:var(--bg)" in css
|
||||
assert "color:var(--text)" in css
|
||||
assert "outline:2px solid var(--focus-ring)" in css
|
||||
@@ -131,6 +143,9 @@ def test_selected_text_reply_styles_and_i18n_exist_for_all_locales():
|
||||
"selected_text_reply",
|
||||
"selected_text_reply_title",
|
||||
"selected_text_reply_appended",
|
||||
"selected_text_refine",
|
||||
"selected_text_refine_title",
|
||||
"selected_text_refine_instruction",
|
||||
"context_block_rename_hint",
|
||||
"context_block_rename_aria",
|
||||
"context_block_remove",
|
||||
@@ -147,11 +162,25 @@ def test_selected_text_reply_button_has_user_select_none():
|
||||
# The base rule must carry user-select:none so browser selection never
|
||||
# renders on or through the button, regardless of hover background opacity.
|
||||
assert "user-select:none" in css
|
||||
# Confirm it lives inside the .selected-text-reply-btn rule, not elsewhere.
|
||||
# Confirm it lives on the floating action shell, not in an unrelated rule.
|
||||
rule_match = re.search(
|
||||
r'\.selected-text-reply-btn\{[^}]*user-select:none[^}]*\}', css
|
||||
r'\.selected-text-action-group\{[^}]*user-select:none[^}]*\}', css
|
||||
)
|
||||
assert rule_match, ".selected-text-reply-btn base rule must include user-select:none"
|
||||
assert rule_match, ".selected-text-action-group base rule must include user-select:none"
|
||||
|
||||
|
||||
def test_selected_text_reply_button_is_hidden_from_keyboard_navigation_until_visible():
|
||||
css = read("static/style.css")
|
||||
hidden_match = re.search(
|
||||
r'\.selected-text-action-group\{[^}]*visibility:hidden[^}]*transition:visibility 0s linear \.12s,opacity \.12s ease,transform \.12s ease[^}]*\}',
|
||||
css,
|
||||
)
|
||||
visible_match = re.search(
|
||||
r'\.selected-text-action-group\.visible\{[^}]*visibility:visible[^}]*transition-delay:0s[^}]*\}',
|
||||
css,
|
||||
)
|
||||
assert hidden_match, "hidden action group must stay out of the tab order until the fade completes"
|
||||
assert visible_match, "visible action group must restore visibility immediately when selection returns"
|
||||
|
||||
|
||||
def test_sent_selected_context_blocks_are_rendered_without_enabling_user_markdown():
|
||||
|
||||
Reference in new issue
Block a user