refactor(chat): tách nốt phần nối lại lượt đang chạy — chat_session_store 414 -> 352

chat_live_turns.py (90 dòng) là phần tinh tế nhất của khung chat: người dùng
mở phiên khác rồi quay lại trong khi lượt cũ vẫn đang chạy. Phải nối vào đúng
luồng đó và đúng danh sách tin nhắn đang sống, chứ không đọc bản trên đĩa (đã
cũ) hay khởi động lại. Sai thì hoặc mất phần agent viết lúc mình vắng mặt,
hoặc hai bên cùng ghi vào một file.

Giờ Gamma không còn file production nào vượt 400 dòng.

756 test xanh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-28 01:11:22 +09:00
co-authored by Claude Opus 5
parent 577b81a641
commit fdaedfa1c2
3 changed files with 92 additions and 63 deletions
+90
View File
@@ -0,0 +1,90 @@
"""Nối lại lượt đang chạy khi người dùng quay về phiên cũ — R08-T06.
Phần tinh tế nhất của khung chat. Người dùng mở phiên khác rồi quay lại trong
khi lượt cũ VẪN đang chạy: phải nối vào đúng luồng đó và đúng danh sách tin
nhắn đang sống, chứ không được đọc bản trên đĩa (đã cũ) hay khởi động lại.
``_detach_live_turns`` gỡ ra khi rời phiên, ``_reattach_running_turn`` nối
lại khi quay về. Sai một trong hai thì hoặc mất phần agent viết trong lúc
vắng mặt, hoặc hai bên cùng ghi vào một file.
Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QMessageBox
from ...core.worker import AgentWorker
from ...i18n import tr
class ChatLiveTurnsMixin:
"""Nối lại lượt đang chạy. Trộn vào ChatPanel."""
def _detach_live_turns(self) -> None:
"""Before switching away from the current conversation, turn its running
turns into background jobs: they stop rendering into the (about-to-be-
cleared) transcript but keep running and save to their own conversation."""
for c in self._active.values():
if c.get("home_id") == self.session_id:
c["detached"] = True
c["assistant"] = None # its bubbles are about to be cleared
def _running_ctx_for(self, session_id: str) -> Optional[Dict[str, Any]]:
"""The in-progress turn's context for a conversation (one at a time), or None."""
for c in self._active.values():
if c.get("home_id") == session_id:
return c
return None
def _reattach_running_turn(self, ctx: Dict[str, Any]) -> None:
"""Re-render an in-progress turn into the current transcript and re-attach it
so it keeps streaming live — used when reopening a running conversation, so
the user sees the CURRENT task (message + steps so far + live plan), not just
the last saved state."""
record = ctx["record"]
record["bubbles"] = [] # the old bubbles were cleared on the view switch
# 1) the user's message that is being processed
ub = self.chat_view.add_user(ctx.get("display_text") or "(attachment)")
record["bubbles"].append(ub)
# 2) steps already completed this turn (assistant text / tool results); found
# by identity after the user message (a system prompt may sit before it).
# Snapshot the list — the worker thread may still be appending to it.
msgs = list(ctx.get("messages", []))
ui = next((i for i, m in enumerate(msgs) if m is ctx.get("user_msg")), -1)
for m in (msgs[ui + 1:] if ui >= 0 else []):
role = m.get("role")
if role == "assistant" and (m.get("content") or "").strip():
b = self.chat_view.add_assistant(self.assistant_title())
b.set_markdown(m["content"])
record["bubbles"].append(b)
elif role == "tool":
b = self.chat_view.add_tool(m.get("name", "tool"), m.get("content", ""), True)
record["bubbles"].append(b)
# 3) the live plan checklist (if any) — inline, expandable
steps = ctx.get("plan_steps") or []
if steps:
self.on_plan(steps)
from ...ui.chat_panel import _format_plan_steps
pb = self.chat_view.add_plan(_format_plan_steps(steps))
record["bubbles"].append(pb)
ctx["plan_bubble"] = pb
# 4) the partial answer of the step currently streaming — re-attach so new
# deltas keep appending to this bubble.
ctx["assistant"] = None
ctx["reasoning"] = None
if (ctx.get("partial") or "").strip():
ab = self.chat_view.add_assistant(self.assistant_title())
ab.set_markdown(ctx["partial"])
record["bubbles"].append(ab)
ctx["assistant"] = ab
# 5) live again → future events render here
ctx["detached"] = False
self.chat_view.scroll_to_bottom()
def running_session_ids(self):
"""Set of conversation ids that currently have a turn running (for the
History status markers)."""
return set(self._sessions_live)
-62
View File
@@ -60,10 +60,6 @@ class ChatSessionMixin:
history_dir=ctx.get("home_history_dir"))
self.history_changed.emit()
def running_session_ids(self):
"""Set of conversation ids that currently have a turn running (for the
History status markers)."""
return set(self._sessions_live)
def _usage_label(self) -> str:
return self.title or self.session_id
@@ -352,63 +348,5 @@ class ChatSessionMixin:
pct = int(_tok([digest]) * 100 / old_tok)
self.status_message.emit(tr("chatpanel.compress_reduced", pct=pct, n=len(old)))
def _detach_live_turns(self) -> None:
"""Before switching away from the current conversation, turn its running
turns into background jobs: they stop rendering into the (about-to-be-
cleared) transcript but keep running and save to their own conversation."""
for c in self._active.values():
if c.get("home_id") == self.session_id:
c["detached"] = True
c["assistant"] = None # its bubbles are about to be cleared
def _running_ctx_for(self, session_id: str) -> Optional[Dict[str, Any]]:
"""The in-progress turn's context for a conversation (one at a time), or None."""
for c in self._active.values():
if c.get("home_id") == session_id:
return c
return None
def _reattach_running_turn(self, ctx: Dict[str, Any]) -> None:
"""Re-render an in-progress turn into the current transcript and re-attach it
so it keeps streaming live — used when reopening a running conversation, so
the user sees the CURRENT task (message + steps so far + live plan), not just
the last saved state."""
record = ctx["record"]
record["bubbles"] = [] # the old bubbles were cleared on the view switch
# 1) the user's message that is being processed
ub = self.chat_view.add_user(ctx.get("display_text") or "(attachment)")
record["bubbles"].append(ub)
# 2) steps already completed this turn (assistant text / tool results); found
# by identity after the user message (a system prompt may sit before it).
# Snapshot the list — the worker thread may still be appending to it.
msgs = list(ctx.get("messages", []))
ui = next((i for i, m in enumerate(msgs) if m is ctx.get("user_msg")), -1)
for m in (msgs[ui + 1:] if ui >= 0 else []):
role = m.get("role")
if role == "assistant" and (m.get("content") or "").strip():
b = self.chat_view.add_assistant(self.assistant_title())
b.set_markdown(m["content"])
record["bubbles"].append(b)
elif role == "tool":
b = self.chat_view.add_tool(m.get("name", "tool"), m.get("content", ""), True)
record["bubbles"].append(b)
# 3) the live plan checklist (if any) — inline, expandable
steps = ctx.get("plan_steps") or []
if steps:
self.on_plan(steps)
from ...ui.chat_panel import _format_plan_steps
pb = self.chat_view.add_plan(_format_plan_steps(steps))
record["bubbles"].append(pb)
ctx["plan_bubble"] = pb
# 4) the partial answer of the step currently streaming — re-attach so new
# deltas keep appending to this bubble.
ctx["assistant"] = None
ctx["reasoning"] = None
if (ctx.get("partial") or "").strip():
ab = self.chat_view.add_assistant(self.assistant_title())
ab.set_markdown(ctx["partial"])
record["bubbles"].append(ab)
ctx["assistant"] = ab
# 5) live again → future events render here
ctx["detached"] = False
self.chat_view.scroll_to_bottom()
+2 -1
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
from ..presentation.chat.chat_event_stream import ChatEventStreamMixin
from ..presentation.chat.chat_panel_layout import ChatPanelLayoutMixin
from ..presentation.chat.chat_live_turns import ChatLiveTurnsMixin
from ..presentation.chat.chat_helpers import ( # noqa: F401 — giữ đường vào cũ
_TOOL_STATUS, _format_plan_steps, _is_scratch,
)
@@ -55,7 +56,7 @@ _PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"
class ChatPanel(ChatPanelLayoutMixin, ChatEventStreamMixin, AttachmentMixin,
class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin, AttachmentMixin,
OutputPanelMixin,
ChatAgentsMixin,
ChatTurnRunnerMixin,