diff --git a/CHANGELOG.md b/CHANGELOG.md index a2884950..2dd504af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/static/i18n.js b/static/i18n.js index 50625ef5..e36f28e0 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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', diff --git a/static/messages.js b/static/messages.js index 02bfde2d..9b68157a 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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 `\n${normalized.split('\n').map(line=>`> ${line}`).join('\n')}`; + const quote=normalized.split('\n').map(line=>`> ${line}`).join('\n'); + return includeMarker?`\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=>{ diff --git a/static/style.css b/static/style.css index 9525d702..4ec0268a 100644 --- a/static/style.css +++ b/static/style.css @@ -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 diff --git a/tests/test_issue1255_refine_selection.py b/tests/test_issue1255_refine_selection.py new file mode 100644 index 00000000..2b01855e --- /dev/null +++ b/tests/test_issue1255_refine_selection.py @@ -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( + "" + '

Alpha Beta

' + '' + "" + ) + 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": "", + } diff --git a/tests/test_issue2481_selected_text_reply.py b/tests/test_issue2481_selected_text_reply.py index 41ce6de7..3cf2e492 100644 --- a/tests/test_issue2481_selected_text_reply.py +++ b/tests/test_issue2481_selected_text_reply.py @@ -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 "" 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():