Stage 381: PR #2485

This commit is contained in:
nesquena-hermes
2026-05-18 01:32:24 +00:00
4 changed files with 260 additions and 0 deletions
+33
View File
@@ -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',
+131
View File
@@ -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().
+4
View File
@@ -1847,6 +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: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{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
@@ -0,0 +1,92 @@
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-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 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
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",
"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}"