Rework the Feishu frontend so each user turn renders as a single
collapsible task card that patches itself in place, replacing the
dq-based streaming path that produced many fragmented messages.
- One _TaskCard per turn; hook reacts to summary / exit_reason events
from the agent loop and patches the same card.
- Each step is a foldable panel: header shows the summary, expanding
reveals three sections (auto-hidden when empty):
* Thinking - from response.thinking (separate field, not content)
* Tool Calls - tool name + truncated JSON args
* Output - response.content, with protocol tags stripped so
the header summary is not duplicated inside
- Final reply rendered as a schema 2.0 markdown card for consistency.
- Code-review pass per code_review_principles.md:
* _TaskCard owns only stateful card lifecycle (start/step/done/fail)
* Pure formatting extracted to module-level _build_step_detail and
_fmt_tool_call (no more reaching into card._private from the hook)
* Hook is a ~10-line dispatcher
* Flattened a 4-level nested lambda into a named function
Replace full-redraw approach with frozen/live slot pattern:
- Completed segments are frozen into individual st.empty() slots
- Only the last (active) segment is re-rendered on each tick
- Reduces flicker and improves performance for long multi-turn conversations
- agent_loop: next_prompt_patcher -> turn_end_callback with full context
- agent_loop: exit logic unified (break + callback), no early return
- ga: summary extraction moved from tool_after_callback to turn_end_callback
- ga: _turn_end_hooks support for external subscribers
- stapp: desktop pet button with HTTP status push
- keychain: XOR-masked secret storage with SecretStr
- gitignore: whitelist keychain.py
Root cause: stream loop emitted a bare st.empty() heartbeat per tick, accumulating
dozens of empty slots under chat_message. On the next rerun the history replay
path had no such slots, so Streamlit's incremental DOM diff misaligned and left
the previous render of the last text block as a grayed-out ghost.
Fix: unify streaming and history replay under the same shell
(slot = st.empty(); with slot.container(): render_segments(...)). Heartbeat now
re-enters the same slot each tick; when response is unchanged Streamlit's diff
is a no-op (no flicker), but the container() call still lets StopException
propagate so abort keeps working. Simplified render_segments from 5 params
(placeholders/rendered_cache/force_text) to 2 (segments, suffix).
Net: +19 -20 lines. Verified: no ghost, abort interrupts mid-stream, per-turn
fold-collapse preserved.
- stapp.py: add st.empty() after render_segments to force Streamlit StopException check on every iteration (incl. heartbeat)
- agentmain.py: fix nround type check, fix timeout comment