Release v0.51.338 — Release LB (saved prompts library, #3571) (#3860)

* 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:
authored and GitHub committed 2026-06-08 19:32:12 -07:00
1 parent de4509702d
commit 8d4ed1f90d
7 files changed
+320

No files matched your search

+5
View File
@@ -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
+50
View File
@@ -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
+65
View File
@@ -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',
+4
View File
@@ -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">
+107
View File
@@ -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');
+18
View File
@@ -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);}
+71
View File
@@ -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