feat: render selected context as quote cards

This commit is contained in:
santastabber
2026-06-18 07:12:02 +10:00
parent b26c184bf5
commit 2eefc11c02
7 changed files with 377 additions and 46 deletions
+5 -1
View File
@@ -3,6 +3,10 @@
## [Unreleased]
### Changed
- **Selected chat text now appears as readable quote cards above the composer and as clean quoted blocks in sent messages.** The safer named-context behavior from #2543 is preserved — selections are still flushed as labeled Markdown quotes on send — but the pending context UI now shows a calm card with a left accent rail, editable label, remove action, and a clipped text excerpt, while the sent user bubble renders that generated context as a styled `figure`/`blockquote` instead of literal Markdown syntax.
## [v0.51.501] — 2026-06-18 — Release RK (Ctrl/Cmd+, opens Settings)
### Added
@@ -64,6 +68,7 @@
### Fixed
- **Reloading a session with a large model context window no longer snaps it back to the 256k fallback (#4248).** The session reload path now resolves context-window metadata with the same base URL / API-key lookup inputs used by streaming saves, and it refuses to overwrite a larger persisted context window with the generic 256k fallback. The reload model-identity check also normalizes slash-qualified model ids (`provider/model`, OpenRouter-style) so a slash-stored model resolving to its bare id is not mistaken for a model change. Thanks @franksong2702.
## [v0.51.489] — 2026-06-18 — Release QY (outline button no longer collides with the scroll control)
### Fixed
@@ -117,7 +122,6 @@
### Fixed
- **Smoother scrolling in the experimental "Virtualize long transcripts" mode — fewer jumps around tool-call rows and during measurement (#4346, via #4367 + #4368).** Two fixes to the (still default-off, opt-in) transcript virtualization: (1) per-role height estimates for not-yet-measured rows (user 120 / assistant 160 / tool_call 400 / default 140) so large tool-call rows aren't under-counted before they're measured, which previously threw off the initial scroll position; and (2) anchor-based scroll compensation during measurement — the view captures an anchor row + scrollTop, renders, then re-pins scrollTop by the anchor's measured delta, plus a short scroll-active guard that defers measurement refresh while you're actively scrolling. Both are additive and only run when "Virtualize long transcripts (experimental)" is enabled. Thanks @rodboev.
## [v0.51.482] — 2026-06-17 — Release QR (archived cron sessions stay hidden)
### Fixed
+39
View File
@@ -56,6 +56,9 @@ const LOCALES = {
saved_prompts_empty_input: 'Type a prompt to save first',
context_block_name_default: 'Context',
context_block_flush_aria: 'Context blocks will be included when you send',
context_block_rename_hint: 'Click or press Enter to rename',
context_block_rename_aria: 'Rename context block',
context_block_remove: 'Remove context block',
diff_loading: 'Loading diff',
diff_error: 'Could not load patch file',
@@ -1568,6 +1571,9 @@ const LOCALES = {
saved_prompts_empty_input: 'Digita un prompt da salvare prima',
context_block_name_default: 'Contesto',
context_block_flush_aria: 'I blocchi di contesto verranno inclusi quando invii',
context_block_rename_hint: 'Fai clic o premi Invio per rinominare',
context_block_rename_aria: 'Rinomina blocco di contesto',
context_block_remove: 'Rimuovi blocco di contesto',
diff_loading: 'Caricamento diff',
diff_error: 'Impossibile caricare il file patch',
@@ -3071,6 +3077,9 @@ const LOCALES = {
saved_prompts_empty_input: '保存するプロンプトを先に入力してください',
context_block_name_default: 'コンテキスト',
context_block_flush_aria: '送信時にコンテキストブロックが含まれます',
context_block_rename_hint: 'クリックまたは Enter で名前を変更',
context_block_rename_aria: 'コンテキストブロック名を変更',
context_block_remove: 'コンテキストブロックを削除',
diff_loading: '差分を読み込み中',
diff_error: 'パッチファイルを読み込めませんでした',
@@ -4576,6 +4585,9 @@ const LOCALES = {
saved_prompts_empty_input: 'Сначала введите подсказку для сохранения',
context_block_name_default: 'Контекст',
context_block_flush_aria: 'Блоки контекста будут включены при отправке',
context_block_rename_hint: 'Нажмите или Enter, чтобы переименовать',
context_block_rename_aria: 'Переименовать блок контекста',
context_block_remove: 'Удалить блок контекста',
diff_loading: 'Загрузка diff',
diff_error: 'Не удалось загрузить файл патча',
@@ -6011,6 +6023,9 @@ const LOCALES = {
saved_prompts_empty_input: 'Escribe una indicación para guardar primero',
context_block_name_default: 'Contexto',
context_block_flush_aria: 'Los bloques de contexto se incluirán al enviar',
context_block_rename_hint: 'Haz clic o pulsa Enter para renombrar',
context_block_rename_aria: 'Renombrar bloque de contexto',
context_block_remove: 'Eliminar bloque de contexto',
diff_loading: 'Cargando diff',
diff_error: 'No se pudo cargar el archivo de parche',
@@ -7450,6 +7465,9 @@ const LOCALES = {
saved_prompts_empty_input: 'Gib zuerst eine Eingabeaufforderung ein',
context_block_name_default: 'Kontext',
context_block_flush_aria: 'Kontextblöcke werden beim Senden eingefügt',
context_block_rename_hint: 'Klicken oder Enter drücken zum Umbenennen',
context_block_rename_aria: 'Kontextblock umbenennen',
context_block_remove: 'Kontextblock entfernen',
diff_loading: 'Lade Diff',
diff_error: 'Patch-Datei konnte nicht geladen werden',
@@ -8893,6 +8911,9 @@ const LOCALES = {
saved_prompts_empty_input: '请先输入要保存的提示',
context_block_name_default: '上下文',
context_block_flush_aria: '发送时将包含上下文块',
context_block_rename_hint: '点击或按 Enter 重命名',
context_block_rename_aria: '重命名上下文块',
context_block_remove: '移除上下文块',
diff_loading: '加载 diff',
diff_error: '无法加载 patch 文件',
@@ -10344,6 +10365,9 @@ const LOCALES = {
saved_prompts_empty_input: '請先輸入要儲存的提示',
context_block_name_default: '上下文',
context_block_flush_aria: '傳送時將包含上下文區塊',
context_block_rename_hint: '點擊或按 Enter 重新命名',
context_block_rename_aria: '重新命名上下文區塊',
context_block_remove: '移除上下文區塊',
diff_loading: '載入 diff',
diff_error: '無法載入 patch 檔案',
@@ -11836,6 +11860,9 @@ const LOCALES = {
saved_prompts_empty_input: 'Digite um prompt para salvar primeiro',
context_block_name_default: 'Contexto',
context_block_flush_aria: 'Os blocos de contexto serão incluídos ao enviar',
context_block_rename_hint: 'Clique ou pressione Enter para renomear',
context_block_rename_aria: 'Renomear bloco de contexto',
context_block_remove: 'Remover bloco de contexto',
you: 'Você',
thinking: 'Pensando',
expand_all: 'Expandir tudo',
@@ -13159,6 +13186,9 @@ const LOCALES = {
saved_prompts_empty_input: '먼저 저장할 프롬프트를 입력하세요',
context_block_name_default: '컨텍스트',
context_block_flush_aria: '전송 시 컨텍스트 블록이 포함됩니다',
context_block_rename_hint: '클릭하거나 Enter를 눌러 이름 변경',
context_block_rename_aria: '컨텍스트 블록 이름 변경',
context_block_remove: '컨텍스트 블록 제거',
diff_loading: 'diff 불러오는 중',
diff_error: '패치 파일을 로드할 수 없습니다',
@@ -14661,6 +14691,9 @@ const LOCALES = {
saved_prompts_empty_input: 'Tapez d\'abord une invite à sauvegarder',
context_block_name_default: 'Contexte',
context_block_flush_aria: 'Les blocs de contexte seront inclus lors de l\'envoi',
context_block_rename_hint: 'Cliquer ou appuyer sur Entrée pour renommer',
context_block_rename_aria: 'Renommer le bloc de contexte',
context_block_remove: 'Supprimer le bloc de contexte',
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',
@@ -16105,6 +16138,9 @@ const LOCALES = {
saved_prompts_empty_input: 'Önce kaydedilecek bir istem girin',
context_block_name_default: 'Bağlam',
context_block_flush_aria: 'Bağlam blokları gönderildiğinde dahil edilecek',
context_block_rename_hint: 'Yeniden adlandırmak için tıklayın veya Entera basın',
context_block_rename_aria: 'Bağlam bloğunu yeniden adlandır',
context_block_remove: 'Bağlam bloğunu kaldır',
diff_loading: 'Fark yükleniyor',
diff_error: 'Yama dosyası yüklenemedi',
@@ -17601,6 +17637,9 @@ const LOCALES = {
saved_prompts_empty_input: 'Najpierw wpisz prompt do zapisania',
context_block_name_default: 'Kontekst',
context_block_flush_aria: 'Bloki kontekstu zostaną dołączone podczas wysyłania',
context_block_rename_hint: 'Kliknij lub naciśnij Enter, aby zmienić nazwę',
context_block_rename_aria: 'Zmień nazwę bloku kontekstu',
context_block_remove: 'Usuń blok kontekstu',
diff_loading: 'Ładowanie zmian (diff)',
diff_error: 'Nie można załadować pliku poprawki',
+96 -32
View File
@@ -588,22 +588,7 @@ function _selectedTextReplySelection(){
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;
return `<!-- hermes-selected-context -->\n${normalized.split('\n').map(line=>`> ${line}`).join('\n')}`;
}
function insertSavedPromptIntoComposer(text){
@@ -733,19 +718,68 @@ function _clearPendingSelections(){
}
if(typeof window!=='undefined') window._clearPendingSelections=_clearPendingSelections;
function _selectedContextPreview(text){
const normalized=String(text||'').replace(/\r\n?/g,'\n').replace(/\n{3,}/g,'\n\n').trim();
if(!normalized)return '';
const max=360;
return normalized.length>max?normalized.slice(0,max).trimEnd()+'…':normalized;
}
function _renderSelectionChips(){
const wrap=document.getElementById('composerSelectionChips');
if(!wrap)return;
wrap.innerHTML='';
wrap.hidden=!_pendingSelections.length;
_pendingSelections.forEach(s=>{
const chip=document.createElement('span');
chip.className='chip selection-chip';
chip.dataset.selectionId=s.id;
chip.innerHTML=`<span class="selection-chip-name" title="${esc(s.text)}">${esc(s.name)}</span>`+
`<button type="button" class="selection-chip-remove" aria-label="Remove context block" onclick="_removeNamedContextBlock('${s.id}')">&#x2715;</button>`;
chip.addEventListener('dblclick',()=>_editSelectionChipName(s.id,chip));
wrap.appendChild(chip);
const card=document.createElement('article');
card.className='selection-context-card';
card.dataset.selectionId=s.id;
card.setAttribute('aria-label', s.name);
const accent=document.createElement('div');
accent.className='selection-context-accent';
accent.setAttribute('aria-hidden','true');
const body=document.createElement('div');
body.className='selection-context-body';
const header=document.createElement('div');
header.className='selection-context-header';
const name=document.createElement('button');
name.type='button';
name.className='selection-context-name selection-chip-name';
name.textContent=s.name;
name.title=_selectedTextReplyT('context_block_rename_hint','Click or press Enter to rename');
name.setAttribute('aria-label', `${_selectedTextReplyT('context_block_rename_aria','Rename context block')}: ${s.name}`);
name.addEventListener('click',()=>_editSelectionChipName(s.id,card));
name.addEventListener('dblclick',()=>_editSelectionChipName(s.id,card));
name.addEventListener('keydown',e=>{
if(e.key==='Enter'||e.key===' '||e.key==='F2'){
e.preventDefault();
_editSelectionChipName(s.id,card);
}
});
const remove=document.createElement('button');
remove.type='button';
remove.className='selection-context-remove selection-chip-remove';
remove.setAttribute('aria-label', `${_selectedTextReplyT('context_block_remove','Remove context block')}: ${s.name}`);
remove.innerHTML='&#x2715;';
remove.addEventListener('click',()=>_removeNamedContextBlock(s.id));
const quote=document.createElement('blockquote');
quote.className='selection-context-quote';
quote.textContent=_selectedContextPreview(s.text);
quote.title=String(s.text||'');
header.appendChild(name);
header.appendChild(remove);
body.appendChild(header);
body.appendChild(quote);
card.appendChild(accent);
card.appendChild(body);
wrap.appendChild(card);
});
}
@@ -753,24 +787,49 @@ function _editSelectionChipName(id,chip){
const s=_pendingSelections.find(x=>x.id===id);
if(!s)return;
const nameEl=chip.querySelector('.selection-chip-name');
if(!nameEl)return;
if(chip.querySelector('.selection-chip-edit'))return;
const inp=document.createElement('input');
inp.type='text';inp.value=s.name;inp.className='selection-chip-edit';
inp.maxLength=120;
inp.setAttribute('aria-label', `${_selectedTextReplyT('context_block_rename_aria','Rename context block')}: ${s.name}`);
inp.title=_selectedTextReplyT('context_block_rename_hint','Click or press Enter to rename');
nameEl.replaceWith(inp);
inp.focus();inp.select();
let done=false;
const commit=()=>{ if(done)return; done=true; s.name=inp.value.trim()||s.name; _renderSelectionChips(); };
const cancel=()=>{ if(done)return; done=true; _renderSelectionChips(); };
const restoreFocus=()=>{
window.requestAnimationFrame(()=>{
const safeId=window.CSS&&CSS.escape?CSS.escape(id):String(id).replace(/"/g,'\\"');
const next=document.querySelector(`[data-selection-id="${safeId}"] .selection-chip-name`);
if(next&&typeof next.focus==='function')next.focus({preventScroll:true});
});
};
const commit=()=>{ if(done)return; done=true; s.name=(inp.value.trim()||s.name).slice(0,120); _renderSelectionChips(); restoreFocus(); };
const cancel=()=>{ if(done)return; done=true; _renderSelectionChips(); restoreFocus(); };
inp.addEventListener('blur',commit);
inp.addEventListener('keydown',e=>{ if(e.key==='Enter'){e.preventDefault();commit();} if(e.key==='Escape'){cancel();} });
}
function _composerTextWithPendingSelections(){
const composer=(typeof $==='function'&&$('msg'))||document.getElementById('msg');
const current=String(composer&&composer.value||'');
if(!_pendingSelections.length)return current;
const blocks=_pendingSelections.map(s=>`**${s.name}:**\n${_formatSelectedTextReplyQuote(s.text)}`).join('\n\n');
return current.trim()?`${current.replace(/\s+$/,'')}\n\n${blocks}\n\n`:`${blocks}\n\n`;
}
function _clearComposerAfterQueuedSelectionSend(){
const composer=(typeof $==='function'&&$('msg'))||document.getElementById('msg');
if(composer)composer.value='';
_clearPendingSelections();
if(typeof autoResize==='function') autoResize();
}
function _flushSelectionBlocksToComposer(){
if(!_pendingSelections.length)return;
const composer=(typeof $==='function'&&$('msg'))||document.getElementById('msg');
if(!composer)return;
const blocks=_pendingSelections.map(s=>`**${s.name}:**\n${_formatSelectedTextReplyQuote(s.text)}`).join('\n\n');
const current=String(composer.value||'');
composer.value=current.trim()?`${current.replace(/\s+$/,'')}\n\n${blocks}\n\n`:`${blocks}\n\n`;
composer.value=_composerTextWithPendingSelections();
_clearPendingSelections();
composer.focus();
try{ composer.setSelectionRange(composer.value.length, composer.value.length); }catch(_e){}
@@ -940,19 +999,21 @@ function applySessionTitleUpdate(sid, titleText, options={}){
}
async function send(){
_flushSelectionBlocksToComposer();
// Static guards expect _busyInputMode to stay near send() while the actual
// read remains in the S.busy branch below.
// _busyInputMode
// Reject concurrent invocations early — before any await yields control.
// If a send is already in-flight (e.g. queue drain), re-queue the message
// instead of silently dropping it.
if (_sendInProgress) {
const _text=$('msg').value.trim();
const _text=_composerTextWithPendingSelections().trim();
// Use the in-flight session's sid, not the currently viewed session,
// so the queued message goes to the chat that owns the active stream.
const _targetSid=_sendInProgressSid||(S.session&&S.session.session_id);
if(_text && _targetSid){
const _modelState=_chatPayloadModelState();
queueSessionMessage(_targetSid,{text:_text,files:[...S.pendingFiles],model:_modelState.model,model_provider:_modelState.model_provider,profile:S.activeProfile||'default'});
$('msg').value='';autoResize();
_clearComposerAfterQueuedSelectionSend();
S.pendingFiles=[];renderTray();
updateQueueBadge(_targetSid);
showToast(`Queued: "${_text.slice(0,40)}${_text.length>40?'…':''}"`,2000);
@@ -962,9 +1023,12 @@ async function send(){
_sendInProgress = true;
try{
let text=$('msg').value.trim();
if(!text&&!S.pendingFiles.length){_sendInProgress=false;_sendInProgressSid=null;return;}
if(!text&&!S.pendingFiles.length&&!_pendingSelections.length){_sendInProgress=false;_sendInProgressSid=null;return;}
// Don't send while an inline message edit is active
if(document.querySelector('.msg-edit-area')){_sendInProgress=false;_sendInProgressSid=null;return;}
_flushSelectionBlocksToComposer();
text=$('msg').value.trim();
if(!text&&!S.pendingFiles.length){_sendInProgress=false;_sendInProgressSid=null;return;}
// Dismiss handoff hint when user sends a message (resets seen_at).
if(S.session&&S.session.session_id&&typeof _dismissHandoffHint==='function'){
+19 -6
View File
@@ -855,13 +855,26 @@
:root:not(.dark) .project-chip.active{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg-strong);}
:root:not(.dark) .chip{border-color:rgba(0,0,0,.1);background:rgba(0,0,0,.04);}
:root:not(.dark) .chip.model{color:var(--accent-text);border-color:var(--accent-bg-strong);background:var(--accent-bg);}
.composer-selection-chips{display:flex;flex-wrap:wrap;gap:6px;padding:6px 12px 0;min-height:0;}
.composer-selection-chips{display:flex;flex-direction:column;gap:8px;max-width:clamp(780px,60vw,1100px);width:100%;box-sizing:border-box;margin:0 auto;padding:8px 0 0;min-height:0;max-height:min(32vh,280px);overflow-y:auto;scrollbar-gutter:stable;}
.composer-selection-chips[hidden]{display:none;}
.selection-chip{display:inline-flex;align-items:center;gap:4px;padding:2px 6px 2px 8px;border-radius:99px;font-size:11px;cursor:default;border:1px solid var(--border);background:var(--surface-subtle);color:var(--text);}
.selection-chip-name{max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.selection-chip-remove{background:none;border:none;padding:0 2px;cursor:pointer;color:var(--muted);font-size:10px;line-height:1;}
.selection-chip-remove:hover{color:var(--text);}
.selection-chip-edit{font-size:11px;border:none;outline:1px solid var(--accent);border-radius:3px;padding:0 2px;background:var(--surface);color:var(--text);width:100px;}
.selection-context-card{display:flex;gap:10px;align-items:stretch;border:1px solid var(--border2);border-radius:12px;background:linear-gradient(135deg,var(--surface-subtle),rgba(255,255,255,.015));box-shadow:0 1px 0 rgba(255,255,255,.03) inset;color:var(--text);overflow:hidden;}
.selection-context-accent{width:3px;flex:0 0 3px;background:var(--accent);opacity:.82;}
.selection-context-body{min-width:0;flex:1;padding:9px 10px 9px 0;}
.selection-context-header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:5px;}
.selection-context-name{appearance:none;border:0;background:transparent;color:var(--accent-text);font-size:11px;font-weight:700;line-height:1.2;padding:3px 4px;min-height:24px;max-width:min(260px,65vw);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;text-align:left;}
.selection-context-name:hover{text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:2px;}
.selection-context-name:focus-visible{outline:2px solid var(--focus-ring);outline-offset:2px;border-radius:4px;}
.selection-context-remove{display:inline-flex;align-items:center;justify-content:center;min-width:28px;min-height:28px;background:transparent;border:1px solid transparent;border-radius:999px;color:var(--muted);cursor:pointer;font-size:12px;line-height:1;padding:2px 5px;flex:0 0 auto;}
.selection-context-remove:hover{color:var(--text);background:var(--hover-bg);border-color:var(--border);}
.selection-context-remove:focus-visible{outline:2px solid var(--focus-ring);outline-offset:2px;}
.selection-context-quote{margin:0;color:var(--muted);font-family:var(--font-ui);font-size:12.5px;line-height:1.45;white-space:pre-wrap;overflow:hidden;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;}
.selection-chip-edit{font-size:11px;border:1px solid var(--accent);outline:none;border-radius:6px;padding:2px 6px;background:var(--surface);color:var(--text);width:130px;max-width:70vw;}
.selection-chip-edit:focus{box-shadow:0 0 0 2px var(--focus-ring);}
@media (min-width:1600px){.composer-selection-chips{max-width:1600px;}}
@media (hover:none) and (pointer:coarse){.composer-selection-chips{max-height:min(28dvh,220px);}.selection-context-name,.selection-context-remove{min-height:44px;}.selection-context-remove{min-width:44px;}}
.sent-selection-context{margin:10px 0 0;padding:10px 12px;border:1px solid color-mix(in srgb,var(--user-bubble-border) 72%,var(--accent) 28%);border-left:3px solid var(--accent);border-radius:10px;background:color-mix(in srgb,var(--user-bubble-bg) 86%,var(--accent-bg) 14%);}
.sent-selection-context-label{margin-bottom:5px;color:var(--accent-text);font-size:11px;font-weight:750;line-height:1.25;letter-spacing:.01em;}
.sent-selection-context-quote{margin:0;color:var(--user-bubble-text);font-size:.94em;line-height:1.5;white-space:pre-wrap;opacity:.88;}
:root:not(.dark) .new-chat-btn{border-color:var(--accent-bg-strong);color:var(--accent-text);}
:root:not(.dark) .new-chat-btn:hover{background:var(--accent-bg);}
:root:not(.dark) .sidebar-search input{border-color:rgba(0,0,0,.1);background:rgba(0,0,0,.03);}
+34 -1
View File
@@ -297,8 +297,36 @@ function _stripWorkspaceDisplayPrefix(text){
}
function _renderUserFencedBlocks(text){
const stash=[];
const contextStash=[];
const mathStash=[];
const stashMath=(type,src)=>{mathStash.push({type,src});return '\x00UM'+(mathStash.length-1)+'\x00';};
const sentContextHtml=(label,quoteText)=>{
const safeLabel=String(label||'').trim()||'Context';
const safeQuote=String(quoteText||'').replace(/\s+$/,'');
return `<figure class="sent-selection-context" data-selected-context="1"><figcaption class="sent-selection-context-label">${esc(safeLabel)}</figcaption><blockquote class="sent-selection-context-quote">${esc(safeQuote)}</blockquote></figure>`;
};
const stashContext=(label,quote)=>{contextStash.push(sentContextHtml(label,quote));return '\x00UC'+(contextStash.length-1)+'\x00';};
const stashSelectedContextBlocks=(value)=>{
const lines=String(value||'').split('\n');
const marker='<!-- hermes-selected-context -->';
const out=[];
for(let i=0;i<lines.length;i++){
const labelMatch=lines[i].match(/^\*\*([^\n]{1,200}):\*\*\s*$/);
if(!labelMatch){out.push(lines[i]);continue;}
const quoteLines=[];
let j=i+1;
if(lines[j]!==marker){out.push(lines[i]);continue;}
j++;
while(j<lines.length&&/^>/.test(lines[j])){
quoteLines.push(lines[j].replace(/^>[ \t]?/,''));
j++;
}
if(!quoteLines.length){out.push(lines[i]);continue;}
out.push(stashContext(labelMatch[1], quoteLines.join('\n')));
i=j-1;
}
return out.join('\n');
};
const restoreMath=html=>String(html||'').replace(/\x00UM(\d+)\x00/g,(_,i)=>{
const item=mathStash[+i];
if(!item) return '';
@@ -340,10 +368,15 @@ function _renderUserFencedBlocks(text){
s=s.replace(/\\\[([\s\S]+?)\\\]/g,(_,m)=>stashMath('display',m));
s=s.replace(/\$([^\s$\n][^$\n]*?[^\s$\n]|\S)\$/g,(_,m)=>stashMath('inline',m));
s=s.replace(/\\\((.+?)\\\)/g,(_,m)=>stashMath('inline',m));
// Render selected-context payloads produced by Reply with selection as calm
// quote cards in the sent user bubble. Keep ordinary user Markdown escaped;
// only blocks carrying the internal marker get custom treatment.
s=stashSelectedContextBlocks(s);
// Escape remaining plain text and convert newlines to <br>
s=esc(s).replace(/\n/g,'<br>');
// Restore stashed code blocks, then math placeholders as KaTeX targets.
// Restore stashed code/context blocks, then math placeholders as KaTeX targets.
s=s.replace(/\x00UF(\d+)\x00/g,(_,i)=>stash[+i]);
s=s.replace(/\x00UC(\d+)\x00/g,(_,i)=>contextStash[+i]||'');
s=restoreMath(s);
return s;
}
+82 -6
View File
@@ -47,7 +47,7 @@ def test_selected_text_reply_button_is_selection_scoped_and_frontend_only():
assert "selected_text" not in js.replace("selected_text_reply", "")
def test_selected_text_reply_appends_blockquote_and_preserves_draft_flow():
def test_selected_text_reply_collects_named_context_blocks_without_dumping_into_composer():
js = read("static/messages.js")
assert "function _formatSelectedTextReplyQuote" in js
@@ -55,20 +55,67 @@ def test_selected_text_reply_appends_blockquote_and_preserves_draft_flow():
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 "function _addNamedContextBlock(text)" in js
assert "function _renderSelectionChips()" in js
assert "function _flushSelectionBlocksToComposer()" in js
assert "_pendingSelections.push({id, name, text})" in js
assert "_addNamedContextBlock(_selectedTextReplyText)" 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
def test_selected_text_reply_context_cards_are_built_with_text_nodes():
js = read("static/messages.js")
assert "function _selectedContextPreview(text)" in js
assert "card.className='selection-context-card'" in js
assert "quote.className='selection-context-quote'" in js
assert "name.textContent=s.name" in js
assert "quote.textContent=_selectedContextPreview(s.text)" in js
assert "quote.title=String(s.text||'')" in js
assert "remove.addEventListener('click',()=>_removeNamedContextBlock(s.id))" in js
assert "name.addEventListener('click',()=>_editSelectionChipName(s.id,card))" in js
assert "e.key==='Enter'||e.key===' '||e.key==='F2'" in js
assert "context_block_rename_aria" in js
assert "context_block_remove" in js
assert "${_selectedTextReplyT('context_block_remove','Remove context block')}: ${s.name}" in js
assert "quote.title=_selectedContextPreview(s.text)" not in js
assert "inp.setAttribute('aria-label'" in js
assert "restoreFocus" in js
assert "focus({preventScroll:true})" in js
assert "innerHTML=`<span class=\"selection-chip-name\"" not 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 ".selection-context-card" in css
assert ".selection-context-accent" in css
assert ".selection-context-quote" in css
assert "-webkit-line-clamp:3" in css
assert "white-space:pre-wrap" in css
assert "max-width:clamp(780px,60vw,1100px)" in css
assert "margin:0 auto" in css
assert "max-height:min(32vh,280px)" in css
assert "overflow-y:auto" in css
assert "scrollbar-gutter:stable" in css
assert "@media (min-width:1600px){.composer-selection-chips{max-width:1600px;}}" in css
assert "min-width:28px" in css
assert "min-height:28px" in css
assert "min-width:44px" in css
assert "min-height:44px" in css
assert ".sent-selection-context" in css
assert ".sent-selection-context-label" in css
assert ".sent-selection-context-quote" in css
ui = read("static/ui.js")
assert "data-selected-context" in ui
assert "const stashSelectedContextBlocks=(value)=>" in ui
assert "<!-- hermes-selected-context -->" in ui
assert "only blocks carrying the internal marker get custom treatment" in ui
assert "position:fixed" in css
assert "pointer-events:none" in css
assert "pointer-events:auto" in css
@@ -84,6 +131,9 @@ def test_selected_text_reply_styles_and_i18n_exist_for_all_locales():
"selected_text_reply",
"selected_text_reply_title",
"selected_text_reply_appended",
"context_block_rename_hint",
"context_block_rename_aria",
"context_block_remove",
}
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
for locale, block in blocks.items():
@@ -102,3 +152,29 @@ def test_selected_text_reply_button_has_user_select_none():
r'\.selected-text-reply-btn\{[^}]*user-select:none[^}]*\}', css
)
assert rule_match, ".selected-text-reply-btn base rule must include user-select:none"
def test_sent_selected_context_blocks_are_rendered_without_enabling_user_markdown():
ui = read("static/ui.js")
assert "const sentContextHtml=(label,quoteText)=>" in ui
assert "const stashSelectedContextBlocks=(value)=>" in ui
assert "<figure class=\"sent-selection-context\" data-selected-context=\"1\">" in ui
assert "<figcaption class=\"sent-selection-context-label\">" in ui
assert "<blockquote class=\"sent-selection-context-quote\">" in ui
assert "${esc(safeLabel)}" in ui
assert "${esc(safeQuote)}" in ui
assert "s=esc(s).replace(/\\n/g,'<br>')" in ui
assert "s=s.replace(/\\x00UC(\\d+)\\x00/g" in ui
def test_selected_text_reply_queue_path_includes_pending_selection_context():
js = read("static/messages.js")
assert "function _composerTextWithPendingSelections()" in js
assert "function _clearComposerAfterQueuedSelectionSend()" in js
assert "const _text=_composerTextWithPendingSelections().trim();" in js
assert "_clearComposerAfterQueuedSelectionSend();" in js
assert "if(!text&&!S.pendingFiles.length&&!_pendingSelections.length)" in js
assert "_flushSelectionBlocksToComposer();\n text=$('msg').value.trim();" in js
@@ -0,0 +1,102 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _run_user_renderer(input_text: str) -> str:
script = r'''
const input = JSON.parse(process.argv[1]);
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
function _sentSelectionContextBlockHtml(label, quoteText){
const safeLabel=String(label||'').trim()||'Context';
const safeQuote=String(quoteText||'').replace(/\s+$/,'');
return `<figure class="sent-selection-context" data-selected-context="1"><figcaption class="sent-selection-context-label">${esc(safeLabel)}</figcaption><blockquote class="sent-selection-context-quote">${esc(safeQuote)}</blockquote></figure>`;
}
function _stashUserSelectedContextBlocks(text, stashContext){
const lines=String(text||'').split('\n');
const marker='<!-- hermes-selected-context -->';
const out=[];
for(let i=0;i<lines.length;i++){
const labelMatch=lines[i].match(/^\*\*([^\n]{1,200}):\*\*\s*$/);
if(!labelMatch){out.push(lines[i]);continue;}
const quoteLines=[];
let j=i+1;
if(lines[j]!==marker){out.push(lines[i]);continue;}
j++;
while(j<lines.length&&/^>/.test(lines[j])){
quoteLines.push(lines[j].replace(/^>[ \t]?/,''));
j++;
}
if(!quoteLines.length){out.push(lines[i]);continue;}
out.push(stashContext(labelMatch[1], quoteLines.join('\n')));
i=j-1;
}
return out.join('\n');
}
function _renderUserFencedBlocks(text){
const stash=[];
const contextStash=[];
const stashContext=(label,quote)=>{contextStash.push(_sentSelectionContextBlockHtml(label,quote));return '\x00UC'+(contextStash.length-1)+'\x00';};
let s=String(text||'');
s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g,(_,lead,_fence,info,code)=>{
stash.push(`<pre><code>${esc(code||'')}</code></pre>`);
return lead+'\x00UF'+(stash.length-1)+'\x00';
});
s=_stashUserSelectedContextBlocks(s, stashContext);
s=esc(s).replace(/\n/g,'<br>');
s=s.replace(/\x00UF(\d+)\x00/g,(_,i)=>stash[+i]);
s=s.replace(/\x00UC(\d+)\x00/g,(_,i)=>contextStash[+i]||'');
return s;
}
process.stdout.write(_renderUserFencedBlocks(input));
'''
result = subprocess.run(
["node", "-e", script, json.dumps(input_text)],
check=True,
cwd=ROOT,
text=True,
capture_output=True,
)
return result.stdout
def test_sent_selected_context_renders_as_semantic_card_and_escapes_content():
html = _run_user_renderer(
"Here is my reply.\n\n**<img src=x onerror=alert(1)>:**\n"
"<!-- hermes-selected-context -->\n> <script>alert(1)</script>\n> second line"
)
assert '<figure class="sent-selection-context" data-selected-context="1">' in html
assert '<figcaption class="sent-selection-context-label">&lt;img src=x onerror=alert(1)&gt;</figcaption>' in html
assert '<blockquote class="sent-selection-context-quote">&lt;script&gt;alert(1)&lt;/script&gt;\nsecond line</blockquote>' in html
assert "**<img" not in html
assert "&gt; &lt;script" not in html
assert "<script>" not in html
def test_sent_selected_context_parser_accepts_user_renamed_edge_labels():
html = _run_user_renderer("***Evidence: alpha:**\n<!-- hermes-selected-context -->\n> quoted")
assert 'class="sent-selection-context"' in html
assert '<figcaption class="sent-selection-context-label">*Evidence: alpha</figcaption>' in html
assert "***Evidence" not in html
def test_matching_shape_inside_user_code_fence_stays_code_not_context_card():
html = _run_user_renderer("```md\n**Evidence:**\n> keep literal\n```")
assert 'class="sent-selection-context"' not in html
assert '<pre><code>' in html
assert '**Evidence:**' in html
assert '&gt; keep literal' in html
def test_manual_labelled_blockquote_without_marker_stays_literal_user_text():
html = _run_user_renderer("**Manual note:**\n> manually typed quote")
assert 'class="sent-selection-context"' not in html
assert '**Manual note:**' in html
assert '&gt; manually typed quote' in html