fix(history): "Tất cả project" và số liệu mỗi project đọc đủ mọi thư mục

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>
This commit is contained in:
2026-09-07 19:55:02 +09:00
co-authored by Claude Opus 5
parent 971d39203b
commit d656c5c834
4 changed files with 230 additions and 1 deletions
+52
View File
@@ -132,6 +132,58 @@ def _matches_query(query: str, title: str, messages: List[Dict[str, Any]]) -> bo
return False
def history_dirs() -> list:
"""Các cặp ``(project_id, thư mục lịch sử)`` của MỌI project, cộng thư mục
mặc định cho hội thoại chưa thuộc project nào.
Có hàm này vì lịch sử KHÔNG nằm chung một chỗ, mà nằm trong thư mục làm việc
của từng project. Ai chỉ gọi ``list_conversations()`` một lần sẽ chỉ thấy
hội thoại của project đang mở — hoặc, nếu gọi không tham số, không thấy cái
nào cả. Đó chính là hai lỗi đã xảy ra: khung "Tất cả project…" hiện nhóm
rỗng cho mọi project trừ một, và mọi dòng project đều đếm "0 đoạn chat".
"""
from ..config import HISTORY_DIR
from .projects import list_projects, project_history_dir
pairs = [("default", HISTORY_DIR)]
for project in list_projects():
pairs.append((project.project_id, project_history_dir(project)))
return pairs
def list_conversations_by_project(pairs, query: str = "") -> List[Dict[str, Any]]:
"""Gộp lịch sử hội thoại của NHIỀU project. ``pairs`` là các cặp
``(project_id, directory)``.
Lịch sử KHÔNG nằm chung một chỗ: ``WorkspaceTab`` đặt
``config._project_history_dir`` thành ``<workspace của project>/.cowork_history``
mỗi lần người dùng chọn project khác, nên ``config.history_dir()`` chỉ trả về
thư mục của project ĐANG mở. Một lần gọi :func:`list_conversations` vì thế
chỉ thấy được hội thoại của project đó — khung "Tất cả project…" dựng đủ
tiêu đề nhóm cho mọi project nhưng mọi nhóm trừ một đều rỗng.
Thư mục là chủ sở hữu có thẩm quyền: hội thoại nằm trong thư mục làm việc của
project nào thì thuộc project đó, kể cả khi trường ``project_id`` ghi trong
file đã cũ (project bị đổi thư mục chẳng hạn).
"""
seen: set = set()
items: List[Dict[str, Any]] = []
for project_id, directory in pairs:
if directory is None:
continue
for meta in list_conversations(directory, query=query):
key = str(meta["path"])
if key in seen:
continue
seen.add(key)
if project_id:
meta["project_id"] = project_id
items.append(meta)
# Cùng thứ tự mà list_conversations dùng: ghim lên đầu, rồi mới nhất trước.
items.sort(key=lambda d: (not d["pinned"], -d["mtime"]))
return items
def list_conversations(directory: Optional[Path] = None, query: str = "") -> List[Dict[str, Any]]:
"""List saved conversations, most recent first (pinned always on top).
+10
View File
@@ -103,6 +103,16 @@ def _slugify(name: str) -> str:
return s or "project"
#: Lich su hoi thoai cua mot project nam TRONG thu muc lam viec cua no, de chia
#: se thu muc do la chia se ca lich su (may khac xem va tiep tuc duoc).
HISTORY_SUBDIR = ".cowork_history"
def project_history_dir(project) -> Path:
"""Thư mục lịch sử hội thoại của một project."""
return project.workspace_dir() / HISTORY_SUBDIR
def new_project(name: str, description: str = "", instructions: str = "",
output_dir: str = "", directory: Path = None) -> Project:
"""Create + persist a new project with a unique id derived from the name."""
+158
View File
@@ -0,0 +1,158 @@
"""Lịch sử hội thoại nằm trong thư mục của TỪNG project, không nằm chung.
``WorkspaceTab._bind_project`` đặt ``config._project_history_dir`` thành
``<workspace của project>/.cowork_history`` mỗi lần người dùng chọn project khác.
Hệ quả: một lần gọi ``list_conversations()`` chỉ thấy hội thoại của project đang
mở — và gọi KHÔNG tham số thì không thấy cái nào cả, vì nó đọc ``HISTORY_DIR``
toàn cục.
Hai lỗi đã xảy ra vì đúng chuyện này:
* Khung "Tất cả project…" dựng đủ tiêu đề nhóm cho mọi project nhưng mọi nhóm
trừ một đều rỗng.
* Mọi dòng project đều đếm "0 đoạn chat · 0 task" dù người dùng đã chat.
Test không chạm ``~/.cowork_local``: ``core/projects.py`` gắn ``PROJECTS_DIR``
vào thư mục dữ liệu THẬT, nên mọi thứ ở đây dùng ``tmp_path`` và fake.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from cowork_local.core.history import (
list_conversations, list_conversations_by_project,
)
def _viet_hoi_thoai(directory: Path, session_id: str, title: str,
project_id: str = "", mtime: float | None = None) -> Path:
"""Ghi một file hội thoại tối thiểu mà ``list_conversations`` đọc được."""
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{session_id}.json"
path.write_text(json.dumps({
"kind": "cowork",
"title": title,
"created": "2026-09-07T10:00:00",
"session_id": session_id,
"project_id": project_id,
"messages": [{"role": "user", "content": title}],
}), encoding="utf-8")
if mtime is not None:
import os
os.utime(path, (mtime, mtime))
return path
@pytest.fixture
def hai_project(tmp_path):
"""Hai project, mỗi cái một đoạn chat — đúng tình huống người dùng báo."""
a = tmp_path / "test" / ".cowork_history"
b = tmp_path / "test1" / ".cowork_history"
_viet_hoi_thoai(a, "s-a", "chat cua test", mtime=1000)
_viet_hoi_thoai(b, "s-b", "chat cua test1", mtime=2000)
return [("p-test", a), ("p-test1", b)]
# ---- triệu chứng gốc -----------------------------------------------------
def test_mot_lan_goi_chi_thay_mot_project(hai_project):
"""Chốt lại chính nguyên nhân, để nó không bị coi là chuyện đương nhiên."""
_pid_a, dir_a = hai_project[0]
chi_mot = list_conversations(dir_a)
assert len(chi_mot) == 1
assert chi_mot[0]["title"] == "chat cua test"
def test_gop_nhieu_thu_muc_thi_thay_du(hai_project):
tat_ca = list_conversations_by_project(hai_project)
assert {c["title"] for c in tat_ca} == {"chat cua test", "chat cua test1"}
def test_moi_hoi_thoai_thuoc_dung_project(hai_project):
"""Thư mục là chủ sở hữu có thẩm quyền, không phải trường project_id trong file."""
theo_pid = {c["project_id"]: c["title"] for c in list_conversations_by_project(hai_project)}
assert theo_pid == {"p-test": "chat cua test", "p-test1": "chat cua test1"}
def test_project_id_cu_trong_file_bi_ghi_de(tmp_path):
"""Project bị đổi thư mục thì trường trong file đã cũ — thư mục vẫn đúng."""
d = tmp_path / "moi" / ".cowork_history"
_viet_hoi_thoai(d, "s1", "x", project_id="pid-cu-roi")
ket_qua = list_conversations_by_project([("pid-that", d)])
assert ket_qua[0]["project_id"] == "pid-that"
# ---- thứ tự và trùng lặp -------------------------------------------------
def test_giu_dung_thu_tu_moi_nhat_truoc(hai_project):
tat_ca = list_conversations_by_project(hai_project)
assert [c["title"] for c in tat_ca] == ["chat cua test1", "chat cua test"]
def test_ghim_len_dau_bat_ke_thoi_gian(tmp_path):
d1 = tmp_path / "a" / ".cowork_history"
d2 = tmp_path / "b" / ".cowork_history"
_viet_hoi_thoai(d1, "cu", "cu ma ghim", mtime=1000)
_viet_hoi_thoai(d2, "moi", "moi ma khong ghim", mtime=9000)
data = json.loads((d1 / "cu.json").read_text(encoding="utf-8"))
data["pinned"] = True
(d1 / "cu.json").write_text(json.dumps(data), encoding="utf-8")
tat_ca = list_conversations_by_project([("a", d1), ("b", d2)])
assert tat_ca[0]["title"] == "cu ma ghim"
def test_cung_mot_thu_muc_hai_lan_khong_dem_doi(hai_project):
"""``history_dirs()`` có thể trả về trùng thư mục khi cấu hình chồng nhau."""
_pid, d = hai_project[0]
assert len(list_conversations_by_project([("x", d), ("y", d)])) == 1
# ---- đầu vào xấu ---------------------------------------------------------
def test_thu_muc_chua_ton_tai_thi_bo_qua(tmp_path, hai_project):
tat_ca = list_conversations_by_project(
hai_project + [("rong", tmp_path / "chua-he-co")])
assert len(tat_ca) == 2
def test_thu_muc_None_thi_bo_qua(hai_project):
assert len(list_conversations_by_project(hai_project + [("x", None)])) == 2
def test_khong_co_project_nao_thi_tra_rong():
assert list_conversations_by_project([]) == []
# ---- tìm kiếm vẫn hoạt động khi đã gộp -----------------------------------
def test_tim_kiem_ap_cho_moi_thu_muc(hai_project):
assert len(list_conversations_by_project(hai_project, query="test1")) == 1
assert len(list_conversations_by_project(hai_project, query="chat cua")) == 2
assert list_conversations_by_project(hai_project, query="khong-he-co") == []
# ---- một định nghĩa duy nhất cho đường dẫn -------------------------------
def test_duong_dan_lich_su_chi_dinh_nghia_mot_cho():
"""Chuỗi ".cowork_history" từng nằm rải ở ``ui/workspace_tab.py``."""
from cowork_local.core.projects import HISTORY_SUBDIR
repo = Path(__file__).resolve().parents[1]
src = (repo / "ui" / "workspace_tab.py").read_text(encoding="utf-8")
assert HISTORY_SUBDIR == ".cowork_history"
assert ".cowork_history" not in src, "phải dùng project_history_dir()"
+10 -1
View File
@@ -241,8 +241,17 @@ class HistorySidebar(QWidget):
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:
convos = list_conversations(self.ctx.config.history_dir(), query=query)
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 = []