"""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) # Loc theo mot project -> doc dung thu muc cua no. Khong loc ("Tat ca # project…") -> phai doc thu muc lich su cua TUNG project roi gop lai: # lich su nam trong thu muc lam viec cua project, nen mot lan goi # list_conversations chi thay duoc project dang mo, va moi nhom con lai # hien ra rong tuy nguoi dung da chat trong do. try: if self._project_filter: convos = list_conversations(self.ctx.config.history_dir(), query=query) else: from ..core.history import history_dirs, list_conversations_by_project convos = list_conversations_by_project(history_dirs(), 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