Skip to content

Commit d90a605

Browse files
fix(#1298): preserve user message on cancel + persist activity-panel expand
Two distinct bugs reported by @YanTianlong-01 in #1298 against v0.50.240. ## Issue 2: Stop/Cancel deletes the user's typed message (data loss) When a user clicked Stop while the agent was streaming, `cancel_stream()` in `api/streaming.py` cleared `s.pending_user_message` before the streaming thread had merged the user turn into `s.messages`. The session was saved with neither the pending field nor a corresponding message — the user's typed text was permanently lost from the session JSON, not just the in-memory client copy. Reload didn't recover it. The fix runs inside the existing post-cancel session lock and synthesizes a user turn into `s.messages` from `pending_user_message` when the latest user message isn't already that turn. Content-match guards against double-append when the streaming thread won the race and merged before cancel got the lock. Pending attachments are preserved. ## Issue 1: Activity panel auto-collapses while user is reading it `ensureActivityGroup()` (`static/ui.js`) creates the live activity group with `tool-call-group-collapsed` whenever it's missing, and `finalizeThinkingCard()` force-adds that class on every tool boundary. The user's manual expand state lives only on the DOM class list, so every thinking → tool → thinking transition (which destroys and recreates the group) wipes the expand and snaps the panel shut. Adds a per-turn singleton `_liveActivityUserExpanded` that tracks the user's last explicit toggle (set by an inline `_onLiveActivityToggle()` hook in the summary button's onclick). `ensureActivityGroup()` consults it when re-creating the live group; `finalizeThinkingCard()` skips the force-collapse when the user has explicitly expanded. `clearLiveToolCards()` resets the tracker between turns so the next turn starts at the default collapsed state. ## Tests - `tests/test_issue1298_cancel_and_activity.py` — 9 tests: - 4 server-side: cancel synthesises user message, doesn't double-append, preserves attachments, no-ops when there's no pending - 5 client-side: source-level guards on `_liveActivityUserExpanded`, `ensureActivityGroup`, `finalizeThinkingCard`, the inline onclick, and `clearLiveToolCards` All adjacent suites still pass (test_cancel_interrupt, test_session_sidecar_repair, test_streaming_race_fix, test_regressions). Closes #1298 Co-authored-by: YanTianlong-01 <[email protected]>
1 parent 3f838fc commit d90a605

4 files changed

Lines changed: 419 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## [Unreleased]
44

55
### Fixed
6+
- **Stop/Cancel during streaming no longer wipes the user's typed message (data-loss bug)** — When a user clicked Stop while the agent was streaming, `cancel_stream()` cleared `pending_user_message` before the streaming thread had merged the user turn into `s.messages`, persisting a session with neither the pending field nor a corresponding message. The user's typed text was permanently lost from the session JSON, not just the in-memory client copy. Now `cancel_stream()` synthesizes a user turn into `s.messages` from `pending_user_message` (with attachments preserved) when the most recent user message isn't already that turn — guards against double-append by content-matching against the last user message. (`api/streaming.py`, `tests/test_issue1298_cancel_and_activity.py`) — fixes #1298 (issue 2)
7+
- **Activity panel no longer auto-collapses when new tool/thinking events arrive** — Both `ensureActivityGroup()` (which re-creates the group with `tool-call-group-collapsed` on every destroy/recreate) and `finalizeThinkingCard()` (which force-adds the collapsed class on every tool boundary) ignored the user's manual expand. Tracks the user's last explicit toggle on the live activity group in a per-turn singleton (`_liveActivityUserExpanded`), restored on re-create and respected by the finalize path. Cleared between turns by `clearLiveToolCards()`. (`static/ui.js`, `tests/test_issue1298_cancel_and_activity.py`) — fixes #1298 (issue 1)
68

79
## [v0.50.244] — 2026-04-30
810

api/streaming.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2576,6 +2576,55 @@ def cancel_stream(stream_id: str) -> bool:
25762576
with _get_session_agent_lock(_cancel_session_id):
25772577
try:
25782578
_cs = get_session(_cancel_session_id)
2579+
# ── Preserve the user's typed message before clearing pending state (#1298) ──
2580+
# The agent's internal messages list (where the user message was appended at
2581+
# the start of run_conversation()) may not have been merged back into
2582+
# _cs.messages yet — cancel_stream() races with the streaming thread's final
2583+
# _merge_display_messages_after_agent_result() call. Without this guard, the
2584+
# user's message is lost: pending_user_message gets cleared below, and
2585+
# _cs.messages still only contains messages from prior turns. The reporter
2586+
# of #1298 sees their typed text vanish from chat after clicking Stop.
2587+
#
2588+
# Recovery rule: if pending_user_message is set AND the latest message in
2589+
# _cs.messages isn't already a matching user turn, synthesize one. The
2590+
# match check guards against double-append when the streaming thread DID
2591+
# reach its merge step before cancel_stream() got the session lock.
2592+
#
2593+
# Wrapped in its own try/except so an unexpected _cs.messages shape (e.g.
2594+
# in unit tests using Mock sessions) cannot escape and skip the rest of
2595+
# the cleanup.
2596+
try:
2597+
_pending_user = getattr(_cs, 'pending_user_message', None)
2598+
_pending_atts_raw = getattr(_cs, 'pending_attachments', None)
2599+
_pending_atts = list(_pending_atts_raw) if isinstance(_pending_atts_raw, (list, tuple)) else []
2600+
_msgs_for_recovery = _cs.messages if isinstance(_cs.messages, list) else None
2601+
if _pending_user and _msgs_for_recovery is not None:
2602+
_last_user = None
2603+
for _m in reversed(_msgs_for_recovery):
2604+
if isinstance(_m, dict) and _m.get('role') == 'user':
2605+
_last_user = _m
2606+
break
2607+
_already_persisted = False
2608+
if _last_user is not None:
2609+
_last_content = _last_user.get('content')
2610+
if isinstance(_last_content, str):
2611+
# Tolerate the workspace prefix the streaming thread prepends.
2612+
if _pending_user in _last_content or _last_content in _pending_user:
2613+
_already_persisted = True
2614+
if not _already_persisted:
2615+
_user_turn: dict = {
2616+
'role': 'user',
2617+
'content': _pending_user,
2618+
'timestamp': int(time.time()),
2619+
}
2620+
if _pending_atts:
2621+
_user_turn['attachments'] = _pending_atts
2622+
_msgs_for_recovery.append(_user_turn)
2623+
except Exception:
2624+
logger.debug(
2625+
"Failed to recover pending user message on cancel for %s",
2626+
_cancel_session_id,
2627+
)
25792628
_cs.active_stream_id = None
25802629
_cs.pending_user_message = None
25812630
_cs.pending_attachments = []

static/ui.js

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2530,6 +2530,30 @@ function _thinkingActivityNode(text){
25302530
row.innerHTML=_thinkingCardHtml(text);
25312531
return row;
25322532
}
2533+
// ── Activity-group user expand intent (#1298) ──────────────────────────────
2534+
// When the user manually expands the live "Activity" dropdown during streaming,
2535+
// preserve that intent across the destroy/recreate cycle that fires on every
2536+
// thinking/tool event. Without this, ensureActivityGroup() re-creates the group
2537+
// with the default collapsed state and finalizeThinkingCard() force-collapses
2538+
// it whenever the assistant transitions from thinking → tool → thinking, so
2539+
// the panel snaps shut every few seconds while the user is trying to read it.
2540+
//
2541+
// The tracker is a singleton boolean: there is at most one live activity group
2542+
// at a time (selector .tool-call-group[data-live-tool-call-group="1"]). It is
2543+
// set to true when the user clicks the summary to expand, false when they
2544+
// click to collapse, and cleared back to undefined when the live group is
2545+
// finalized into a settled assistant turn (the live attribute is removed in
2546+
// _convertLiveActivityGroupToSettled / when liveAssistantTurn loses its id).
2547+
let _liveActivityUserExpanded;
2548+
function _onLiveActivityToggle(group){
2549+
if(!group) return;
2550+
// Only track explicit user clicks on the live group, not programmatic toggles.
2551+
if(group.getAttribute('data-live-tool-call-group')!=='1') return;
2552+
_liveActivityUserExpanded = !group.classList.contains('tool-call-group-collapsed');
2553+
}
2554+
function _clearLiveActivityUserIntent(){
2555+
_liveActivityUserExpanded = undefined;
2556+
}
25332557
function ensureActivityGroup(inner, opts){
25342558
opts=opts||{};
25352559
if(!inner) return null;
@@ -2538,12 +2562,16 @@ function ensureActivityGroup(inner, opts){
25382562
let group=inner.querySelector(selector);
25392563
if(!group){
25402564
group=document.createElement('div');
2541-
const collapsed=opts.collapsed!==false;
2565+
let collapsed=opts.collapsed!==false;
2566+
// Restore the user's explicit expand intent when recreating the live
2567+
// activity group within the same turn (#1298).
2568+
if(live && _liveActivityUserExpanded === true) collapsed=false;
2569+
else if(live && _liveActivityUserExpanded === false) collapsed=true;
25422570
group.className='tool-call-group agent-activity-group'+(collapsed?' tool-call-group-collapsed':'');
25432571
group.setAttribute('data-tool-call-group','1');
25442572
group.setAttribute('data-agent-activity-group','1');
25452573
if(live) group.setAttribute('data-live-tool-call-group','1');
2546-
group.innerHTML=`<button type="button" class="tool-call-group-summary" aria-expanded="${collapsed?'false':'true'}" onclick="const g=this.closest('.tool-call-group');const c=g.classList.toggle('tool-call-group-collapsed');this.setAttribute('aria-expanded',String(!c));"><span class="tool-call-group-chevron">${li('chevron-right',12)}</span><span class="tool-call-group-label">Activity</span><span class="tool-call-group-list">tools / thinking</span><span class="tool-call-group-count">0</span></button><div class="tool-call-group-body"></div>`;
2574+
group.innerHTML=`<button type="button" class="tool-call-group-summary" aria-expanded="${collapsed?'false':'true'}" onclick="const g=this.closest('.tool-call-group');const c=g.classList.toggle('tool-call-group-collapsed');this.setAttribute('aria-expanded',String(!c));if(typeof _onLiveActivityToggle==='function')_onLiveActivityToggle(g);"><span class="tool-call-group-chevron">${li('chevron-right',12)}</span><span class="tool-call-group-label">Activity</span><span class="tool-call-group-list">tools / thinking</span><span class="tool-call-group-count">0</span></button><div class="tool-call-group-body"></div>`;
25472575
const anchor=opts.anchor||null;
25482576
if(anchor&&anchor.parentElement===inner) anchor.insertAdjacentElement('afterend', group);
25492577
else inner.appendChild(group);
@@ -3462,6 +3490,9 @@ function appendLiveToolCard(tc){
34623490
function clearLiveToolCards(){
34633491
const inner=_assistantTurnBlocks($('liveAssistantTurn'));
34643492
if(inner) inner.querySelectorAll('.tool-call-group[data-live-tool-call-group],.tool-card-row[data-live-tid]').forEach(el=>el.remove());
3493+
// Reset the per-turn user expand intent so the next turn starts at the
3494+
// default collapsed state (#1298).
3495+
if(typeof _clearLiveActivityUserIntent==='function') _clearLiveActivityUserIntent();
34653496
// Legacy #liveToolCards container cleanup — kept for safety in case any
34663497
// leftover cards were inserted there before this refactor took effect.
34673498
const container=$('liveToolCards');
@@ -4141,9 +4172,15 @@ function finalizeThinkingCard(){
41414172
const turn=$('liveAssistantTurn');
41424173
const group=turn&&turn.querySelector('.tool-call-group[data-live-tool-call-group="1"]');
41434174
if(group){
4144-
group.classList.add('tool-call-group-collapsed');
4145-
const summary=group.querySelector('.tool-call-group-summary');
4146-
if(summary) summary.setAttribute('aria-expanded','false');
4175+
// Respect the user's explicit expand intent (#1298) — only force-collapse
4176+
// when the user has not manually expanded this turn's activity group, or
4177+
// has manually collapsed it. Otherwise the panel snaps shut whenever new
4178+
// activity arrives, even mid-read.
4179+
if(_liveActivityUserExpanded !== true){
4180+
group.classList.add('tool-call-group-collapsed');
4181+
const summary=group.querySelector('.tool-call-group-summary');
4182+
if(summary) summary.setAttribute('aria-expanded','false');
4183+
}
41474184
const active=group.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
41484185
if(active) active.removeAttribute('data-thinking-active');
41494186
_syncToolCallGroupSummary(group);

0 commit comments

Comments
 (0)