* feat(composer): add saved prompts library with per-profile storage (#2732) * fix(composer): move saved-prompts popup out of .composer-left to preserve DOM test (#2732) * fix(composer): correct ARIA roles, add server-side prompt limits (#2732) * fix(composer): surface save-prompt errors instead of silent success toast (#2732) * Release v0.51.338 — Release LB (saved prompts library, #3571) Composer saved-prompts library (@rodboev): bookmark button → popup of saved prompts; click to insert, save current input, delete. Persists to $HERMES_HOME/webui/saved_prompts.json with server-side caps (8000 chars / 200). Maintainer work (per Nathan): conditions were (a) verify it actually works and (b) hide on mobile. Both met: - Live-verified load/save/delete all persist through the UI. - Added mobile-hide (#btnSavedPrompts,.saved-prompts-popup display:none in the @media max-width:640px + 900px composer blocks). DOM-verified visible at 1280px, vision-confirmed absent from the composer at 390px. - Added missing Polish (pl) i18n for the 5 saved_prompts_* keys (PR had en+others but not pl — failed locale-parity). - Added tests/test_issue3571_saved_prompts.py (mobile-hide + caps + wiring guards). Full suite green, ESLint/scope-undef CLEAN, Opus SHIP-safe (auth-gated, CSRF, XSS-safe, sane caps), Codex SAFE-TO-SHIP. Co-authored-by: rodboev <rodboev@users.noreply.github.com> --------- Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
7 files changed
+320
No files matched your search
@@ -3,6 +3,11 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.338] — 2026-06-09 — Release LB (saved prompts library)
|
||||
|
||||
### Added
|
||||
- **A saved prompts library in the composer.** A bookmark button in the composer toolbar opens a popup of your saved prompts; click one to drop it into the composer, or save the current input as a new prompt, and delete prompts you no longer want. Prompts persist per Hermes home (`webui/saved_prompts.json`) with server-side caps (8000 chars/prompt, 200 prompts). The affordance is desktop-only — it's hidden on mobile to keep the narrow composer uncluttered. (#3571, @rodboev)
|
||||
|
||||
## [v0.51.337] — 2026-06-09 — Release LA (model-picker keyboard nav + mobile new-chat)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -5087,6 +5087,30 @@ def _serve_manifest(handler) -> bool:
|
||||
return j(handler, {"error": "not found"}, status=404)
|
||||
|
||||
|
||||
def _saved_prompts_path() -> "Path":
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
return Path(get_active_hermes_home()).expanduser() / "webui" / "saved_prompts.json"
|
||||
except Exception:
|
||||
return Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser() / "webui" / "saved_prompts.json"
|
||||
|
||||
|
||||
def _load_saved_prompts() -> list:
|
||||
p = _saved_prompts_path()
|
||||
if not p.exists():
|
||||
return []
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _save_saved_prompts(prompts: list) -> None:
|
||||
p = _saved_prompts_path()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(prompts, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def handle_get(handler, parsed) -> bool:
|
||||
"""Handle all GET routes. Returns True if handled, False for 404."""
|
||||
|
||||
@@ -5981,6 +6005,9 @@ def handle_get(handler, parsed) -> bool:
|
||||
"other_profile_count": len(all_projects) - len(scoped),
|
||||
})
|
||||
|
||||
if parsed.path == "/api/prompts":
|
||||
return j(handler, {"prompts": _load_saved_prompts()})
|
||||
|
||||
if parsed.path == "/api/session/export":
|
||||
return _handle_session_export(handler, parsed)
|
||||
|
||||
@@ -6716,6 +6743,21 @@ def handle_post(handler, parsed) -> bool:
|
||||
bad(handler, str(exc), status=500)
|
||||
return True
|
||||
|
||||
if parsed.path == "/api/prompts":
|
||||
text = str(body.get("text") or "").strip()
|
||||
label = str(body.get("label") or "").strip()
|
||||
if not text:
|
||||
return bad(handler, "text is required")
|
||||
if len(text) > 8000:
|
||||
return bad(handler, "text too long (max 8000 chars)")
|
||||
prompts = _load_saved_prompts()
|
||||
if len(prompts) >= 200:
|
||||
return bad(handler, "saved prompts limit reached (max 200)")
|
||||
new_prompt = {"id": uuid.uuid4().hex[:12], "label": label or text[:60], "text": text, "created_at": time.time()}
|
||||
prompts.append(new_prompt)
|
||||
_save_saved_prompts(prompts)
|
||||
return j(handler, {"ok": True, "prompt": new_prompt})
|
||||
|
||||
if parsed.path == "/api/session/new":
|
||||
try:
|
||||
workspace = str(resolve_trusted_workspace(body.get("workspace"))) if body.get("workspace") else None
|
||||
@@ -8581,6 +8623,14 @@ def handle_delete(handler, parsed) -> bool:
|
||||
if parsed.path.startswith("/api/mcp/servers/"):
|
||||
name = parsed.path[len("/api/mcp/servers/"):]
|
||||
return _handle_mcp_server_delete(handler, name)
|
||||
if parsed.path == "/api/prompts":
|
||||
pid = str(body.get("id") or "").strip()
|
||||
if not pid:
|
||||
return bad(handler, "id is required")
|
||||
prompts = [p for p in _load_saved_prompts() if p.get("id") != pid]
|
||||
_save_saved_prompts(prompts)
|
||||
return j(handler, {"ok": True})
|
||||
|
||||
if parsed.path.startswith("/api/kanban/"):
|
||||
from api.kanban_bridge import handle_kanban_delete
|
||||
|
||||
|
||||
@@ -48,6 +48,11 @@ const LOCALES = {
|
||||
selected_text_reply: 'Reply with selection',
|
||||
selected_text_reply_title: 'Append selected chat text as quoted context',
|
||||
selected_text_reply_appended: 'Selected text added to composer',
|
||||
saved_prompts_empty: 'No saved prompts yet.',
|
||||
saved_prompts_save_current: 'Save current input as prompt',
|
||||
saved_prompts_saved: 'Prompt saved',
|
||||
saved_prompts_delete: 'Delete prompt',
|
||||
saved_prompts_empty_input: 'Type a prompt to save first',
|
||||
|
||||
diff_loading: 'Loading diff',
|
||||
diff_error: 'Could not load patch file',
|
||||
@@ -1433,6 +1438,11 @@ const LOCALES = {
|
||||
selected_text_reply: 'Rispondi con selezione',
|
||||
selected_text_reply_title: 'Aggiungi il testo della chat selezionato come contesto citato',
|
||||
selected_text_reply_appended: 'Testo selezionato aggiunto al compositore',
|
||||
saved_prompts_empty: 'Nessun prompt salvato.',
|
||||
saved_prompts_save_current: 'Salva input corrente come prompt',
|
||||
saved_prompts_saved: 'Prompt salvato',
|
||||
saved_prompts_delete: 'Elimina prompt',
|
||||
saved_prompts_empty_input: 'Digita un prompt da salvare prima',
|
||||
|
||||
diff_loading: 'Caricamento diff',
|
||||
diff_error: 'Impossibile caricare il file patch',
|
||||
@@ -2810,6 +2820,11 @@ const LOCALES = {
|
||||
selected_text_reply: '選択範囲で返信',
|
||||
selected_text_reply_title: '選択したチャットテキストを引用コンテキストとして追加',
|
||||
selected_text_reply_appended: '選択したテキストを入力欄に追加しました',
|
||||
saved_prompts_empty: '保存済みプロンプトはありません。',
|
||||
saved_prompts_save_current: '現在の入力をプロンプトとして保存',
|
||||
saved_prompts_saved: 'プロンプトを保存しました',
|
||||
saved_prompts_delete: 'プロンプトを削除',
|
||||
saved_prompts_empty_input: '保存するプロンプトを先に入力してください',
|
||||
|
||||
diff_loading: '差分を読み込み中',
|
||||
diff_error: 'パッチファイルを読み込めませんでした',
|
||||
@@ -4189,6 +4204,11 @@ const LOCALES = {
|
||||
selected_text_reply: 'Ответить с выделенным',
|
||||
selected_text_reply_title: 'Добавить выделенный текст чата как цитируемый контекст',
|
||||
selected_text_reply_appended: 'Выделенный текст добавлен в поле ввода',
|
||||
saved_prompts_empty: 'Сохранённых подсказок пока нет.',
|
||||
saved_prompts_save_current: 'Сохранить текущий ввод как подсказку',
|
||||
saved_prompts_saved: 'Подсказка сохранена',
|
||||
saved_prompts_delete: 'Удалить подсказку',
|
||||
saved_prompts_empty_input: 'Сначала введите подсказку для сохранения',
|
||||
|
||||
diff_loading: 'Загрузка diff',
|
||||
diff_error: 'Не удалось загрузить файл патча',
|
||||
@@ -5498,6 +5518,11 @@ const LOCALES = {
|
||||
selected_text_reply: 'Responder con selección',
|
||||
selected_text_reply_title: 'Añadir el texto del chat seleccionado como contexto citado',
|
||||
selected_text_reply_appended: 'Texto seleccionado añadido al compositor',
|
||||
saved_prompts_empty: 'No hay indicaciones guardadas todavía.',
|
||||
saved_prompts_save_current: 'Guardar entrada actual como indicación',
|
||||
saved_prompts_saved: 'Indicación guardada',
|
||||
saved_prompts_delete: 'Eliminar indicación',
|
||||
saved_prompts_empty_input: 'Escribe una indicación para guardar primero',
|
||||
|
||||
diff_loading: 'Cargando diff',
|
||||
diff_error: 'No se pudo cargar el archivo de parche',
|
||||
@@ -6811,6 +6836,11 @@ const LOCALES = {
|
||||
selected_text_reply: 'Mit Auswahl antworten',
|
||||
selected_text_reply_title: 'Ausgewählten Chattext als Zitatkontext anhängen',
|
||||
selected_text_reply_appended: 'Auswahl zum Composer hinzugefügt',
|
||||
saved_prompts_empty: 'Noch keine gespeicherten Eingabeaufforderungen.',
|
||||
saved_prompts_save_current: 'Aktuelle Eingabe als Eingabeaufforderung speichern',
|
||||
saved_prompts_saved: 'Eingabeaufforderung gespeichert',
|
||||
saved_prompts_delete: 'Eingabeaufforderung löschen',
|
||||
saved_prompts_empty_input: 'Gib zuerst eine Eingabeaufforderung ein',
|
||||
|
||||
diff_loading: 'Lade Diff',
|
||||
diff_error: 'Patch-Datei konnte nicht geladen werden',
|
||||
@@ -8128,6 +8158,11 @@ const LOCALES = {
|
||||
selected_text_reply: '用所选内容回复',
|
||||
selected_text_reply_title: '将所选聊天文本作为引用上下文追加',
|
||||
selected_text_reply_appended: '已将所选文本添加到输入框',
|
||||
saved_prompts_empty: '暂无保存的提示。',
|
||||
saved_prompts_save_current: '将当前输入保存为提示',
|
||||
saved_prompts_saved: '提示已保存',
|
||||
saved_prompts_delete: '删除提示',
|
||||
saved_prompts_empty_input: '请先输入要保存的提示',
|
||||
|
||||
diff_loading: '加载 diff',
|
||||
diff_error: '无法加载 patch 文件',
|
||||
@@ -9453,6 +9488,11 @@ const LOCALES = {
|
||||
selected_text_reply: '用所選內容回覆',
|
||||
selected_text_reply_title: '將所選聊天文字附加為引用脈絡',
|
||||
selected_text_reply_appended: '已將所選文字加入輸入框',
|
||||
saved_prompts_empty: '尚無已儲存的提示。',
|
||||
saved_prompts_save_current: '將目前輸入儲存為提示',
|
||||
saved_prompts_saved: '提示已儲存',
|
||||
saved_prompts_delete: '刪除提示',
|
||||
saved_prompts_empty_input: '請先輸入要儲存的提示',
|
||||
|
||||
diff_loading: '載入 diff',
|
||||
diff_error: '無法載入 patch 檔案',
|
||||
@@ -10817,6 +10857,11 @@ const LOCALES = {
|
||||
selected_text_reply: 'Responder com seleção',
|
||||
selected_text_reply_title: 'Anexar o texto selecionado do chat como contexto citado',
|
||||
selected_text_reply_appended: 'Texto selecionado adicionado ao compositor',
|
||||
saved_prompts_empty: 'Nenhum prompt salvo ainda.',
|
||||
saved_prompts_save_current: 'Salvar entrada atual como prompt',
|
||||
saved_prompts_saved: 'Prompt salvo',
|
||||
saved_prompts_delete: 'Excluir prompt',
|
||||
saved_prompts_empty_input: 'Digite um prompt para salvar primeiro',
|
||||
you: 'Você',
|
||||
thinking: 'Pensando',
|
||||
expand_all: 'Expandir tudo',
|
||||
@@ -12010,6 +12055,11 @@ const LOCALES = {
|
||||
selected_text_reply: '선택 항목으로 답장',
|
||||
selected_text_reply_title: '선택한 채팅 텍스트를 인용 컨텍스트로 추가',
|
||||
selected_text_reply_appended: '선택한 텍스트가 입력창에 추가되었습니다',
|
||||
saved_prompts_empty: '저장된 프롬프트가 없습니다.',
|
||||
saved_prompts_save_current: '현재 입력을 프롬프트로 저장',
|
||||
saved_prompts_saved: '프롬프트가 저장되었습니다',
|
||||
saved_prompts_delete: '프롬프트 삭제',
|
||||
saved_prompts_empty_input: '먼저 저장할 프롬프트를 입력하세요',
|
||||
|
||||
diff_loading: 'diff 불러오는 중',
|
||||
diff_error: '패치 파일을 로드할 수 없습니다',
|
||||
@@ -13386,6 +13436,11 @@ const LOCALES = {
|
||||
selected_text_reply: 'Répondre avec la sélection',
|
||||
selected_text_reply_title: 'Ajouter le texte sélectionné du chat comme contexte cité',
|
||||
selected_text_reply_appended: 'Texte sélectionné ajouté au compositeur',
|
||||
saved_prompts_empty: 'Aucune invite sauvegardée pour l\'instant.',
|
||||
saved_prompts_save_current: 'Sauvegarder l\'entrée actuelle comme invite',
|
||||
saved_prompts_saved: 'Invite sauvegardée',
|
||||
saved_prompts_delete: 'Supprimer l\'invite',
|
||||
saved_prompts_empty_input: 'Tapez d\'abord une invite à sauvegarder',
|
||||
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',
|
||||
@@ -14699,6 +14754,11 @@ const LOCALES = {
|
||||
selected_text_reply: 'Seçimle yanıtla',
|
||||
selected_text_reply_title: 'Seçilen sohbet metnini alıntılanan bağlam olarak ekle',
|
||||
selected_text_reply_appended: 'Seçilen metin besteciye eklendi',
|
||||
saved_prompts_empty: 'Henüz kaydedilmiş istem yok.',
|
||||
saved_prompts_save_current: 'Mevcut girişi istem olarak kaydet',
|
||||
saved_prompts_saved: 'İstem kaydedildi',
|
||||
saved_prompts_delete: 'İstemi sil',
|
||||
saved_prompts_empty_input: 'Önce kaydedilecek bir istem girin',
|
||||
|
||||
diff_loading: 'Fark yükleniyor',
|
||||
diff_error: 'Yama dosyası yüklenemedi',
|
||||
@@ -16069,6 +16129,11 @@ const LOCALES = {
|
||||
selected_text_reply: 'Odpowiedz z zaznaczeniem',
|
||||
selected_text_reply_title: 'Dodaj zaznaczony tekst czatu jako cytowany kontekst',
|
||||
selected_text_reply_appended: 'Dodano zaznaczony tekst do pola wprowadzania',
|
||||
saved_prompts_empty: 'Brak zapisanych promptów.',
|
||||
saved_prompts_save_current: 'Zapisz bieżący tekst jako prompt',
|
||||
saved_prompts_saved: 'Prompt zapisany',
|
||||
saved_prompts_delete: 'Usuń prompt',
|
||||
saved_prompts_empty_input: 'Najpierw wpisz prompt do zapisania',
|
||||
|
||||
diff_loading: 'Ładowanie zmian (diff)',
|
||||
diff_error: 'Nie można załadować pliku poprawki',
|
||||
|
||||
@@ -573,6 +573,9 @@
|
||||
<button type="button" class="icon-btn has-tooltip" id="btnAttach" data-tooltip="Attach files">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
</button>
|
||||
<button type="button" class="icon-btn has-tooltip" id="btnSavedPrompts" data-tooltip="Saved prompts" onclick="toggleSavedPromptsPopup()" aria-haspopup="menu" aria-expanded="false" aria-controls="savedPromptsPopup">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn mic-btn has-tooltip" id="btnMic" data-tooltip="Dictate" data-i18n-title="voice_dictate" style="display:none">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="1" width="6" height="12" rx="3"/>
|
||||
@@ -665,6 +668,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="savedPromptsPopup" class="saved-prompts-popup" style="display:none" role="menu" aria-label="Saved prompts"></div>
|
||||
<div class="composer-right">
|
||||
<span class="composer-status" id="composerStatus" style="display:none"></span>
|
||||
<div class="ctx-indicator-wrap" id="ctxIndicatorWrap" style="display:none">
|
||||
|
||||
@@ -558,6 +558,113 @@ function _appendSelectedTextReplyToComposer(text){
|
||||
return true;
|
||||
}
|
||||
|
||||
function insertSavedPromptIntoComposer(text){
|
||||
const composer=(typeof $==='function'&&$('msg'))||document.getElementById('msg');
|
||||
if(!composer||!text)return;
|
||||
const current=String(composer.value||'');
|
||||
composer.value=current.trim()?`${current.replace(/\s+$/,'')}\n\n${text}\n\n`:`${text}\n\n`;
|
||||
composer.focus();
|
||||
try{composer.setSelectionRange(composer.value.length, composer.value.length);}catch(_e){}
|
||||
composer.dispatchEvent(new Event('input',{bubbles:true}));
|
||||
if(typeof autoResize==='function') autoResize();
|
||||
}
|
||||
|
||||
let _savedPromptsCache=null;
|
||||
|
||||
async function _loadSavedPrompts(){
|
||||
try{
|
||||
const data=await api('/api/prompts');
|
||||
_savedPromptsCache=Array.isArray(data&&data.prompts)?data.prompts:[];
|
||||
}catch(_e){_savedPromptsCache=[];}
|
||||
return _savedPromptsCache;
|
||||
}
|
||||
|
||||
async function toggleSavedPromptsPopup(){
|
||||
const popup=(typeof $==='function'&&$('savedPromptsPopup'))||document.getElementById('savedPromptsPopup');
|
||||
const btn=(typeof $==='function'&&$('btnSavedPrompts'))||document.getElementById('btnSavedPrompts');
|
||||
if(!popup)return;
|
||||
if(popup.style.display!=='none'){
|
||||
popup.style.display='none';
|
||||
if(btn)btn.setAttribute('aria-expanded','false');
|
||||
return;
|
||||
}
|
||||
popup.innerHTML='<div class="saved-prompts-loading">Loading…</div>';
|
||||
popup.style.display='flex';
|
||||
if(btn)btn.setAttribute('aria-expanded','true');
|
||||
const prompts=await _loadSavedPrompts();
|
||||
popup.innerHTML='';
|
||||
if(!prompts.length){
|
||||
const empty=document.createElement('div');
|
||||
empty.className='saved-prompts-empty';
|
||||
empty.textContent=(typeof t==='function'&&t('saved_prompts_empty'))||'No saved prompts yet.';
|
||||
popup.appendChild(empty);
|
||||
}else{
|
||||
for(const p of prompts){
|
||||
const row=document.createElement('div');
|
||||
row.className='saved-prompt-row';
|
||||
row.setAttribute('role','menuitem');
|
||||
const label=document.createElement('span');
|
||||
label.className='saved-prompt-label';
|
||||
label.textContent=p.label||p.text;
|
||||
label.title=p.text;
|
||||
row.onclick=()=>{
|
||||
insertSavedPromptIntoComposer(p.text);
|
||||
popup.style.display='none';
|
||||
if(btn)btn.setAttribute('aria-expanded','false');
|
||||
};
|
||||
const del=document.createElement('button');
|
||||
del.className='saved-prompt-delete';
|
||||
del.type='button';
|
||||
del.title=(typeof t==='function'&&t('saved_prompts_delete'))||'Delete';
|
||||
del.innerHTML='<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
del.onclick=async(e)=>{
|
||||
e.stopPropagation();
|
||||
try{await api('/api/prompts',{method:'DELETE',body:JSON.stringify({id:p.id})});}catch(_e){}
|
||||
_savedPromptsCache=null;
|
||||
await toggleSavedPromptsPopup();
|
||||
await toggleSavedPromptsPopup();
|
||||
};
|
||||
row.appendChild(label);
|
||||
row.appendChild(del);
|
||||
popup.appendChild(row);
|
||||
}
|
||||
}
|
||||
const addRow=document.createElement('div');
|
||||
addRow.className='saved-prompt-add-row';
|
||||
const saveBtn=document.createElement('button');
|
||||
saveBtn.type='button';
|
||||
saveBtn.className='saved-prompt-save-btn';
|
||||
saveBtn.textContent=(typeof t==='function'&&t('saved_prompts_save_current'))||'Save current input';
|
||||
saveBtn.onclick=async()=>{
|
||||
const msgEl=(typeof $==='function'&&$('msg'))||document.getElementById('msg');
|
||||
const text=(msgEl&&msgEl.value||'').trim();
|
||||
if(!text){
|
||||
if(typeof showToast==='function') showToast((typeof t==='function'&&t('saved_prompts_empty_input'))||'Type a prompt first',2000,'error');
|
||||
return;
|
||||
}
|
||||
try{await api('/api/prompts',{method:'POST',body:JSON.stringify({text})});}catch(_e){
|
||||
if(typeof showToast==='function') showToast(_e&&_e.message||'Failed to save prompt',2000,'error');
|
||||
return;
|
||||
}
|
||||
_savedPromptsCache=null;
|
||||
popup.style.display='none';
|
||||
if(btn)btn.setAttribute('aria-expanded','false');
|
||||
if(typeof showToast==='function') showToast((typeof t==='function'&&t('saved_prompts_saved'))||'Prompt saved',1600);
|
||||
};
|
||||
addRow.appendChild(saveBtn);
|
||||
popup.appendChild(addRow);
|
||||
}
|
||||
|
||||
document.addEventListener('click',(e)=>{
|
||||
const popup=(typeof $==='function'&&$('savedPromptsPopup'))||document.getElementById('savedPromptsPopup');
|
||||
const btn=(typeof $==='function'&&$('btnSavedPrompts'))||document.getElementById('btnSavedPrompts');
|
||||
if(!popup||popup.style.display==='none')return;
|
||||
if(!popup.contains(e.target)&&e.target!==btn&&!(btn&&btn.contains(e.target))){
|
||||
popup.style.display='none';
|
||||
if(btn)btn.setAttribute('aria-expanded','false');
|
||||
}
|
||||
},{capture:false});
|
||||
|
||||
function _selectedTextReplyButton(){
|
||||
if(_selectedTextReplyBtn)return _selectedTextReplyBtn;
|
||||
const btn=document.createElement('button');
|
||||
|
||||
@@ -2289,6 +2289,9 @@
|
||||
.composer-left > .composer-model-wrap,
|
||||
.composer-left > .composer-reasoning-wrap,
|
||||
.composer-left > .composer-toolsets-wrap{display:none!important;}
|
||||
/* Saved prompts is a desktop-only composer affordance — too much for the
|
||||
narrow mobile composer (Nathan 2026-06-09). Hide the button + popup. */
|
||||
#btnSavedPrompts,.saved-prompts-popup{display:none!important;}
|
||||
.composer-mobile-config-btn{box-sizing:border-box;position:relative;display:inline-flex!important;width:44px;height:44px;min-width:44px;min-height:44px;flex-shrink:0;}
|
||||
.composer-mobile-config-panel.open{display:flex;}
|
||||
.composer-mobile-config-action{flex:1 1 0;min-height:44px;min-width:0;background:var(--input-bg);border-color:var(--border);}
|
||||
@@ -2384,6 +2387,9 @@
|
||||
.composer-left > .composer-model-wrap,
|
||||
.composer-left > .composer-reasoning-wrap,
|
||||
.composer-left > .composer-toolsets-wrap{display:none!important;}
|
||||
/* Saved prompts is a desktop-only composer affordance — too much for the
|
||||
narrow mobile composer (Nathan 2026-06-09). Hide the button + popup. */
|
||||
#btnSavedPrompts,.saved-prompts-popup{display:none!important;}
|
||||
.composer-mobile-config-btn{box-sizing:border-box;position:relative;display:inline-flex!important;width:44px;height:44px;min-width:44px;min-height:44px;flex-shrink:0;}
|
||||
.composer-mobile-config-panel.open{display:flex;}
|
||||
.composer-mobile-config-action{box-sizing:border-box;flex:1 1 0;min-height:44px;min-width:0;background:var(--input-bg);border-color:var(--border);}
|
||||
@@ -4173,6 +4179,18 @@ main.main > #mainPlugin{display:none;}
|
||||
the row's gap:6px handles spacing, no margin/vertical-align needed. */
|
||||
.session-project-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0;display:inline-block;}
|
||||
|
||||
/* ── Saved prompts popup ── */
|
||||
.saved-prompts-popup{position:absolute;bottom:calc(100% + 8px);left:0;min-width:280px;max-width:380px;max-height:320px;overflow-y:auto;background:var(--surface);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.18);z-index:200;flex-direction:column;gap:2px;padding:6px;}
|
||||
.saved-prompt-row{display:flex;align-items:center;gap:6px;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;}
|
||||
.saved-prompt-row:hover{background:var(--hover-bg);}
|
||||
.saved-prompt-label{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);}
|
||||
.saved-prompt-delete{flex-shrink:0;background:none;border:none;cursor:pointer;color:var(--muted);padding:2px;border-radius:4px;display:flex;align-items:center;justify-content:center;}
|
||||
.saved-prompt-delete:hover{color:var(--danger,#f56565);background:rgba(245,101,101,.12);}
|
||||
.saved-prompt-add-row{padding:6px 4px 2px;border-top:1px solid var(--border);margin-top:4px;}
|
||||
.saved-prompt-save-btn{width:100%;padding:6px 10px;border-radius:6px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);color:var(--accent-text);font-size:12px;font-weight:600;cursor:pointer;text-align:center;}
|
||||
.saved-prompt-save-btn:hover{background:var(--accent-bg-strong);}
|
||||
.saved-prompts-empty,.saved-prompts-loading{padding:12px 10px;font-size:12px;color:var(--muted);text-align:center;}
|
||||
|
||||
/* ── Code copy button ── */
|
||||
.code-copy-btn{background:var(--hover-bg);border:1px solid var(--border2);border-radius:4px;color:var(--muted);font-size:11px;cursor:pointer;padding:2px 6px;transition:all .15s;line-height:1.3;}
|
||||
.code-copy-btn:hover{background:rgba(255,255,255,.12);color:var(--text);}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""#3571 saved-prompts library — structural + mobile-visibility guards.
|
||||
|
||||
The saved-prompts composer affordance is a desktop-only feature: per Nathan
|
||||
(2026-06-09) it must be hidden on mobile (too much for the narrow composer).
|
||||
These tests pin the mobile-hide rule and the core wiring so a future refactor
|
||||
can't silently regress either.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def read(rel: str) -> str:
|
||||
return (REPO / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_saved_prompts_button_hidden_on_mobile():
|
||||
"""#btnSavedPrompts (and its popup) must be display:none inside a mobile
|
||||
max-width:640px @media block — desktop-only affordance. Verified by walking
|
||||
each media block and confirming the hide rule lives inside a 640px-max one."""
|
||||
css = read("static/style.css")
|
||||
found = False
|
||||
for m in re.finditer(r"@media([^{]*)\{", css):
|
||||
cond = m.group(1)
|
||||
if "max-width" not in cond or "640px" not in cond:
|
||||
continue
|
||||
# Slice to this block's matching close brace.
|
||||
depth = 0
|
||||
i = m.end() - 1
|
||||
end = len(css)
|
||||
while i < len(css):
|
||||
if css[i] == "{":
|
||||
depth += 1
|
||||
elif css[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i
|
||||
break
|
||||
i += 1
|
||||
block = css[m.end():end]
|
||||
if re.search(r"#btnSavedPrompts[^{]*\{[^}]*display:\s*none", block):
|
||||
found = True
|
||||
break
|
||||
assert found, (
|
||||
"#btnSavedPrompts must be hidden on mobile inside a max-width:640px block "
|
||||
"(desktop-only saved-prompts feature)"
|
||||
)
|
||||
|
||||
|
||||
def test_saved_prompts_backend_caps_present():
|
||||
"""The POST /api/prompts route must cap text length and total count so
|
||||
saved_prompts.json can't grow unbounded."""
|
||||
routes = read("api/routes.py")
|
||||
assert "text too long" in routes, "POST /api/prompts must cap text length"
|
||||
assert re.search(r"len\(prompts\)\s*>=\s*\d+", routes), (
|
||||
"POST /api/prompts must cap the total number of saved prompts"
|
||||
)
|
||||
|
||||
|
||||
def test_saved_prompts_core_wiring_present():
|
||||
"""The composer must expose the saved-prompts toggle + popup and the
|
||||
load/save/delete API calls."""
|
||||
js = read("static/messages.js")
|
||||
assert "toggleSavedPromptsPopup" in js
|
||||
assert "insertSavedPromptIntoComposer" in js
|
||||
assert re.search(r"api\('/api/prompts',\s*\{method:'POST'", js), "save wiring (POST) missing"
|
||||
assert re.search(r"api\('/api/prompts',\s*\{method:'DELETE'", js), "delete wiring (DELETE) missing"
|
||||
html = read("static/index.html")
|
||||
assert 'id="btnSavedPrompts"' in html
|
||||
assert 'id="savedPromptsPopup"' in html
|
||||
Reference in new issue
Block a user