Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@

## [Unreleased]

## [v0.51.302] — 2026-06-06 — Release JR (stage-brick — mobile/iOS breakage + large-session perf hotfixes)

### Fixed
- **Hidden notification toasts no longer block taps on mobile.** The `.toast` container stayed `pointer-events:auto` while hidden (`opacity:0`), and its fixed padding sat over the profile action buttons at the top of the mobile content view — so taps on Activate/Delete and similar controls were silently eaten by an invisible element. The toast is now `pointer-events:none` when hidden and only becomes interactive on `.toast.show`. (#3735, @timlawrenz)
- **Renaming a conversation now works on iOS Safari.** iOS has no Enter key on the soft keyboard; tapping "Done" fires `blur`, and the old `onblur` handler *cancelled* the rename — so a mobile rename could never be saved. Blur now commits the rename (Escape still explicitly cancels), matching the more natural desktop expectation that typing a name then clicking away saves it. The same blur-saves fix applies to project create/rename, guarded by a `_finishDone` latch so the blur and the API callback can't double-fire. (#3729, @reinocheong)

### Performance
- **Loading a session with very large tool/log payloads no longer stalls the whole WebUI for many seconds.** `_matching_visible_duplicate()` eagerly casefolded and regex-tokenized every visible message key — including multi-megabyte tool outputs — on each duplicate probe, so `/api/session` could take 10s+ and block the sidebar's `/api/sessions` for ~19s. Loose-content normalization is now lazy and cached, and substring/fuzzy matching is skipped for non-exact payloads larger than 200KB; exact visible-key matches still short-circuit before the guard. (#3730, @alvistar)

## [v0.51.301] — 2026-06-06 — Release JQ (stage-3710 — hide test-helper console windows on Windows)

### Changed
Expand Down
21 changes: 17 additions & 4 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4040,7 +4040,6 @@ def _session_message_visible_key(msg: dict):

def _build_visible_duplicate_lookup(visible_keys: set[tuple]) -> dict:
by_role = {}
loose_by_key = {}
for key in visible_keys:
try:
role = key[0]
Expand All @@ -4050,8 +4049,10 @@ def _build_visible_duplicate_lookup(visible_keys: set[tuple]) -> dict:
if not content:
continue
by_role.setdefault(role, []).append(key)
loose_by_key[key] = _loose_session_message_content(content)
return {"keys": visible_keys, "by_role": by_role, "loose_by_key": loose_by_key}
# Keep loose_by_key lazy. Some transcripts contain multi-megabyte tool
# outputs; eagerly casefolding + regex-tokenizing every visible key on every
# duplicate probe made /api/session take 10s+ and blocked /api/sessions.
return {"keys": visible_keys, "by_role": by_role, "loose_by_key": {}}


def _matching_visible_duplicate(visible_key: tuple, visible_keys: set[tuple], lookup: dict | None = None):
Expand All @@ -4064,16 +4065,28 @@ def _matching_visible_duplicate(visible_key: tuple, visible_keys: set[tuple], lo
if lookup is None:
lookup = _build_visible_duplicate_lookup(visible_keys)
loose_content = None
loose_by_key = lookup.setdefault("loose_by_key", {})
for existing_key in lookup.get("by_role", {}).get(role, []):
existing_role = existing_key[0]
existing_content = existing_key[1] if len(existing_key) > 1 else ""
if role != existing_role or not existing_content:
continue
# Exact visible-key equality was checked above. For very large payloads
# (tool logs / request dumps), Python-in substring and fuzzy-token
# comparisons are both expensive and low-value; doing them repeatedly
# made session loading block the whole WebUI for many seconds. Keep
# fuzzy matching for normal chat-sized text, but do exact-only matching
# for giant payloads.
if max(len(content), len(existing_content)) > 200_000:
continue
if content in existing_content or existing_content in content:
return existing_key
if loose_content is None:
loose_content = _loose_session_message_content(content)
loose_existing = lookup.get("loose_by_key", {}).get(existing_key, "")
loose_existing = loose_by_key.get(existing_key)
if loose_existing is None:
loose_existing = _loose_session_message_content(existing_content)
loose_by_key[existing_key] = loose_existing
if loose_content and loose_existing and (
loose_content in loose_existing or loose_existing in loose_content
):
Expand Down
26 changes: 21 additions & 5 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -5191,8 +5191,11 @@ function renderSessionListFromCache(){
}
if(e2.key==='Escape'){e2.preventDefault();e2.stopPropagation();finish(false);}
};
// onblur: cancel only -- no accidental saves
inp.onblur=()=>{ if(_renamingSid===s.session_id) finish(false); };
// onblur: save on blur — Escape explicitly cancels. The old cancel-on-blur
// behavior broke rename on mobile (iPhone "Done" dismisses the keyboard,
// triggering blur) and was less natural on desktop too (typing a name then
// clicking elsewhere should save, not discard).
inp.onblur=()=>{ if(_renamingSid===s.session_id) finish(true); };
title.replaceWith(inp);
setTimeout(()=>{inp.focus();inp.select();},10);
};
Expand Down Expand Up @@ -5883,10 +5886,19 @@ function _startProjectCreate(bar, addBtn){
const inp=document.createElement('input');
inp.className='project-create-input';
inp.placeholder='Project name';
let _finishDone=false;
const finish=async(save)=>{
if(_finishDone) return;
_finishDone=true;
if(save&&inp.value.trim()){
const color=PROJECT_COLORS[_allProjects.length%PROJECT_COLORS.length];
await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:inp.value.trim(),color})});
try{
await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:inp.value.trim(),color})});
}catch(e){
_finishDone=false;
showToast('Project create failed: '+(e.message||e));
return;
}
await renderSessionList();
showToast('Project created');
Comment on lines +5895 to 5903

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 renderSessionList() sits outside the try-catch in _startProjectCreate, but inside it in _startProjectRename. If a network error occurs during the post-create list refresh, the async finish function rejects with _finishDone still true, leaving inp permanently in the DOM with no way for the user to dismiss it (every subsequent blur/Enter is a no-op) short of a page refresh. Moving renderSessionList() and showToast inside the try block matches _startProjectRename's pattern and restores retry-ability on failure.

