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:
nesquena-hermesandrodboev committed 2026-08-11 02:19:06 +00:00
1 parent 728ec10e66
commit 8b873c55eb
6 files changed
+981 -34

No files matched your search

+2
View File
@@ -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)
+45
View File
@@ -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
View File
@@ -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
View File
@@ -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
+817
View File
@@ -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 "&lt;!-- hermes-selected-context --&gt;" not in rendered
assert "&gt; 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": "",
}
+39 -10
View File
@@ -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():