Files
cowork-local/ui/sidebar.py
T
anhtnm1andClaude Opus 5 e29a0ccdbd refactor: vá 4 hồi quy, tách 4 file chạm trần LOC, docstring lên 100%
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>
2026-08-30 10:41:45 +09:00

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