Suggested change
try{
await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:inp.value.trim(),color})});
}catch(e){
_finishDone=false;
showToast('Project create failed: '+(e.message||e));
return;
}
await renderSessionList();
showToast('Project created');
try{
await api('/api/projects/create',{method:'POST',body:JSON.stringify({name:inp.value.trim(),color})});
await renderSessionList();
showToast('Project created');
}catch(e){
_finishDone=false;
showToast('Project create failed: '+(e.message||e));
return;
}

}else{
Expand All @@ -5901,7 +5913,7 @@ function _startProjectCreate(bar, addBtn){
}
if(e.key==='Escape'){e.preventDefault();finish(false);}
};
inp.onblur=()=>finish(false);
inp.onblur=()=>finish(true);
inp.addEventListener('input',()=>_resizeProjectInput(inp));
addBtn.replaceWith(inp);
_resizeProjectInput(inp);
Expand All @@ -5912,13 +5924,17 @@ function _startProjectRename(proj, chip){
const inp=document.createElement('input');
inp.className='project-create-input';
inp.value=proj.name;
let _finishDone=false;
const finish=async(save)=>{
if(_finishDone) return;
_finishDone=true;
if(save&&inp.value.trim()&&inp.value.trim()!==proj.name){
try {
await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:inp.value.trim()})});
await renderSessionList();
showToast('Project renamed');
} catch(e) {
_finishDone=false;
showToast('Rename failed: '+(e.message||e));
}
}else{
Expand All @@ -5933,7 +5949,7 @@ function _startProjectRename(proj, chip){
}
if(e.key==='Escape'){e.preventDefault();finish(false);}
};
inp.onblur=()=>finish(false);
inp.onblur=()=>finish(true);
inp.onclick=(e)=>e.stopPropagation();
inp.addEventListener('input',()=>_resizeProjectInput(inp));
chip.replaceWith(inp);
Expand Down
4 changes: 2 additions & 2 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1203,8 +1203,8 @@
.app-dialog-btn.confirm.danger{border-color:var(--error);background:rgba(239,83,80,.12);color:var(--error);}
.app-dialog-btn.confirm.danger:hover{background:rgba(239,83,80,.2);border-color:var(--error);}
.app-dialog-btn:focus-visible,.app-dialog-close:focus-visible{outline:2px solid var(--accent);outline-offset:2px;}
.toast{pointer-events:auto;position:fixed;top:24px;right:24px;left:auto;bottom:auto;transform:translateY(-6px);display:flex;align-items:center;gap:10px;background:color-mix(in srgb,var(--accent) 14%,var(--surface));border:1px solid color-mix(in srgb,var(--accent) 45%,var(--surface));color:var(--accent-text);font-size:13px;font-weight:500;padding:10px 12px 10px 16px;border-radius:10px;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 6px 24px rgba(0,0,0,.12);letter-spacing:.01em;max-width:min(520px,calc(100vw - 48px));}
.toast.show{opacity:1;transform:translateY(0);}
.toast{pointer-events:none;position:fixed;top:24px;right:24px;left:auto;bottom:auto;transform:translateY(-6px);display:flex;align-items:center;gap:10px;background:color-mix(in srgb,var(--accent) 14%,var(--surface));border:1px solid color-mix(in srgb,var(--accent) 45%,var(--surface));color:var(--accent-text);font-size:13px;font-weight:500;padding:10px 12px 10px 16px;border-radius:10px;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 6px 24px rgba(0,0,0,.12);letter-spacing:.01em;max-width:min(520px,calc(100vw - 48px));}
.toast.show{opacity:1;transform:translateY(0);pointer-events:auto;}
.toast.success{background:color-mix(in srgb,var(--success) 14%,var(--surface));border-color:color-mix(in srgb,var(--success) 45%,var(--surface));color:var(--success);}
.toast.error{background:color-mix(in srgb,var(--error) 14%,var(--surface));border-color:color-mix(in srgb,var(--error) 45%,var(--surface));color:var(--error);}
.toast-message{min-width:0;overflow-wrap:anywhere;white-space:pre-wrap;}
Expand Down
10 changes: 9 additions & 1 deletion tests/test_issue1796_error_toasts.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,13 @@ def test_toast_dismissal_pauses_on_hover_and_keyboard_focus():
assert "onmouseleave=()=>setToastDismissTimer(el,duration)" in UI_JS
assert "onfocusin=()=>clearToastDismissTimer(el)" in UI_JS
assert "onfocusout=()=>setToastDismissTimer(el,duration)" in UI_JS
assert ".toast{pointer-events:auto" in STYLE_CSS
# A *visible* toast must remain interactive so hover/focus can pause the
# dismiss timer. Interactivity lives on `.toast.show` (see #3735): the hidden
# base `.toast` is pointer-events:none so its invisible padding can't eat
# taps on controls underneath it (mobile profile buttons), and it becomes
# pointer-events:auto only once shown.
assert ".toast.show{" in STYLE_CSS
show_rule = STYLE_CSS[STYLE_CSS.index(".toast.show{"):STYLE_CSS.index("}", STYLE_CSS.index(".toast.show{"))]
assert "pointer-events:auto" in show_rule
assert ".toast{pointer-events:none" in STYLE_CSS # hidden toast must not intercept clicks (#3735)
assert ".toast-copy" in STYLE_CSS
41 changes: 41 additions & 0 deletions tests/test_merge_key_tool_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"""
from __future__ import annotations

from api import models
from api.models import (
_matching_visible_duplicate,
_session_message_dedup_key,
Expand Down Expand Up @@ -130,3 +131,43 @@ def test_no_tool_calls_still_deduped(self):
msg = {"role": "assistant", "content": "hello", "timestamp": 1000}
result = merge_session_messages_append_only([msg], [msg])
assert len(result) == 1


# ── large-payload duplicate matching performance ────────────────────────────


class TestVisibleDuplicateLargePayloadPerformance:
def test_large_nonmatching_payload_skips_loose_normalizer(self, monkeypatch):
"""Giant tool/log payloads must not be regex-tokenized for fuzzy matching.

Exact visible-key equality is checked before this path. For non-exact
multi-hundred-KB payloads, fuzzy substring/token matching is too costly
for the /api/session hot path and low-value for deduplication.
"""
def fail_if_called(_content):
raise AssertionError("large payloads should not hit loose normalizer")

monkeypatch.setattr(models, "_loose_session_message_content", fail_if_called)

large_state = ("state output\n" * 25_000).strip()
large_sidecar = ("sidecar output\n" * 25_000).strip()
visible_key = ("assistant", large_state, "")
sidecar_key = ("assistant", large_sidecar, "")

assert _matching_visible_duplicate(visible_key, {sidecar_key}) is None

def test_small_nonmatching_payload_keeps_loose_matching(self, monkeypatch):
"""The large-payload guard must not disable legacy fuzzy matching."""
calls = []

def counted_loose(content):
calls.append(content)
return " ".join(str(content).lower().replace(",", "").replace("!", "").split())

monkeypatch.setattr(models, "_loose_session_message_content", counted_loose)

visible_key = ("assistant", "hello world", "")
sidecar_key = ("assistant", "HELLO, WORLD!!", "")

assert _matching_visible_duplicate(visible_key, {sidecar_key}) == sidecar_key
assert len(calls) == 2
Loading