Refine iOS-style session swipe actions

This commit is contained in:
dobby-d-elf 2026-05-24 17:58:26 -06:00
parent b4fa00b793
commit e2bff58964
3 changed files with 60 additions and 13 deletions

View file

@ -1908,12 +1908,15 @@ async function _archiveSession(session, archived=true, beforeListRender=null){
try{
const response=await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived})});
session.archived=archived;
const cached=(_allSessions||[]).find(s=>s&&s.session_id===session.session_id);
if(cached) cached.archived=archived;
if(S.session&&S.session.session_id===session.session_id) S.session.archived=archived;
showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored'));
if(renderHold) await renderHold;
if(_showArchived&&!_sessionPrefersReducedMotion()) _sessionSwipeReturnOffsets.set(session.session_id,'0px');
_pendingSessionReflowPositions=reflowPositions;
await renderSessionList();
showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored'));
renderSessionListFromCache();
void renderSessionList();
return true;
}catch(err){if(renderHold) await renderHold.catch(()=>{});_pendingSessionReflowPositions=null;showToast(t('session_archive_failed')+err.message);return false;}
}
@ -3767,7 +3770,7 @@ function renderSessionListFromCache(){
if(!readOnly){
el.append(
_makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?t('session_restore'):t('session_batch_archive')),
_makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?'Restore':t('session_batch_archive')),
_makeSessionSwipeAffordance('left','trash-2',t('session_batch_delete')),
);
}
@ -3860,10 +3863,12 @@ function renderSessionListFromCache(){
const offset=Math.sign(rawOffset)*(Math.abs(revealedOffset)+Math.sqrt(overshoot)*5);
const progress=Math.min(1,Math.abs(revealedOffset)/72);
const reveal=Math.abs(offset);
const iconScale=1+Math.min(.45,Math.max(0,Math.abs(rawOffset)-52)/130);
const iconScale=Math.min(1,Math.max(.01,progress*1.12));
const badgeStretch=Math.min(Math.max(0,reveal-34),overshoot*1.15);
el.style.setProperty('--session-swipe-offset',offset+'px');
el.style.setProperty('--session-swipe-reveal',reveal+'px');
el.style.setProperty('--session-swipe-icon-scale',iconScale);
el.style.setProperty('--session-swipe-badge-stretch',badgeStretch+'px');
el.style.setProperty('--session-swipe-progress',Math.pow(progress,1.5));
el.classList.toggle('swiping-right',offset>0);
el.classList.toggle('swiping-left',offset<0);
@ -3872,6 +3877,7 @@ function renderSessionListFromCache(){
el.style.removeProperty('--session-swipe-offset');
el.style.removeProperty('--session-swipe-reveal');
el.style.removeProperty('--session-swipe-icon-scale');
el.style.removeProperty('--session-swipe-badge-stretch');
el.style.removeProperty('--session-swipe-progress');
el.style.removeProperty('height');
el.style.removeProperty('min-height');

View file

@ -735,7 +735,7 @@
justify-content:center;
gap:3px;
width:var(--session-swipe-reveal,0px);
padding:0 8px;
padding:0;
box-sizing:border-box;
opacity:0;
overflow:hidden;
@ -746,16 +746,18 @@
.session-swipe-affordance-right{
left:0;
color:#fff;
background:var(--warning);
border-radius:8px 6px 6px 8px;
background:transparent;
border-radius:0;
transform-origin:left center;
--session-swipe-action-color:var(--warning);
}
.session-swipe-affordance-left{
right:0;
color:#fff;
background:var(--error);
border-radius:6px 8px 8px 6px;
background:transparent;
border-radius:0;
transform-origin:right center;
--session-swipe-action-color:var(--error);
}
.session-item.swiping-right .session-swipe-affordance-right,
.session-item.swiping-left .session-swipe-affordance-left{
@ -763,13 +765,15 @@
}
.session-text,.session-state-indicator,.session-actions{z-index:1;}
.session-swipe-badge{
width:28px;
height:28px;
border-radius:0;
width:calc(34px + var(--session-swipe-badge-stretch,0px));
height:34px;
border-radius:999px;
background:var(--session-swipe-action-color);
display:flex;
align-items:center;
justify-content:center;
color:#fff;
flex:0 0 auto;
transform:scale(var(--session-swipe-icon-scale,1));
transform-origin:center;
}
@ -804,7 +808,7 @@
.session-item.active{background:var(--accent-bg);color:var(--accent);}
.session-item.active.swiping-right{background:color-mix(in srgb,var(--warning) 20%,var(--accent-bg));}
.session-item.active.swiping-left{background:color-mix(in srgb,var(--error) 18%,var(--accent-bg));}
.session-item.archived .session-swipe-affordance-right{background:var(--success);}
.session-item.archived .session-swipe-affordance-right{--session-swipe-action-color:var(--success);}
.session-item.archived.swiping-right,
.session-item.archived.dragging.swiping-right{background:color-mix(in srgb,var(--success) 16%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--success) 48%,transparent);}
.session-item.active.archived.swiping-right{background:color-mix(in srgb,var(--success) 20%,var(--accent-bg));}

View file

@ -121,12 +121,16 @@ def test_session_swipe_paint_uses_transform_only_exit():
assert "--session-swipe-offset" in paint
assert "--session-swipe-reveal" in paint
assert "--session-swipe-progress" in paint
assert "--session-swipe-badge-stretch" in paint
assert "const reveal=Math.abs(offset);" in paint
assert "const iconScale=Math.min(1,Math.max(.01,progress*1.12));" in paint
assert "const badgeStretch=Math.min(Math.max(0,reveal-34),overshoot*1.15);" in paint
assert "window.innerWidth+'px'" in complete
assert "el.style.height=rect.height+'px'" in complete
assert "requestAnimationFrame(()=>el.classList.add('swipe-removing'))" in complete
assert "requestAnimationFrame(()=>requestAnimationFrame(_clearSessionSwipePaint))" in SESSIONS_JS
assert "el.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing')" in clear
assert "el.style.removeProperty('--session-swipe-badge-stretch');" in clear
assert ".session-item.swipe-committed,\n .session-item.swipe-removing{transition:" in STYLE_CSS
assert "transform:translate3d(calc(-1 * var(--session-swipe-offset,0px)),0,0)" in STYLE_CSS
assert "transform:scale(var(--session-swipe-icon-scale,1))" in STYLE_CSS
@ -140,6 +144,29 @@ def test_session_swipe_paint_uses_transform_only_exit():
assert "transform:translate3d(var(--session-swipe-offset,0),var(--session-reflow-offset,0),0)" in STYLE_CSS
def test_session_swipe_actions_use_circular_icon_badges():
right_start = STYLE_CSS.find(".session-swipe-affordance-right{")
left_start = STYLE_CSS.find(".session-swipe-affordance-left{")
badge_start = STYLE_CSS.find(".session-swipe-badge{")
right = STYLE_CSS[right_start:left_start]
left = STYLE_CSS[left_start:STYLE_CSS.find(".session-item.swiping-right", left_start)]
badge = STYLE_CSS[badge_start:STYLE_CSS.find(".session-swipe-badge svg", badge_start)]
assert "background:transparent" in right
assert "background:transparent" in left
assert "--session-swipe-action-color:var(--warning)" in right
assert "--session-swipe-action-color:var(--error)" in left
assert "width:calc(34px + var(--session-swipe-badge-stretch,0px))" in badge
assert "height:34px" in badge
assert "border-radius:999px" in badge
assert "background:var(--session-swipe-action-color)" in badge
assert ".session-item.archived .session-swipe-affordance-right{--session-swipe-action-color:var(--success);}" in STYLE_CSS
assert "_makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?'Restore':t('session_batch_archive'))" in SESSIONS_JS
label = STYLE_CSS[STYLE_CSS.find(".session-swipe-label{"):STYLE_CSS.find(".session-item.dragging")]
assert "session-swipe-label" in SESSIONS_JS
assert "max-width:58px" in label
assert "font-size:10px" in label
def test_session_removal_reflows_surviving_rows_smoothly():
assert "let _pendingSessionReflowPositions = null;" in SESSIONS_JS
assert "const _optimisticallyRemovedSessionIds = new Set();" in SESSIONS_JS
@ -159,6 +186,16 @@ def test_session_removal_reflows_surviving_rows_smoothly():
assert "_playSessionRowsReflowFromPositions(reflowBefore,reflowTimeout,_sessionPrefersReducedMotion);" in SESSIONS_JS
assert "async function _archiveSession(session, archived=true, beforeListRender=null){" in SESSIONS_JS
assert "const renderHold=beforeListRender?Promise.resolve().then(beforeListRender):null;" in SESSIONS_JS
assert "const cached=(_allSessions||[]).find(s=>s&&s.session_id===session.session_id);" in SESSIONS_JS
assert "if(cached) cached.archived=archived;" in SESSIONS_JS
archive_start = SESSIONS_JS.find("async function _archiveSession(session, archived=true, beforeListRender=null){")
archive_end = SESSIONS_JS.find("function _openSessionActionMenu", archive_start)
archive_body = SESSIONS_JS[archive_start:archive_end]
toast_idx = archive_body.find("showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored'));")
hold_idx = archive_body.find("if(renderHold) await renderHold;")
cache_render_idx = archive_body.find("renderSessionListFromCache();")
reconcile_idx = archive_body.find("void renderSessionList();")
assert 0 <= toast_idx < hold_idx < cache_render_idx < reconcile_idx
assert "if(renderHold) await renderHold;" in SESSIONS_JS
assert "const serverSessions=_optimisticallyRemovedSessionIds.size" in SESSIONS_JS
assert "? (sessData.sessions||[]).filter(s=>s&&!_optimisticallyRemovedSessionIds.has(s.session_id))" in SESSIONS_JS