diff --git a/presentation/chat/attachment_picker.py b/presentation/chat/attachment_picker.py index f198710..5be6109 100644 --- a/presentation/chat/attachment_picker.py +++ b/presentation/chat/attachment_picker.py @@ -59,9 +59,19 @@ class AttachmentMixin: lines = [text] if text else [] # --- User-attached files --- + # Đường dẫn đã giải quyết của các tệp đính kèm, để vòng quét thư mục + # phía sau không gửi lại chính chúng một lần nữa. + da_dinh_kem = set() if has_attachments: - lines.append("\n[Attachments] — read and use these files to answer the request:") + lines.append( + "\n[Attachments] — the user attached these files for THIS request. " + "They are the PRIMARY subject: read them in full and base the answer " + "on them. Anything listed further below is background context only.") for p in attachments: + try: + da_dinh_kem.add(str(Path(p).resolve())) + except OSError: + pass lines.extend(self._read_one_attachment(p, limit, notify)) # --- Auto-load existing workspace/output folder files as input data --- @@ -74,10 +84,10 @@ class AttachmentMixin: if workspace is not None: lines.extend(self._folder_input_lines( workspace, - "[Workspace files] — existing files in output folder, " - "read and use as input data. The user expects you to " - "process these files automatically:", - limit, max_files, notify)) + "[Workspace files] — other files that happen to sit in the output " + "folder. Background context; do NOT let them displace the " + "attached files or the user's own question:", + limit, max_files, notify, da_dinh_kem)) # --- Project knowledge (Claude-Projects style) --- # Only scanned separately when it's a DIFFERENT folder from the @@ -88,31 +98,44 @@ class AttachmentMixin: if knowledge is not None and knowledge != workspace: lines.extend(self._folder_input_lines( knowledge, - "[Project files] — shared knowledge files of this project, " - "available to every conversation in it. Read and use them " - "as context for the request:", - limit, max_files, notify)) + "[Project files] — shared knowledge of this project. Background " + "context; do NOT let them displace the attached files or " + "the user's own question:", + limit, max_files, notify, da_dinh_kem)) return "\n".join(lines) def _folder_input_lines(self, folder: Path, header: str, limit: int, - max_files: int, notify=None) -> list: + max_files: int, notify=None, skip=frozenset()) -> list: """Embed a folder's readable files into the prompt — recursing into every sub-folder, any depth, not just the top level, so files placed in nested folders are read and processed too (same per-message file cap as manual attachments — Settings → Attachments → max files; 0 = unlimited — so a folder with dozens of files can't blow the context window).""" + from pathlib import Path as _P + from ...core.doc_extract import find_input_files out: list = [] shown, total = find_input_files(folder, self._INPUT_EXTS, max_files) + # Bo qua tep nguoi dung DA dinh kem tuong minh. Tep dinh kem thuong nam + # ngay trong thu muc workspace, nen khong loc thi cung mot tai lieu di vao + # prompt HAI lan: mot lan duoi [Attachments], mot lan duoi [Workspace + # files]. Voi tai lieu dai, ban thu hai vua nhan doi ngu canh vua khien + # model khong biet ban nao la ban duoc hoi. + # Số tệp thư mục này thực sự trả về, ĐO TRƯỚC khi lọc trùng: dòng cảnh + # báo bên dưới nói về giới hạn mỗi lượt, nên đếm cả tệp bị lọc vì đã + # đính kèm sẽ báo sai là "không nạp được". + so_lay_duoc = len(shown) + if skip: + shown = [f for f in shown if str(_P(f).resolve()) not in skip] if shown: out.append("\n" + header) for f in shown: out.extend(self._read_one_attachment(str(f), limit, notify)) - if total > len(shown): - skipped = total - len(shown) + if total > so_lay_duoc: + skipped = total - so_lay_duoc out.append(f"…({skipped} more files in the folder were not " "loaded — per-message attachment limit; mention a " "file by name if the user asks about it)") diff --git a/presentation/shell/top_bar.py b/presentation/shell/top_bar.py index 3f02be6..ebc5ab8 100644 --- a/presentation/shell/top_bar.py +++ b/presentation/shell/top_bar.py @@ -191,10 +191,9 @@ class TopBarMixin: # Reload the Cowork tab's Agent (Model) list for the newly selected provider. self.cowork.refresh_agents() self.workspace.refresh_ai_models() # + the Folder AI-edit model picker - self.statusBar().showMessage( - tr("app.status.using_provider", - label=PROVIDER_LABELS.get(self.ctx.config.active_provider)) - ) + # Khong bao "dang dung " o thanh trang thai: chinh bo chon + # provider nam ngay tren man hinh va da hien thu vua chon, nen dong thong + # bao chi nhac lai mot thu nguoi dung vua tu tay lam. def _on_language_changed(self, _idx: int) -> None: """Đổi ngôn ngữ giao diện; trùng ngôn ngữ hiện tại thì bỏ qua để không dựng lại toàn bộ chữ vô ích. diff --git a/tests/ui/test_ui_polish_fixes.py b/tests/ui/test_ui_polish_fixes.py new file mode 100644 index 0000000..d3eb2d7 --- /dev/null +++ b/tests/ui/test_ui_polish_fixes.py @@ -0,0 +1,167 @@ +"""Sáu chỗ chỉnh nhỏ trên giao diện, mỗi bài chốt đúng một triệu chứng đã báo. + +Gom một file vì chúng không chia sẻ gì ngoài việc đều là phản hồi từ người dùng +trong cùng một vòng; tách sáu file cho sáu khẳng định chỉ tạo thêm chỗ để tìm. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] + + +# ---- bỏ nhãn "provider · model" cạnh chữ Cowork -------------------------- + +def test_thanh_cong_cu_cowork_khong_con_nhan_provider(): + """Nó lặp lại thứ bộ chọn provider ở thanh trên đang hiển thị.""" + src = (REPO / "ui" / "cowork_tab.py").read_text(encoding="utf-8") + + assert "model_lbl" not in src + assert "PROVIDER_LABELS" not in src, "import đã thành vô dụng thì phải gỡ" + + +# ---- không báo trạng thái khi đổi provider ------------------------------- + +def test_doi_provider_khong_bao_o_thanh_trang_thai(): + """Bộ chọn nằm ngay trên màn hình và đã hiện thứ vừa chọn.""" + src = (REPO / "presentation" / "shell" / "top_bar.py").read_text(encoding="utf-8") + than_ham = src.split("def _on_provider_changed")[1].split("def _on_language_changed")[0] + + assert "showMessage" not in than_ham + assert "using_provider" not in than_ham + + +# ---- tên project mặc định không gắn ngôn ngữ ---------------------------- + +def test_ten_project_mac_dinh_khong_qua_tr(): + """Tên project được GHI XUỐNG ĐĨA. + + Tạo project lúc đang ở tiếng Nhật thì tên nó thành "新規プロジェクト" vĩnh + viễn, và đổi ngôn ngữ về tiếng Việt không sửa được — đó là dữ liệu, không + phải chữ giao diện. Người dùng nhìn thấy chữ Nhật trên màn tiếng Việt và + tưởng là lỗi hiển thị. + """ + from cowork_local.presentation.workspace import project_editing as pe + + assert pe._DEFAULT_PROJECT_NAME.isascii(), "tên mặc định phải trung tính" + src = (REPO / "presentation" / "workspace" / "project_editing.py").read_text(encoding="utf-8") + than_ham = src.split("def _create")[1].split("def _delete")[0] + assert 'tr("workspace.default_new_name")' not in than_ham + + +def test_ten_mac_dinh_khong_trung_nhau(monkeypatch): + """Bấm "Project mới" hai lần liên tiếp không được ra hai tên giống nhau.""" + from cowork_local.presentation.workspace.project_editing import ( + ProjectEditingMixin, _DEFAULT_PROJECT_NAME, + ) + import cowork_local.core.projects as projects + + class _P: + def __init__(self, pid, name): + self.project_id, self.name = pid, name + + da_co = [_P("p1", _DEFAULT_PROJECT_NAME)] + monkeypatch.setattr(projects, "list_projects", lambda: da_co) + + class _K: + _name_taken = ProjectEditingMixin._name_taken + + assert _K()._name_taken(_DEFAULT_PROJECT_NAME) is True + assert _K()._name_taken(f"{_DEFAULT_PROJECT_NAME} (2)") is False + + +# ---- "Tất cả project…" phải hiện MỌI project ---------------------------- + +def test_tat_ca_project_xoa_bo_loc_theo_project(): + """Bảng lịch sử nhúng trong Cowork của MỘT project nên bị lọc theo project đó. + + Vào bằng link "Tất cả project…" mà còn bộ lọc thì tạo 5 project chỉ thấy 1. + """ + src = (REPO / "presentation" / "shell" / "rail_project.py").read_text(encoding="utf-8") + than_ham = src.split("def goto_all_projects")[1].split("def _on_rail_recent")[0] + + assert 'set_project_filter("")' in than_ham + + +# ---- ba nút quản lý project về cùng một hàng ---------------------------- + +def test_sua_va_luu_project_cung_hang_voi_project_moi(qapp, tmp_path): + """Trước đó "Lưu project" nằm dưới cùng khung bên phải, cách "Project mới" + gần hết chiều cao màn hình.""" + from cowork_local.presentation.shell.bootstrap import build_config, build_context + from cowork_local.presentation.shell.main_window import MainWindow + from cowork_local.presentation.workspace.project_editing import _row_layout_of + + config_path = tmp_path / "config.json" + build_config(config_path) + win = MainWindow(build_context(config_path)) + try: + ws = win.workspace + hang = _row_layout_of(ws._new_btn) + + assert hang is not None, "không tìm được hàng chứa nút Project mới" + assert _row_layout_of(ws._edit_btn) is hang + assert _row_layout_of(ws._save_btn) is hang + # Thu tu doc tu trai sang: tao moi -> sua -> luu + assert (hang.indexOf(ws._new_btn) + < hang.indexOf(ws._edit_btn) + < hang.indexOf(ws._save_btn)) + finally: + win.close() + + +def test_hai_nut_project_khong_hien_ngoai_sub_tab_project(qapp, tmp_path): + """Hàng tiêu đề vắt ngang CẢ màn Workspace. + + Chuyển "Sửa project" + "Lưu project" lên đó (bug 10) làm chúng hiện luôn ở + Cowork, Co4E, Thư mục và GraphRAG — nơi không có biểu mẫu project nào để sửa + hay lưu. Đúng luật mà ``_new_btn`` đã theo từ trước. + """ + from cowork_local.presentation.shell.bootstrap import build_config, build_context + from cowork_local.presentation.shell.main_window import MainWindow + + config_path = tmp_path / "config.json" + build_config(config_path) + win = MainWindow(build_context(config_path)) + try: + ws = win.workspace + ws._current_id = "gia-lap" + + ws.tabs.setCurrentIndex(ws._project_tab_idx) + ws._sync_project_buttons() + assert ws._edit_btn.isHidden() is False + assert ws._save_btn.isHidden() is False + + if ws._cowork_tab_idx < 0: + pytest.skip("bản dựng này không có sub-tab Cowork") + ws.tabs.setTabVisible(ws._cowork_tab_idx, True) + ws.tabs.setCurrentIndex(ws._cowork_tab_idx) + ws._sync_project_buttons() + + assert ws._edit_btn.isHidden() is True, "nút Sửa project lọt sang tab Cowork" + assert ws._save_btn.isHidden() is True, "nút Lưu project lọt sang tab Cowork" + finally: + win.close() + + +def test_chua_chon_project_thi_hai_nut_cung_an(qapp, tmp_path): + """Không có project nào đang mở thì cả Sửa lẫn Lưu đều vô nghĩa.""" + from cowork_local.presentation.shell.bootstrap import build_config, build_context + from cowork_local.presentation.shell.main_window import MainWindow + + config_path = tmp_path / "config.json" + build_config(config_path) + win = MainWindow(build_context(config_path)) + try: + ws = win.workspace + ws._current_id = "" + ws.tabs.setCurrentIndex(ws._project_tab_idx) + ws._sync_project_buttons() + + assert ws._edit_btn.isHidden() is True + assert ws._save_btn.isHidden() is True + finally: + win.close() diff --git a/ui/cowork_tab.py b/ui/cowork_tab.py index 9653cbe..b383854 100644 --- a/ui/cowork_tab.py +++ b/ui/cowork_tab.py @@ -7,7 +7,6 @@ from PySide6.QtWidgets import ( QFileDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget, ) -from ..config import PROVIDER_LABELS from ..core import agent_roles from ..core.worker import AgentWorker from ..i18n import on_language_changed, tr @@ -32,9 +31,6 @@ class CoworkTab(ChatPanel): self._title_lbl = QLabel() self._title_lbl.setStyleSheet("font-weight:700; font-size:15px;") - self.model_lbl = QLabel("") - self.model_lbl.setObjectName("hint") - self.skills_btn = QPushButton() self.skills_btn.setIcon(icon("book")) self.skills_btn.clicked.connect(self._open_skills_manager) @@ -44,7 +40,6 @@ class CoworkTab(ChatPanel): self._new_btn.clicked.connect(self.new_session) self.toolbar_layout.addWidget(self._title_lbl) - self.toolbar_layout.addWidget(self.model_lbl) self.toolbar_layout.addStretch(1) self.toolbar_layout.addWidget(self.skills_btn) self.toolbar_layout.addWidget(self._new_btn) @@ -377,10 +372,12 @@ class CoworkTab(ChatPanel): self._apply_output_folder_label() def refresh_header(self) -> None: - """Cập nhật dòng "provider · model" trên thanh công cụ Cowork.""" - cfg = self.ctx.config - label = PROVIDER_LABELS.get(cfg.active_provider, cfg.active_provider) - self.model_lbl.setText(f"{label} · {cfg.model_label()}") + """Cập nhật các nhãn trên thanh công cụ Cowork. + + Dòng "provider · model" từng nằm ngay sau chữ "Cowork" đã được gỡ: nó + lặp lại thông tin mà bộ chọn provider ở thanh trên đang hiển thị, và + chiếm chỗ đắt nhất trên thanh công cụ cho một thứ chỉ để đọc. + """ self._apply_output_folder_label() # picks up edits made via Settings too def build_job(self, text: str, messages, out_dir):