diff --git a/frontends/stapp.py b/frontends/stapp.py index f8727151..83658f29 100644 --- a/frontends/stapp.py +++ b/frontends/stapp.py @@ -434,20 +434,20 @@ if prompt: with st.chat_message("user"): st.markdown(prompt) _start_main_task(prompt) -# Stream hosts only when this session owns the queue. -# Poll quickly while active; reduce idle renderer churn. -_stream_fh = None -if st.session_state.get('display_queue') is not None: - with st.chat_message("assistant"): - _stream_fh = st.container() -elif agent.is_running: +# Stream bubble is owned by the fragment below: a fragment atomically replaces +# its *own* subtree on every rerun in all Streamlit versions, whereas writing +# into a container created outside the fragment is version-dependent (pre-1.62 +# appends forever → duplicates; ≥1.62 resets/GCs → disappears). So never hoist +# the host out of the fragment; paint the full desired state each tick. +_owns_stream = st.session_state.get('display_queue') is not None +if not _owns_stream and agent.is_running: st.chat_message("assistant").markdown(T("detached_running")) -@st.fragment(run_every=timedelta(seconds=1 if (_stream_fh is not None or agent.is_running or st.session_state.get('loop_enabled')) else 5)) +@st.fragment(run_every=timedelta(seconds=1 if (_owns_stream or agent.is_running or st.session_state.get('loop_enabled')) else 5)) def _tick(): """Poll every second while active and every five seconds while idle.""" # 1) Own stream: drain done, paint all_outputs - if _stream_fh is not None: + if _owns_stream: done = _poll_main_task() if done is not None: if done: @@ -466,16 +466,12 @@ def _tick(): _dq = st.session_state.get("display_queue") steps = (list(((agent.all_outputs or [{}])[-1].get("outputs")) or []) if _dq is getattr(agent, "_current_queue", None) else []) - # Streamlit ≥1.62: a fragment's outside-container wrapper resets to - # index 0 on every fragment rerun and children not re-emitted are GC'd - # (see runtime/fragment.py:_reset_outside_wrappers). Incremental appends - # don't survive across ticks, so repaint *everything* every tick — frozen - # expanders plus the live tail — into the single host container. Always - # writing (even when steps is empty) also claims the slot on the initial - # run, avoiding "could not reserve a stable position". live = re.sub(r'\**LLM Running \(Turn \d+\) \.\.\.\**\s*$', '', (steps[-1] if steps else '') or '').rstrip() - with _stream_fh: + # Idempotent repaint inside the fragment's own subtree: the whole + # bubble (expanders + live tail) is re-emitted from state every tick, + # so a rerun can neither drop nor duplicate elements. + with st.chat_message("assistant"): for i in range(max(0, len(steps) - 1)): body = steps[i] or '' with st.expander(_step_title(body, i), expanded=False): st.markdown(body)