From 9646773487955894ee533bb079c78054953340b7 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Mon, 18 May 2026 07:13:14 +0800 Subject: [PATCH 1/2] Add selected text reply composer append --- static/i18n.js | 33 +++++ static/messages.js | 131 ++++++++++++++++++++ static/style.css | 3 + tests/test_issue2481_selected_text_reply.py | 82 ++++++++++++ 4 files changed, 249 insertions(+) create mode 100644 tests/test_issue2481_selected_text_reply.py diff --git a/static/i18n.js b/static/i18n.js index 084c6ce8..4715f4c2 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -44,6 +44,9 @@ const LOCALES = { copy: 'Copy', copied: 'Copied!', copy_failed: 'Copy failed', + selected_text_reply: 'Reply with selection', + selected_text_reply_title: 'Append selected chat text as quoted context', + selected_text_reply_appended: 'Selected text added to composer', diff_loading: 'Loading diff', diff_error: 'Could not load patch file', @@ -1252,6 +1255,9 @@ const LOCALES = { copy: 'Copia', copied: 'Copiato!', 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_reply_appended: 'Testo selezionato aggiunto al compositore', diff_loading: 'Caricamento diff', diff_error: 'Impossibile caricare il file patch', @@ -2452,6 +2458,9 @@ const LOCALES = { copy: 'コピー', copied: 'コピーしました!', copy_failed: 'コピー失敗', + selected_text_reply: '選択範囲で返信', + selected_text_reply_title: '選択したチャットテキストを引用コンテキストとして追加', + selected_text_reply_appended: '選択したテキストを入力欄に追加しました', diff_loading: '差分を読み込み中', diff_error: 'パッチファイルを読み込めませんでした', @@ -3654,6 +3663,9 @@ const LOCALES = { copy: 'Копировать', copied: 'Скопировано!', 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_reply_appended: 'Выделенный текст добавлен в поле ввода', diff_loading: 'Загрузка diff', diff_error: 'Не удалось загрузить файл патча', @@ -4788,6 +4800,9 @@ const LOCALES = { copy: 'Copiar', copied: '¡Copiado!', 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_reply_appended: 'Texto seleccionado añadido al compositor', diff_loading: 'Cargando diff', diff_error: 'No se pudo cargar el archivo de parche', @@ -5925,6 +5940,9 @@ const LOCALES = { copy: 'Kopieren', copied: 'Kopiert!', copy_failed: 'Kopieren fehlgeschlagen', + selected_text_reply: 'Mit Auswahl antworten', + selected_text_reply_title: 'Ausgewählten Chattext als Zitatkontext anhängen', + selected_text_reply_appended: 'Auswahl zum Composer hinzugefügt', diff_loading: 'Lade Diff', diff_error: 'Patch-Datei konnte nicht geladen werden', @@ -7066,6 +7084,9 @@ const LOCALES = { copy: '复制', copied: '已复制', copy_failed: '复制失败', + selected_text_reply: '用所选内容回复', + selected_text_reply_title: '将所选聊天文本作为引用上下文追加', + selected_text_reply_appended: '已将所选文本添加到输入框', diff_loading: '加载 diff', diff_error: '无法加载 patch 文件', @@ -8194,6 +8215,9 @@ const LOCALES = { copy: '\u8907\u88fd', copied: '\u5df2\u8907\u88fd', copy_failed: '\u8907\u88fd\u5931\u6557', + selected_text_reply: '用所選內容回覆', + selected_text_reply_title: '將所選聊天文字作為引用上下文附加', + selected_text_reply_appended: '已將所選文字加入輸入框', diff_loading: '載入 diff', diff_error: '無法載入 patch 檔案', @@ -9422,6 +9446,9 @@ const LOCALES = { copy: 'Copiar', copied: 'Copiado!', 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_reply_appended: 'Texto selecionado adicionado ao compositor', you: 'Você', thinking: 'Pensando', expand_all: 'Expandir tudo', @@ -10448,6 +10475,9 @@ const LOCALES = { copy: '복사', copied: '복사됨!', copy_failed: '복사 실패', + selected_text_reply: '선택 항목으로 답장', + selected_text_reply_title: '선택한 채팅 텍스트를 인용 컨텍스트로 추가', + selected_text_reply_appended: '선택한 텍스트가 입력창에 추가되었습니다', diff_loading: 'diff 불러오는 중', diff_error: '패치 파일을 로드할 수 없습니다', @@ -11646,6 +11676,9 @@ const LOCALES = { copy: 'Copie', copied: 'Copié!', 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_reply_appended: 'Texte sélectionné ajouté au compositeur', diff_loading: 'Chargement des différences', diff_error: 'Impossible de charger le fichier de correctif', diff_too_large: 'Fichier de correctif trop volumineux pour être affiché en ligne', diff --git a/static/messages.js b/static/messages.js index 0c77e89e..ae7c6113 100644 --- a/static/messages.js +++ b/static/messages.js @@ -52,6 +52,137 @@ const _msgEl=document.getElementById('msg'); if(_msgEl) _msgEl.addEventListener('focus', ()=>{ if('speechSynthesis' in window && speechSynthesis.speaking) speechSynthesis.pause(); }); if(_msgEl) _msgEl.addEventListener('blur', ()=>{ if('speechSynthesis' in window && speechSynthesis.paused) speechSynthesis.resume(); }); +let _selectedTextReplyBtn=null; +let _selectedTextReplyText=''; +let _selectedTextReplyRaf=0; + +function _selectedTextReplyT(key, fallback){ + try{ + const val=(typeof t==='function')?t(key):''; + return val&&val!==key?val:fallback; + }catch(_err){ + return fallback; + } +} + +function _selectedTextReplyRoot(){ + if(typeof $==='function') return $('messages')||$('msgInner'); + return document.getElementById('messages')||document.getElementById('msgInner'); +} + +function _selectedTextReplyNodeInChat(node, root){ + if(!node||!root)return false; + const el=node.nodeType===Node.ELEMENT_NODE?node:node.parentElement; + return !!(el&&root.contains(el)); +} + +function _selectedTextReplySelection(){ + if(!window.getSelection)return null; + const selection=window.getSelection(); + if(!selection||selection.isCollapsed||!selection.rangeCount)return null; + const root=_selectedTextReplyRoot(); + if(!root)return null; + const range=selection.getRangeAt(0); + if(!_selectedTextReplyNodeInChat(range.startContainer, root)||!_selectedTextReplyNodeInChat(range.endContainer, root))return null; + const text=selection.toString().replace(/\u00a0/g,' ').trim(); + if(!text)return null; + const rect=range.getBoundingClientRect(); + if(!rect||(!rect.width&&!rect.height))return null; + return {text, rect}; +} + +function _formatSelectedTextReplyQuote(text){ + const normalized=String(text||'').replace(/\r\n?/g,'\n').replace(/\n{3,}/g,'\n\n').trim(); + if(!normalized)return ''; + return normalized.split('\n').map(line=>`> ${line}`).join('\n'); +} + +function _appendSelectedTextReplyToComposer(text){ + const composer=(typeof $==='function'&&$('msg'))||document.getElementById('msg'); + if(!composer)return false; + const quote=_formatSelectedTextReplyQuote(text); + if(!quote)return false; + const current=String(composer.value||''); + composer.value=current.trim()?`${current.replace(/\s+$/,'')}\n\n${quote}\n\n`:`${quote}\n\n`; + composer.focus(); + try{ composer.setSelectionRange(composer.value.length, composer.value.length); }catch(_err){} + composer.dispatchEvent(new Event('input', {bubbles:true})); + if(typeof autoResize==='function') autoResize(); + if(typeof showToast==='function') showToast(_selectedTextReplyT('selected_text_reply_appended', 'Selected text added to composer'), 1600); + return true; +} + +function _selectedTextReplyButton(){ + if(_selectedTextReplyBtn)return _selectedTextReplyBtn; + 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.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.addEventListener('mousedown', e=>e.preventDefault()); + btn.addEventListener('click', e=>{ + e.preventDefault(); + if(_appendSelectedTextReplyToComposer(_selectedTextReplyText)){ + _hideSelectedTextReplyButton(); + const selection=window.getSelection&&window.getSelection(); + if(selection&&selection.removeAllRanges)selection.removeAllRanges(); + } + }); + document.body.appendChild(btn); + if(typeof applyLocaleToDOM==='function') applyLocaleToDOM(); + _selectedTextReplyBtn=btn; + return btn; +} + +function _hideSelectedTextReplyButton(){ + _selectedTextReplyText=''; + if(_selectedTextReplyBtn)_selectedTextReplyBtn.classList.remove('visible'); +} + +function _positionSelectedTextReplyButton(info){ + const btn=_selectedTextReplyButton(); + _selectedTextReplyText=info.text; + btn.classList.add('visible'); + const gap=8; + const btnRect=btn.getBoundingClientRect(); + const width=btnRect.width||150; + const height=btnRect.height||32; + 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`; +} + +function _updateSelectedTextReplyButton(){ + if(_selectedTextReplyRaf)return; + _selectedTextReplyRaf=window.requestAnimationFrame(()=>{ + _selectedTextReplyRaf=0; + const info=_selectedTextReplySelection(); + if(!info){ + _hideSelectedTextReplyButton(); + return; + } + _positionSelectedTextReplyButton(info); + }); +} + +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; + _updateSelectedTextReplyButton(); + }); + document.addEventListener('keyup', e=>{ + if(e.key&&/Arrow|Shift|Control|Meta|Alt/.test(e.key))_updateSelectedTextReplyButton(); + }); + window.addEventListener('resize', _hideSelectedTextReplyButton); +} + // Guard against concurrent send() calls. Without this, two rapid sends // (e.g. queue drain + user click) can both pass the S.busy check because // setBusy(true) is only called after the first await inside send(). diff --git a/static/style.css b/static/style.css index ce1c85aa..c1d685c2 100644 --- a/static/style.css +++ b/static/style.css @@ -1847,6 +1847,9 @@ .msg-row:hover .msg-actions{opacity:1;} .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:1px solid var(--accent-bg-strong);border-radius:999px;background:var(--surface);color:var(--accent-text);box-shadow:0 8px 24px rgba(0,0,0,.22);font-size:12px;font-weight:600;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;} +.selected-text-reply-btn.visible{opacity:1;pointer-events:auto;transform:translateY(0);} +.selected-text-reply-btn:hover,.selected-text-reply-btn:focus-visible{background:var(--accent-bg);border-color:var(--accent);outline:none;} /* 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_issue2481_selected_text_reply.py b/tests/test_issue2481_selected_text_reply.py new file mode 100644 index 00000000..47924cb7 --- /dev/null +++ b/tests/test_issue2481_selected_text_reply.py @@ -0,0 +1,82 @@ +from pathlib import Path +import re + + +REPO = Path(__file__).resolve().parent.parent + + +def read(rel: str) -> str: + return (REPO / rel).read_text(encoding="utf-8") + + +def _locale_blocks(src: str) -> dict[str, str]: + matches = list(re.finditer(r"\n ([a-z]{2}(?:-[A-Z]{2})?): \{", 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(1)] = src[start:end] + return blocks + + +def test_selected_text_reply_button_is_selection_scoped_and_frontend_only(): + js = read("static/messages.js") + + assert "window.getSelection" in js + assert "selection.isCollapsed" in js + assert "range.getBoundingClientRect" in js + assert "_selectedTextReplyRoot" in js + assert "$('messages')||$('msgInner')" in js + assert "root.contains(el)" in js + assert "document.addEventListener('selectionchange', _updateSelectedTextReplyButton)" in js + + assert "id='selectedTextReplyBtn'" in js + assert "selected-text-reply-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 + + # 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_appends_blockquote_and_preserves_draft_flow(): + js = read("static/messages.js") + + assert "function _formatSelectedTextReplyQuote" in js + assert "replace(/\\r\\n?/g,'\\n')" in js + assert "replace(/\\n{3,}/g,'\\n\\n')" in js + assert "map(line=>`> ${line}`).join('\\n')" in js + + assert "function _appendSelectedTextReplyToComposer" in js + assert "$('msg')" in js + assert "current.trim()?" in js + assert "${quote}\\n\\n" in js + assert "composer.dispatchEvent(new Event('input', {bubbles:true}))" in js + assert "if(typeof autoResize==='function') autoResize()" in js + + +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 "position:fixed" in css + assert "pointer-events:none" in css + assert "pointer-events:auto" in css + + blocks = _locale_blocks(i18n) + assert blocks, "No locale blocks found" + required = { + "selected_text_reply", + "selected_text_reply_title", + "selected_text_reply_appended", + } + key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE) + for locale, block in blocks.items(): + keys = set(key_pattern.findall(block)) + missing = sorted(required - keys) + assert not missing, f"{locale} missing selected-text reply keys: {missing}" From 8daf716307cd49b8549cd41d921ec3e818cd202e Mon Sep 17 00:00:00 2001 From: Frank Song Date: Mon, 18 May 2026 07:26:19 +0800 Subject: [PATCH 2/2] Repair selected text reply review blockers --- static/style.css | 5 +++-- tests/test_issue2481_selected_text_reply.py | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/static/style.css b/static/style.css index c1d685c2..7a594b90 100644 --- a/static/style.css +++ b/static/style.css @@ -1847,9 +1847,10 @@ .msg-row:hover .msg-actions{opacity:1;} .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:1px solid var(--accent-bg-strong);border-radius:999px;background:var(--surface);color:var(--accent-text);box-shadow:0 8px 24px rgba(0,0,0,.22);font-size:12px;font-weight:600;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;} +.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;} .selected-text-reply-btn.visible{opacity:1;pointer-events:auto;transform:translateY(0);} -.selected-text-reply-btn:hover,.selected-text-reply-btn:focus-visible{background:var(--accent-bg);border-color:var(--accent);outline:none;} +.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;} /* 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_issue2481_selected_text_reply.py b/tests/test_issue2481_selected_text_reply.py index 47924cb7..ee7b273b 100644 --- a/tests/test_issue2481_selected_text_reply.py +++ b/tests/test_issue2481_selected_text_reply.py @@ -10,12 +10,17 @@ def read(rel: str) -> str: def _locale_blocks(src: str) -> dict[str, str]: - matches = list(re.finditer(r"\n ([a-z]{2}(?:-[A-Z]{2})?): \{", src)) + 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(1)] = src[start:end] + blocks[match.group(2) or match.group(3)] = src[start:end] return blocks @@ -67,9 +72,14 @@ def test_selected_text_reply_styles_and_i18n_exist_for_all_locales(): assert "position:fixed" in css assert "pointer-events:none" in css assert "pointer-events:auto" in css + assert "border:2px solid var(--accent)" in css + assert "background:var(--bg)" in css + assert "color:var(--text)" in css + assert "outline:2px solid var(--focus-ring)" in css blocks = _locale_blocks(i18n) assert blocks, "No locale blocks found" + assert "zh-Hant" in blocks, "Locale parser must include quoted script locales" required = { "selected_text_reply", "selected_text_reply_title",