diff --git a/presentation/chat/chat_helpers.py b/presentation/chat/chat_helpers.py index 72b3881..09b47ed 100644 --- a/presentation/chat/chat_helpers.py +++ b/presentation/chat/chat_helpers.py @@ -9,6 +9,13 @@ from typing import Any, Dict, List, Optional _PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"} +HEADER_TITLE_MAX_CHARS = 10 # thanh tiêu đề khung chat chỉ hiện tối đa ngần này ký tự + + +def clip_chars(text: str, limit: int = HEADER_TITLE_MAX_CHARS) -> str: + """Giữ tối đa ``limit`` ký tự, dư thì cắt và thêm "…".""" + return text if len(text) <= limit else text[:limit].rstrip() + "…" + def _format_plan_steps(steps) -> str: diff --git a/presentation/chat/chat_session_store.py b/presentation/chat/chat_session_store.py index c8a1ea4..e0ec135 100644 --- a/presentation/chat/chat_session_store.py +++ b/presentation/chat/chat_session_store.py @@ -224,6 +224,23 @@ class ChatSessionMixin: if getattr(self, "_usage_total_lbl", None) is not None: self.refresh_usage() + def set_title_label(self, lbl, fallback: str) -> None: + """Đặt tiêu đề hội thoại lên nhãn: tối đa 10 ký tự, bản đầy đủ ở tooltip.""" + from .chat_helpers import clip_chars + + full = self.title or fallback + lbl.setText(clip_chars(full)) + lbl.setToolTip(full) + + def apply_renamed_title(self, session_id: str, title: str) -> None: + """Hội thoại đang mở vừa được đổi tên ở cột lịch sử: đổi luôn ``self.title``. + + Không chỉ để thanh tiêu đề cập nhật ngay — lượt chat kế tiếp lưu bằng + ``self.title``, giữ tên cũ sẽ ghi đè mất tên người dùng vừa đặt.""" + if session_id and session_id == self.session_id: + self.title = title + self._notify_title() + def load_conversation(self, conv: Dict[str, Any]) -> None: """Switch the view to a stored conversation. Allowed while work is running — the current turns keep going in the background.""" diff --git a/tests/integration/test_cowork_rename_title.py b/tests/integration/test_cowork_rename_title.py new file mode 100644 index 0000000..a28c4e8 --- /dev/null +++ b/tests/integration/test_cowork_rename_title.py @@ -0,0 +1,95 @@ +"""Đổi tên hội thoại ở cột lịch sử thì thanh tiêu đề Cowork đổi theo ngay. + +Trước đây nhãn tiêu đề giữ tên cũ cho tới khi người dùng mở lại hội thoại, và +``self.title`` cũ còn ghi đè tên mới ở lượt chat kế tiếp. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from cowork_local.config import AppConfig # noqa: E402 +from cowork_local.presentation.chat.chat_helpers import clip_chars # noqa: E402 +from cowork_local.state import AppContext # noqa: E402 + +pytest.importorskip("PySide6", reason="Qt is required for the integration suite") + + +@pytest.fixture(scope="module") +def qt_app(): + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) + + +@pytest.fixture +def cowork(qt_app, tmp_path: Path): + from cowork_local.ui.cowork_tab import CoworkTab + + tab = CoworkTab(AppContext(AppConfig.load(tmp_path / "config.json"))) + yield tab + tab.deleteLater() + + +def test_clip_chars_keeps_ten_characters(): + assert clip_chars("Tên ngắn") == "Tên ngắn" + assert clip_chars("0123456789") == "0123456789" + assert clip_chars("0123456789AB") == "0123456789…" + assert clip_chars("Dự án mới toanh") == "Dự án mới…" # không để khoảng trắng trước "…" + + +def test_rename_of_open_conversation_updates_header(cowork): + cowork.load_conversation({"session_id": "s1", "title": "Tên cũ", "messages": []}) + assert cowork._title_lbl.text() == "Tên cũ" + + cowork.apply_renamed_title("s1", "Tên mới") + + assert cowork.title == "Tên mới" # lượt chat sau lưu bằng tên mới + assert cowork._title_lbl.text() == "Tên mới" + + +def test_rename_of_other_conversation_is_ignored(cowork): + cowork.load_conversation({"session_id": "s1", "title": "Đang mở", "messages": []}) + cowork.apply_renamed_title("s2", "Khác") + assert cowork._title_lbl.text() == "Đang mở" + + +def test_long_title_is_clipped_with_full_tooltip(cowork): + long_title = "Báo cáo doanh thu quý 3" + cowork.load_conversation({"session_id": "s1", "title": "x", "messages": []}) + cowork.apply_renamed_title("s1", long_title) + assert cowork._title_lbl.text() == "Báo cáo do…" + assert cowork._title_lbl.toolTip() == long_title + + +def test_history_list_clips_title_to_ten_chars(qt_app, tmp_path): + from PySide6.QtCore import Qt + + from cowork_local.core.history import save_conversation + from cowork_local.ui.sidebar import HistorySidebar + + ctx = AppContext(AppConfig.load(tmp_path / "config.json")) + ctx.config.data.setdefault("history", {})["custom_dir"] = str(tmp_path / "history") + assert ctx.config.history_dir() == tmp_path / "history" # không đọc lịch sử thật + title = "Báo cáo doanh thu quý 3" + save_conversation(tmp_path / "history", "cowork", "s1", + [{"role": "user", "content": "hi"}], title, project_id="p-test") + sb = HistorySidebar(ctx) + sb._project_filter = "p-test" # chỉ đọc history_dir() tạm, không gộp các project thật + sb.refresh() + items = [] + stack = [sb.tree.topLevelItem(i) for i in range(sb.tree.topLevelItemCount())] + while stack: + it = stack.pop() + if it.data(0, Qt.UserRole): + items.append(it) + stack.extend(it.child(i) for i in range(it.childCount())) + assert len(items) == 1 + assert items[0].text(0).splitlines()[0] == "Báo cáo do…" + assert items[0].toolTip(0) == title + assert items[0].data(0, Qt.UserRole + 3) == title # đổi tên vẫn điền tên đầy đủ + sb.deleteLater() diff --git a/ui/cowork_tab.py b/ui/cowork_tab.py index 5b00879..997a9fa 100644 --- a/ui/cowork_tab.py +++ b/ui/cowork_tab.py @@ -95,7 +95,7 @@ class CoworkTab(ChatPanel): lbl = getattr(self, "_title_lbl", None) if lbl is None: return # ChatPanel.__init__ sets self.title before we exist - lbl.setText(getattr(self, "title", "") or tr("cowork.title")) + self.set_title_label(lbl, tr("cowork.title")) def _retranslate(self) -> None: """Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và các nút trên thanh công cụ.""" diff --git a/ui/sidebar.py b/ui/sidebar.py index 0c8c790..ed0fdc7 100644 --- a/ui/sidebar.py +++ b/ui/sidebar.py @@ -14,6 +14,7 @@ from ..core.history import ( rename_conversation, set_pinned, ) from ..i18n import on_language_changed, tr +from ..presentation.chat.chat_helpers import clip_chars from .dialog_buttons import ask_text, confirm from .icons import collapse_left_icon, dot_icon, DOT_BLUE, icon from .widgets import CollapseStrip @@ -81,6 +82,7 @@ class HistorySidebar(QWidget): expand_requested = Signal() # strip clicked: re-expand refresh_requested = Signal() # Refresh button: re-list + re-sync agent status history_changed = Signal() # a conversation was deleted — other views (Project tab) should re-sync + conversation_renamed = Signal(str, str) # session_id, new title — the open chat retitles itself def __init__(self, ctx: AppContext): """Cột lịch sử hội thoại. @@ -273,7 +275,9 @@ class HistorySidebar(QWidget): is_current = bool(sid) and sid == self.current_session_id is_running = sid in self.running_ids suffix = tr("sidebar.running_suffix") if is_running else "" - item = QTreeWidgetItem([f"{title}{suffix}\n{created}"]) + # Tối đa 10 ký tự như tiêu đề khung chat; tên đầy đủ ở tooltip. + item = QTreeWidgetItem([f"{clip_chars(title)}{suffix}\n{created}"]) + item.setToolTip(0, title) # A running turn (blue LED) takes visual priority over the pin icon. if is_running: item.setIcon(0, dot_icon(DOT_BLUE)) @@ -352,6 +356,8 @@ class HistorySidebar(QWidget): if ok and new.strip(): rename_conversation(path, new.strip()) self.refresh() + sid = load_conversation(path).get("session_id", "") + self.conversation_renamed.emit(sid, new.strip()) elif chosen == del_act: if confirm(self, tr("sidebar.delete.title"), tr("sidebar.delete.confirm", title=title)): diff --git a/ui/workspace_tab.py b/ui/workspace_tab.py index 96b4e9c..14a5bbd 100644 --- a/ui/workspace_tab.py +++ b/ui/workspace_tab.py @@ -339,6 +339,8 @@ class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget): sb.expand_requested.connect(lambda: self._on_history_fold(False)) sb.refresh_requested.connect(self._on_sidebar_refresh) sb.history_changed.connect(self._reload_threads) + if self._cowork is not None: + sb.conversation_renamed.connect(self._cowork.apply_renamed_title) def _on_history_fold(self, collapsed: bool) -> None: """Its chevron closes the panel away, back to the drawn layout."""