Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
378 lines
17 KiB
Python
378 lines
17 KiB
Python
"""Left sidebar listing previous Cowork conversations."""
|
|
from __future__ import annotations
|
|
|
|
from PySide6.QtCore import Qt, Signal
|
|
from PySide6.QtGui import QColor, QPainter, QPen
|
|
from PySide6.QtWidgets import (
|
|
QAbstractItemView, QApplication, QHBoxLayout, QInputDialog, QLabel,
|
|
QLineEdit, QMenu, QMessageBox, QPushButton, QStyle, QStyledItemDelegate,
|
|
QStyleOptionViewItem, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,
|
|
)
|
|
|
|
from ..core.history import (
|
|
delete_conversation, list_conversations, load_conversation,
|
|
rename_conversation, set_pinned,
|
|
)
|
|
from ..i18n import on_language_changed, tr
|
|
from .icons import collapse_left_icon, dot_icon, DOT_BLUE, icon
|
|
from .widgets import CollapseStrip
|
|
from ..state import AppContext
|
|
|
|
|
|
|
|
class _SessionDelegate(QStyledItemDelegate):
|
|
"""Draw each conversation as a rounded, bordered card so sessions read as
|
|
separate blocks. Group headers (rows with no stored path) keep the default
|
|
look."""
|
|
|
|
def paint(self, painter, option, index): # noqa: N802
|
|
"""Vẽ một dòng hội thoại: tiêu đề, chấm trạng thái và nền của dòng đang mở.
|
|
|
|
Dòng tiêu đề nhóm (không mang dữ liệu hội thoại) để Qt vẽ mặc định.
|
|
"""
|
|
if not index.data(Qt.UserRole): # group header → default
|
|
super().paint(painter, option, index)
|
|
return
|
|
is_current = bool(index.data(Qt.UserRole + 4))
|
|
painter.save()
|
|
painter.setRenderHint(QPainter.Antialiasing)
|
|
rect = option.rect.adjusted(3, 3, -5, -3)
|
|
if is_current:
|
|
# The conversation currently on screen — a strong, persistent highlight
|
|
# (deep ocean blue border + filled bg) regardless of Qt's transient selection.
|
|
bg, border = QColor(0, 150, 199, 70), QColor(0, 150, 199, 220)
|
|
elif option.state & QStyle.State_Selected:
|
|
bg, border = QColor(0, 150, 199, 48), QColor(0, 150, 199, 170)
|
|
elif option.state & QStyle.State_MouseOver:
|
|
bg, border = QColor(140, 146, 152, 40), QColor(140, 146, 152, 120)
|
|
else:
|
|
bg, border = QColor(140, 146, 152, 22), QColor(140, 146, 152, 85)
|
|
painter.setBrush(bg)
|
|
painter.setPen(QPen(border, 2 if is_current else 1))
|
|
painter.drawRoundedRect(rect, 10, 10)
|
|
painter.restore()
|
|
|
|
opt = QStyleOptionViewItem(option)
|
|
self.initStyleOption(opt, index)
|
|
opt.rect = rect.adjusted(8, 0, -6, 0)
|
|
opt.state &= ~QStyle.State_Selected
|
|
opt.state &= ~QStyle.State_MouseOver
|
|
widget = option.widget
|
|
style = widget.style() if widget else QApplication.style()
|
|
style.drawControl(QStyle.CE_ItemViewItem, opt, painter, widget)
|
|
|
|
def sizeHint(self, option, index): # noqa: N802
|
|
"""Dòng hội thoại cao hơn dòng nhóm 16px để có chỗ cho dòng phụ."""
|
|
size = super().sizeHint(option, index)
|
|
if index.data(Qt.UserRole):
|
|
size.setHeight(size.height() + 16)
|
|
return size
|
|
|
|
|
|
class HistorySidebar(QWidget):
|
|
"""Cột lịch sử hội thoại: tìm kiếm, gom nhóm theo project, ghim, xoá hàng loạt.
|
|
|
|
Gập lại được thành một dải mỏng để nhường chỗ cho khung chat.
|
|
"""
|
|
new_chat = Signal(str) # kind
|
|
open_chat = Signal(str, dict) # kind, conversation
|
|
collapse_requested = Signal() # in-header button: collapse to a strip
|
|
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
|
|
|
|
def __init__(self, ctx: AppContext):
|
|
"""Cột lịch sử hội thoại.
|
|
|
|
Nhúng trong tab Cowork của một dự án thì chỉ hiện hội thoại của dự án ấy;
|
|
``_project_filter`` rỗng nghĩa là hiện tất cả, gom theo dự án.
|
|
"""
|
|
super().__init__()
|
|
self.ctx = ctx
|
|
self.current_session_id = "" # conversation currently on screen (highlighted)
|
|
self.running_ids: set = set() # conversations with a turn running (status dot)
|
|
# When History is embedded inside a project's Cowork sub-tab, it shows
|
|
# ONLY that project's threads. "" = show every project (grouped).
|
|
self._project_filter = ""
|
|
self.setMinimumWidth(220)
|
|
self.setMaximumWidth(360)
|
|
|
|
root = QVBoxLayout(self)
|
|
root.setContentsMargins(0, 0, 0, 0)
|
|
root.setSpacing(0)
|
|
|
|
# Thin clickable line shown when the panel is collapsed (hidden otherwise).
|
|
self._strip = CollapseStrip(tr("sidebar.expand_tooltip"))
|
|
self._strip.clicked.connect(self.expand_requested.emit)
|
|
self._strip.setVisible(False)
|
|
root.addWidget(self._strip)
|
|
|
|
# Full panel content (hidden when collapsed). The single collapse button
|
|
# sits in the header, just left of the title.
|
|
self._content = QWidget()
|
|
root.addWidget(self._content, 1)
|
|
c = QVBoxLayout(self._content)
|
|
c.setContentsMargins(8, 10, 8, 10)
|
|
|
|
header_row = QHBoxLayout()
|
|
self._collapse_btn = QPushButton()
|
|
self._collapse_btn.setIcon(collapse_left_icon())
|
|
self._collapse_btn.setFixedWidth(28)
|
|
self._collapse_btn.clicked.connect(self.collapse_requested.emit)
|
|
self._header = QLabel()
|
|
self._header.setStyleSheet("font-weight:700; font-size:14px;")
|
|
header_row.addWidget(self._collapse_btn)
|
|
header_row.addWidget(self._header, 1)
|
|
c.addLayout(header_row)
|
|
|
|
# Search by title or message content — sits right under the header, above
|
|
# the kind filter (see refresh(), which passes the text to
|
|
# core.history.list_conversations's query filter).
|
|
search_row = QHBoxLayout()
|
|
search_row.setSpacing(4)
|
|
self.search_box = QLineEdit()
|
|
self.search_box.setClearButtonEnabled(True)
|
|
self.search_box.textChanged.connect(self.refresh)
|
|
self.search_box.returnPressed.connect(self.refresh)
|
|
self.search_btn = QPushButton("")
|
|
self.search_btn.setIcon(icon("search"))
|
|
self.search_btn.setFixedWidth(32)
|
|
self.search_btn.clicked.connect(self.refresh)
|
|
search_row.addWidget(self.search_box, 1)
|
|
search_row.addWidget(self.search_btn)
|
|
c.addLayout(search_row)
|
|
|
|
# Filter removed — no kind filtering in History sidebar
|
|
self.tree = QTreeWidget()
|
|
self.tree.setHeaderHidden(True)
|
|
self.tree.setItemDelegate(_SessionDelegate(self.tree))
|
|
self.tree.setMouseTracking(True) # hover highlight on the cards
|
|
self.tree.setIndentation(10)
|
|
self.tree.setRootIsDecorated(False) # cleaner: no branch triangles
|
|
# Shift/Ctrl-click a range or individual items to multi-select, then
|
|
# right-click → "Delete N selected" to bulk-remove conversations.
|
|
self.tree.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
|
self.tree.itemActivated.connect(self._on_item)
|
|
self.tree.itemClicked.connect(self._on_item)
|
|
self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
self.tree.customContextMenuRequested.connect(self._context_menu)
|
|
c.addWidget(self.tree, 1)
|
|
|
|
self._refresh_btn = QPushButton()
|
|
self._refresh_btn.setIcon(icon("refresh"))
|
|
# Emit a request so the app can ALSO re-sync the chat-box agent status,
|
|
# not just re-list conversations.
|
|
self._refresh_btn.clicked.connect(self.refresh_requested.emit)
|
|
c.addWidget(self._refresh_btn)
|
|
|
|
self.refresh()
|
|
on_language_changed(self._retranslate)
|
|
|
|
def _retranslate(self) -> None:
|
|
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, ô tìm và các tooltip."""
|
|
self._strip.setToolTip(tr("sidebar.expand_tooltip"))
|
|
self._collapse_btn.setToolTip(tr("sidebar.collapse_tooltip"))
|
|
self._header.setText(tr("sidebar.header"))
|
|
self.search_box.setPlaceholderText(tr("sidebar.search_placeholder"))
|
|
self.search_btn.setToolTip(tr("sidebar.search_tooltip"))
|
|
self._refresh_btn.setText(tr("sidebar.refresh"))
|
|
self._refresh_btn.setToolTip(tr("sidebar.refresh_tooltip"))
|
|
self.refresh() # re-render group headers / running suffix in the new language
|
|
|
|
def is_collapsed(self) -> bool:
|
|
"""Cột đang ở trạng thái gập (chỉ còn dải mỏng) hay không."""
|
|
return self._strip.isVisible()
|
|
|
|
def set_collapsed(self, collapsed: bool) -> None:
|
|
"""Collapse to a thin line (kept visible) or restore the full panel."""
|
|
self._content.setVisible(not collapsed)
|
|
self._strip.setVisible(collapsed)
|
|
if collapsed:
|
|
self.setMinimumWidth(CollapseStrip.WIDTH)
|
|
self.setMaximumWidth(CollapseStrip.WIDTH)
|
|
else:
|
|
self.setMinimumWidth(220)
|
|
self.setMaximumWidth(360)
|
|
|
|
def set_project_filter(self, project_id: str) -> None:
|
|
"""Restrict History to a single project's threads (used when the sidebar
|
|
is embedded in that project's Cowork sub-tab). '' shows every project."""
|
|
pid = project_id or ""
|
|
if pid == self._project_filter:
|
|
return
|
|
self._project_filter = pid
|
|
self.refresh()
|
|
|
|
def set_view_state(self, current_session_id: str, running_ids) -> None:
|
|
"""Tell the sidebar which conversation is on screen (to highlight) and which
|
|
ones have a turn running (to mark). Call before refresh()."""
|
|
self.current_session_id = current_session_id or ""
|
|
self.running_ids = set(running_ids or ())
|
|
|
|
def refresh(self) -> None:
|
|
"""Group conversations by PROJECT (Claude-Projects style): one bold
|
|
section per project, its threads beneath."""
|
|
self.tree.clear()
|
|
query = self.search_box.text() if hasattr(self, "search_box") else ""
|
|
|
|
def _make_group(label: str) -> QTreeWidgetItem:
|
|
"""Dựng một dòng tiêu đề nhóm (theo project) trong cây lịch sử."""
|
|
node = QTreeWidgetItem([label])
|
|
node.setIcon(0, icon("folder"))
|
|
node.setFirstColumnSpanned(True)
|
|
font = node.font(0)
|
|
font.setBold(True)
|
|
node.setFont(0, font)
|
|
# Not selectable — a Shift-click range must skip section headers,
|
|
# never sweep them into a "delete N selected" bulk action.
|
|
node.setFlags(node.flags() & ~Qt.ItemIsSelectable)
|
|
self.tree.addTopLevelItem(node)
|
|
node.setExpanded(True)
|
|
return node
|
|
|
|
groups = {}
|
|
try:
|
|
from ..core.projects import list_projects
|
|
projects = list_projects()
|
|
except Exception:
|
|
projects = []
|
|
if self._project_filter:
|
|
projects = [p for p in projects if p.project_id == self._project_filter]
|
|
for p in projects:
|
|
groups[p.project_id] = _make_group(p.name)
|
|
|
|
try:
|
|
convos = list_conversations(self.ctx.config.history_dir(), query=query)
|
|
except Exception:
|
|
convos = []
|
|
|
|
for meta in convos:
|
|
kind = meta.get("kind", "") or "cowork"
|
|
pid = meta.get("project_id", "") or "default"
|
|
if self._project_filter and pid != self._project_filter:
|
|
continue # embedded in one project → hide other projects' threads
|
|
parent = groups.get(pid)
|
|
if parent is None and not self._project_filter:
|
|
parent = groups.get("default")
|
|
if parent is None: # no matching group — flat fallback bucket
|
|
parent = groups.setdefault(pid, _make_group("…"))
|
|
created = (meta.get("created", "") or "").replace("T", " ")[:16]
|
|
title = meta.get("title") or tr("sidebar.empty")
|
|
pinned = bool(meta.get("pinned", False))
|
|
sid = meta.get("session_id", "")
|
|
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}"])
|
|
# A running turn (blue LED) takes visual priority over the pin icon.
|
|
if is_running:
|
|
item.setIcon(0, dot_icon(DOT_BLUE))
|
|
elif pinned:
|
|
item.setIcon(0, icon("pin"))
|
|
item.setData(0, Qt.UserRole, str(meta["path"]))
|
|
item.setData(0, Qt.UserRole + 1, kind)
|
|
item.setData(0, Qt.UserRole + 2, pinned)
|
|
item.setData(0, Qt.UserRole + 3, title)
|
|
item.setData(0, Qt.UserRole + 4, is_current) # persistent highlight
|
|
item.setData(0, Qt.UserRole + 5, is_running)
|
|
parent.addChild(item)
|
|
|
|
for _pid, node in groups.items():
|
|
if node.childCount() == 0:
|
|
text = tr("sidebar.no_matches") if query.strip() else tr("sidebar.empty")
|
|
empty = QTreeWidgetItem([text])
|
|
empty.setFlags(Qt.NoItemFlags)
|
|
node.addChild(empty)
|
|
|
|
def _on_item(self, item: QTreeWidgetItem, _column: int = 0) -> None:
|
|
# Shift/Ctrl-click means "extend the multi-selection", not "open this
|
|
# conversation" — otherwise every click while multi-selecting for a
|
|
# bulk-delete would also jump into that conversation.
|
|
"""Bấm một dòng: mở hội thoại đó.
|
|
|
|
Giữ Shift/Ctrl thì KHÔNG mở — lúc đó người dùng đang chọn nhiều dòng để
|
|
xoá hàng loạt, mở hội thoại giữa chừng sẽ phá thao tác đang làm.
|
|
"""
|
|
if QApplication.keyboardModifiers() & (Qt.ShiftModifier | Qt.ControlModifier):
|
|
return
|
|
path = item.data(0, Qt.UserRole)
|
|
kind = item.data(0, Qt.UserRole + 1)
|
|
if not path or not kind:
|
|
return
|
|
conv = load_conversation(path)
|
|
self.open_chat.emit(kind, conv)
|
|
|
|
def _selected_conversation_items(self):
|
|
"""Các dòng đang chọn thật sự là hội thoại (bỏ qua dòng tiêu đề nhóm)."""
|
|
return [it for it in self.tree.selectedItems() if it.data(0, Qt.UserRole)]
|
|
|
|
@staticmethod
|
|
def _is_multi_selection(item, selected) -> bool:
|
|
"""True when the right-clicked card is part of an existing multi-item
|
|
selection — pure boolean, kept separate from _context_menu so it's
|
|
testable without ever invoking Qt's (modal, event-loop-blocking) menu."""
|
|
return len(selected) > 1 and item in selected
|
|
|
|
def _context_menu(self, pos) -> None:
|
|
"""Menu chuột phải: đổi tên, ghim, mở thư mục, xoá — hỗ trợ chọn nhiều dòng."""
|
|
clicked = self.tree.itemAt(pos)
|
|
if clicked is None:
|
|
return
|
|
selected = self._selected_conversation_items()
|
|
if self._is_multi_selection(clicked, selected):
|
|
self._bulk_delete_menu(pos, selected)
|
|
return
|
|
item = clicked
|
|
path = item.data(0, Qt.UserRole)
|
|
if not path:
|
|
return
|
|
pinned = bool(item.data(0, Qt.UserRole + 2))
|
|
title = item.data(0, Qt.UserRole + 3) or ""
|
|
menu = QMenu(self.tree)
|
|
pin_act = menu.addAction(tr("sidebar.menu.unpin") if pinned else tr("sidebar.menu.pin"))
|
|
rename_act = menu.addAction(tr("sidebar.menu.rename"))
|
|
del_act = menu.addAction(tr("sidebar.menu.delete"))
|
|
chosen = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
|
if chosen == pin_act:
|
|
set_pinned(path, not pinned)
|
|
self.refresh()
|
|
elif chosen == rename_act:
|
|
new, ok = QInputDialog.getText(
|
|
self, tr("sidebar.rename.title"), tr("sidebar.rename.label"), text=title)
|
|
if ok and new.strip():
|
|
rename_conversation(path, new.strip())
|
|
self.refresh()
|
|
elif chosen == del_act:
|
|
if QMessageBox.question(
|
|
self, tr("sidebar.delete.title"), tr("sidebar.delete.confirm", title=title)
|
|
) == QMessageBox.Yes:
|
|
delete_conversation(path)
|
|
self.refresh()
|
|
self.history_changed.emit()
|
|
|
|
def _bulk_delete_menu(self, pos, selected) -> None:
|
|
"""Right-click on a multi-selection (Shift/Ctrl-click several
|
|
conversations first): one action deletes every selected conversation.
|
|
The popup itself is a thin wrapper — see _confirm_and_delete_selected
|
|
for the actual (independently testable) confirm+delete logic."""
|
|
menu = QMenu(self.tree)
|
|
del_act = menu.addAction(tr("sidebar.menu.delete_selected", n=len(selected)))
|
|
chosen = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
|
if chosen == del_act:
|
|
self._confirm_and_delete_selected(selected)
|
|
|
|
def _confirm_and_delete_selected(self, selected) -> bool:
|
|
"""Confirm, then delete every conversation in ``selected``. Split out
|
|
of _bulk_delete_menu so tests can drive it directly without having to
|
|
fake a real (modal, event-loop-blocking) QMenu popup."""
|
|
if QMessageBox.question(
|
|
self, tr("sidebar.delete.title"),
|
|
tr("sidebar.delete_multi.confirm", n=len(selected))) != QMessageBox.Yes:
|
|
return False
|
|
for item in selected:
|
|
path = item.data(0, Qt.UserRole)
|
|
if path:
|
|
delete_conversation(path)
|
|
self.refresh()
|
|
self.history_changed.emit()
|
|
return True |