fix(chat): đổi tên hội thoại thì tiêu đề khung chat đổi theo ngay
Đổi tên một hội thoại ở cột lịch sử (chuột phải → Đổi tên) thì tiêu đề khung chat bên phải vẫn giữ tên cũ, phải click ra ngoài rồi click lại mới đổi. Tệ hơn, self.title cũ vẫn nằm trong bộ nhớ nên lượt chat kế tiếp lưu đè tên cũ lên tên người dùng vừa đặt. - HistorySidebar phát conversation_renamed(session_id, title); WorkspaceTab nối vào apply_renamed_title của Cowork. Hội thoại đang mở thì đổi luôn self.title và làm mới thanh tiêu đề. - Tiêu đề hội thoại chỉ hiện tối đa 10 ký tự, dư thì "…", tên đầy đủ ở tooltip. Áp cho cả thanh tiêu đề khung chat lẫn danh sách hội thoại trong project (dùng chung clip_chars). Hộp thoại Đổi tên vẫn điền tên đầy đủ. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ddb31f9aff
commit
fefc9f94db
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
+1
-1
@@ -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ụ."""
|
||||
|
||||
+7
-1
@@ -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)):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user