diff --git a/CHANGELOG.md b/CHANGELOG.md index 8feaf0a8a..40fb88fa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/static/i18n.js b/static/i18n.js index d646c6cca..71b483070 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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', diff --git a/static/messages.js b/static/messages.js index b5b7a7de3..594ac9e89 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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 `\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=`${esc(s.name)}`+ - ``; - 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'){ diff --git a/static/style.css b/static/style.css index 5de390771..9ca310445 100644 --- a/static/style.css +++ b/static/style.css @@ -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);} diff --git a/static/ui.js b/static/ui.js index 1b0f00ed2..10b1995e8 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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 `
${esc(safeLabel)}
${esc(safeQuote)}
`; + }; + 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=''; + const out=[]; + for(let i=0;i/.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
s=esc(s).replace(/\n/g,'
'); - // 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; } diff --git a/tests/test_issue2481_selected_text_reply.py b/tests/test_issue2481_selected_text_reply.py index 45133fc22..c1ad2ccc0 100644 --- a/tests/test_issue2481_selected_text_reply.py +++ b/tests/test_issue2481_selected_text_reply.py @@ -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=`" in ui + assert "" 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 "
" in ui + assert "
" in ui + assert "
" in ui + assert "${esc(safeLabel)}" in ui + assert "${esc(safeQuote)}" in ui + assert "s=esc(s).replace(/\\n/g,'
')" 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 diff --git a/tests/test_selected_context_user_render_runtime.py b/tests/test_selected_context_user_render_runtime.py new file mode 100644 index 000000000..5e0ce386c --- /dev/null +++ b/tests/test_selected_context_user_render_runtime.py @@ -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 `
${esc(safeLabel)}
${esc(safeQuote)}
`; +} +function _stashUserSelectedContextBlocks(text, stashContext){ + const lines=String(text||'').split('\n'); + const marker=''; + const out=[]; + for(let i=0;i/.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(`
${esc(code||'')}
`); + return lead+'\x00UF'+(stash.length-1)+'\x00'; + }); + s=_stashUserSelectedContextBlocks(s, stashContext); + s=esc(s).replace(/\n/g,'
'); + 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**:**\n" + "\n> \n> second line" + ) + + assert '
' in html + assert '
<img src=x onerror=alert(1)>
' in html + assert '
<script>alert(1)</script>\nsecond line
' in html + assert "**" not in html + + +def test_sent_selected_context_parser_accepts_user_renamed_edge_labels(): + html = _run_user_renderer("***Evidence: alpha:**\n\n> quoted") + + assert 'class="sent-selection-context"' in html + assert '
*Evidence: alpha
' 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 '
' 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