mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
feat: render selected context as quote cards
This commit is contained in:
+5
-1
@@ -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
|
||||
|
||||
@@ -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 Enter’a 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
@@ -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}')">✕</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='✕';
|
||||
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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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"><img src=x onerror=alert(1)></figcaption>' in html
|
||||
assert '<blockquote class="sent-selection-context-quote"><script>alert(1)</script>\nsecond line</blockquote>' in html
|
||||
assert "**<img" not in html
|
||||
assert "> <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 '> 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 '> manually typed quote' in html
|
||||
Reference in New Issue
Block a user