Lịch sử hội thoại KHÔNG nằm chung một chỗ: _bind_project đặt config._project_history_dir thành <workspace của project>/.cowork_history mỗi lần chọn project khác — cố ý, để chia sẻ thư mục project là chia sẻ cả lịch sử. Nhưng hai chỗ đọc lại chỉ đọc MỘT thư mục, gây hai triệu chứng cùng gốc: - ui/sidebar.py đọc history_dir() (thư mục của project ĐANG mở), nên khung "Tất cả project…" dựng đủ tiêu đề nhóm mà mọi nhóm trừ một đều rỗng; - _project_counts gọi list_conversations() KHÔNG tham số, tức đọc HISTORY_DIR toàn cục nơi không có hội thoại nào của project, nên mọi dòng project đều đếm "0 đoạn chat". Thêm history_dirs() + list_conversations_by_project(), giữ đúng thứ tự cũ (ghim trước, mới nhất trước) và chống trùng. Thư mục là chủ sở hữu có thẩm quyền: hội thoại nằm trong workspace của project nào thì thuộc project đó, kể cả khi trường project_id trong file đã cũ vì project bị đổi thư mục. HISTORY_SUBDIR + project_history_dir() gom đường dẫn về một định nghĩa duy nhất — chuỗi ".cowork_history" từng nằm rải trong ui/workspace_tab.py. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
387 lines
18 KiB
Python
387 lines
18 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)
|
|
|
|
# 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 |