From 9393fc17483c3567f76d224ccedb4048111993be Mon Sep 17 00:00:00 2001 From: Nam Pham Dinh Thanh Date: Tue, 18 Aug 2026 15:12:02 +0900 Subject: [PATCH] =?UTF-8?q?Rework=20Workspace=20=E2=96=B8=20Project=20to?= =?UTF-8?q?=20its=20wireframe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 4 draws a "Quản lý project" title with + Project mới on its right, a caps PROJECT heading over the list, every row carrying "N đoạn chat · M task", and the form reading Tên / Mô tả / Instructions / Thư mục làm việc. The screen had none of that: the header still said "Workspace — Projects", the create button sat at the foot of the list among the list's own controls, rows were bare names, the folder field had no label, and Instructions carried a parenthetical the drawing does not. The row counts are the substantive part — they are the only thing on the screen that says a project contains anything. Read once per refresh from list_conversations() and list_tasks() grouped by project_id, not per row. Delete stays under the list it acts on. The drawing does not show it, but it does not show it moved either, and dropping a control is not something a layout pass gets to do. Also shortened by the drawing: "Đổi thư mục…"/"Mở thư mục" to "Đổi"/"Mở", which the new "Thư mục làm việc" label above them now disambiguates. check_project_screen covers the four points and fails when the count line is removed. 22/22 checkers pass. Co-Authored-By: Claude Opus 5 (1M context) --- i18n.py | 14 +++-- tools/check_project_screen.py | 115 ++++++++++++++++++++++++++++++++++ ui/workspace_tab.py | 73 ++++++++++++++++++--- 3 files changed, 190 insertions(+), 12 deletions(-) create mode 100644 tools/check_project_screen.py diff --git a/i18n.py b/i18n.py index c4fc6f2..ab7440f 100644 --- a/i18n.py +++ b/i18n.py @@ -198,7 +198,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "vi": "Tạo project trước"}, # ---- workspace_tab.py (Projects — Claude-Projects style) ----------- - "workspace.header": {"en": "Workspace — Projects", "ja": "ワークスペース — プロジェクト", "vi": "Workspace — Projects"}, + "workspace.header": {"en": "Manage projects", "ja": "プロジェクト管理", "vi": "Quản lý project"}, "workspace.tab_cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"}, "workspace.tab_graphrag": {"en": "GraphRAG", "ja": "GraphRAG", "vi": "GraphRAG"}, "workspace.tab_project": {"en": "Project", "ja": "プロジェクト", "vi": "Project"}, @@ -378,6 +378,12 @@ STRINGS: Dict[str, Dict[str, str]] = { "các file đặt ở gốc thư mục đó (project knowledge)."), }, "workspace.new_project": {"en": "New project", "ja": "新規プロジェクト", "vi": "Project mới"}, + "workspace.projects_heading": {"en": "PROJECTS", "ja": "プロジェクト", "vi": "PROJECT"}, + "workspace.folder_label": {"en": "Workspace folder", "ja": "作業フォルダ", "vi": "Thư mục làm việc"}, + "workspace.counts": { + "en": "{chats} chats · {tasks} tasks", + "ja": "チャット {chats} · タスク {tasks}", + "vi": "{chats} đoạn chat · {tasks} task"}, "workspace.delete": {"en": "Delete", "ja": "削除", "vi": "Xóa"}, "workspace.delete_confirm": { "en": "Delete project “{name}”? Its conversations and files are kept (threads move to General).", @@ -391,19 +397,19 @@ STRINGS: Dict[str, Dict[str, str]] = { "vi": "Project của hội thoại này không còn tồn tại — không thể mở."}, "workspace.name": {"en": "Name", "ja": "名前", "vi": "Tên"}, "workspace.description": {"en": "Description", "ja": "説明", "vi": "Mô tả"}, - "workspace.instructions": {"en": "Instructions (shared project context)", "ja": "指示(プロジェクト共有コンテキスト)", "vi": "Instructions (ngữ cảnh chung của project)"}, + "workspace.instructions": {"en": "Instructions", "ja": "Instructions", "vi": "Instructions"}, "workspace.instructions_placeholder": { "en": "e.g. \"All answers in Vietnamese. We are building the X reporting tool; always follow the naming rules …\"", "ja": "例:「回答はすべて日本語で。X レポートツールを開発中。命名規則に従うこと …」", "vi": "vd: \"Trả lời bằng tiếng Việt. Team đang xây tool báo cáo X; luôn theo quy tắc đặt tên …\"", }, - "workspace.browse": {"en": "Change folder…", "ja": "フォルダ変更…", "vi": "Đổi thư mục…"}, + "workspace.browse": {"en": "Change", "ja": "変更", "vi": "Đổi"}, "workspace.browse_tooltip": { "en": "Choose the project's workspace folder (agent sandbox + shared knowledge root)", "ja": "プロジェクトのワークスペースフォルダを選択(エージェントのサンドボックス+共有ナレッジのルート)", "vi": "Chọn thư mục workspace của project (sandbox của agent + gốc chứa knowledge chung)", }, - "workspace.open_folder": {"en": "Open folder", "ja": "フォルダを開く", "vi": "Mở thư mục"}, + "workspace.open_folder": {"en": "Open", "ja": "開く", "vi": "Mở"}, "workspace.save": {"en": "Save project", "ja": "プロジェクトを保存", "vi": "Lưu project"}, "workspace.saved": {"en": "Saved project {name}.", "ja": "プロジェクト {name} を保存しました。", "vi": "Đã lưu project {name}."}, "workspace.threads": {"en": "Conversations in this project", "ja": "このプロジェクトの会話", "vi": "Hội thoại trong project này"}, diff --git a/tools/check_project_screen.py b/tools/check_project_screen.py new file mode 100644 index 0000000..67323a4 --- /dev/null +++ b/tools/check_project_screen.py @@ -0,0 +1,115 @@ +"""Workspace ▸ Project against its wireframe (section 4). + +The drawing: a "Quản lý project" title with + Project mới on its right, a caps +PROJECT heading over the list, every row carrying "N đoạn chat · M task", and +the form reading Tên / Mô tả / Instructions / Thư mục làm việc. +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts) + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtCore import QPoint + from PySide6.QtWidgets import QApplication, QLabel + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + _apply_theme(app) + + from cowork_local.config import CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.config import AppConfig + from cowork_local.i18n import set_language, tr + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1400, 900) + win.show() + app.processEvents() + win._goto(win._ROW_WORKSPACE, win.workspace._project_tab_idx) + app.processEvents() + + w = win.workspace + fails = [] + + # 1. title, and the create button on its row, to its right + print(f"tieu de: {w._header.text()!r}") + if w._header.text() != tr("workspace.header"): + fails.append("tieu de khong phai workspace.header") + h_pos = w._header.mapTo(w, QPoint(0, 0)) + h_mid = h_pos.y() + w._header.height() // 2 + b_pos = w._new_btn.mapTo(w, QPoint(0, 0)) + b_mid = b_pos.y() + w._new_btn.height() // 2 + aligned = abs(b_mid - h_mid) <= 8 + after = b_pos.x() >= h_pos.x() + w._header.width() + print(f"nut '+ Project mới': tam y={b_mid} (tieu de {h_mid}) thang hang={aligned} " + f"| x={b_pos.x()} sau tieu de={after}") + if not (aligned and after): + fails.append("nut tao project khong nam cung hang, ben phai tieu de") + + # 2. caps heading over the list + hdr = w._projects_hdr.text() + print(f"tieu de pane trai: {hdr!r}") + if not hdr or hdr != hdr.upper(): + fails.append(f"tieu de pane trai chua viet hoa: {hdr!r}") + + # 3. every row says how much is in the project + lst = w.project_list + if not lst.count(): + fails.append("khong co project nao de kiem") + shown = 0 + for i in range(lst.count()): + row = lst.itemWidget(lst.item(i)) + labels = [l.text() for l in row.findChildren(QLabel)] if row else [] + if len(labels) < 2: + fails.append(f"hang {i} khong co dong dem chat/task") + continue + shown += 1 + if i < 2: + print(f" hang {i}: {labels[0]!r} / {labels[1]!r}") + # the sub-line must be the counts string, not the name repeated + if labels[1] == labels[0] or not any(ch.isdigit() for ch in labels[1]): + fails.append(f"hang {i}: dong phu khong phai so dem: {labels[1]!r}") + print(f"so hang co dong dem: {shown}/{lst.count()}") + + # 4. the form reads as the drawing labels it + want = [tr("workspace.name"), tr("workspace.description"), + tr("workspace.instructions"), tr("workspace.folder_label")] + seen = [l.text() for l in w.findChildren(QLabel) if l.isVisible() and l.text()] + for label in want: + if label not in seen: + fails.append(f"thieu nhan {label!r}") + print(f"nhan form: {want}") + + print() + for f in fails: + print("FAIL " + f) + print("PASS man Project khop ban ve" if not fails else f"{len(fails)} problem(s)") + sys.stdout.flush() + os._exit(1 if fails else 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ui/workspace_tab.py b/ui/workspace_tab.py index 99e9a1d..e8d5113 100644 --- a/ui/workspace_tab.py +++ b/ui/workspace_tab.py @@ -32,6 +32,25 @@ from .osutil import open_folder from .widgets import CollapseStrip +class _ProjectRow(QWidget): + """A project in the list: its name, and under it how much is in it. + + The drawing gives every row a second line — "2 đoạn chat · 3 task" — which + is the only thing on this screen that says a project holds anything at all. + """ + + def __init__(self, name: str, counts: str): + super().__init__() + lay = QVBoxLayout(self) + lay.setContentsMargins(6, 4, 6, 4) + lay.setSpacing(0) + title = QLabel(name) + sub = QLabel(counts) + sub.setObjectName("hint") + lay.addWidget(title) + lay.addWidget(sub) + + class WorkspaceTab(QWidget): status_message = Signal(str) open_chat = Signal(str, dict) # kind, conversation — open a thread in Cowork @@ -98,12 +117,23 @@ class WorkspaceTab(QWidget): self._sidebar = sidebar root = QVBoxLayout(self) + # Title row — the drawing puts "+ Project mới" up here beside the title, + # not at the foot of the project list where it read as belonging to the + # list's own controls. self._header = QLabel() self._header.setStyleSheet("font-weight:700; font-size:15px;") + self._new_btn = QPushButton() + self._new_btn.setIcon(icon("plus")) + self._new_btn.setObjectName("primary") + self._new_btn.clicked.connect(self._create) + title_row = QHBoxLayout() + title_row.addWidget(self._header) + title_row.addStretch(1) + title_row.addWidget(self._new_btn) + root.addLayout(title_row) self._hint = QLabel() self._hint.setObjectName("hint") self._hint.setWordWrap(True) - root.addWidget(self._header) root.addWidget(self._hint) self._split = QSplitter(Qt.Horizontal) @@ -115,6 +145,9 @@ class WorkspaceTab(QWidget): ll = QVBoxLayout(left) ll.setContentsMargins(0, 0, 0, 0) left_hdr = QHBoxLayout() + self._projects_hdr = QLabel() + self._projects_hdr.setObjectName("navSectionHdr") + left_hdr.addWidget(self._projects_hdr) self._proj_collapse_btn = QPushButton() self._proj_collapse_btn.setIcon(collapse_left_icon()) self._proj_collapse_btn.setFixedWidth(28) @@ -126,15 +159,13 @@ class WorkspaceTab(QWidget): self.project_list.currentItemChanged.connect(self._on_select) ll.addWidget(self.project_list, 1) btns = QHBoxLayout() - self._new_btn = QPushButton() - self._new_btn.setIcon(icon("plus")) - self._new_btn.setObjectName("primary") - self._new_btn.clicked.connect(self._create) + # Delete stays under the list it acts on. The drawing does not show it, + # but it does not show it moved either, and dropping a control is not + # something a layout pass gets to do. self._del_btn = QPushButton() self._del_btn.setIcon(icon("trash")) self._del_btn.clicked.connect(self._delete) - btns.addWidget(self._new_btn, 1) - btns.addWidget(self._del_btn) + btns.addWidget(self._del_btn, 1) ll.addLayout(btns) self._projects_panel = left @@ -240,6 +271,8 @@ class WorkspaceTab(QWidget): rl.addWidget(self._instr_lbl) rl.addWidget(self.instr_edit) + self._folder_hdr = QLabel() + rl.addWidget(self._folder_hdr) folder_row = QHBoxLayout() self.folder_lbl = QLabel() self.folder_lbl.setObjectName("hint") @@ -394,6 +427,8 @@ class WorkspaceTab(QWidget): def _retranslate(self) -> None: self._header.setText(tr("workspace.header")) self._hint.setText(tr("workspace.hint")) + self._projects_hdr.setText(tr("workspace.projects_heading").upper()) + self._folder_hdr.setText(tr("workspace.folder_label")) self._new_btn.setText(tr("workspace.new_project")) self._del_btn.setText(tr("workspace.delete")) self._name_lbl.setText(tr("workspace.name")) @@ -491,21 +526,43 @@ class WorkspaceTab(QWidget): from ..core.projects import list_projects keep = self._current_id + counts = self._project_counts() self.project_list.blockSignals(True) self.project_list.clear() row_to_select = 0 for i, p in enumerate(list_projects()): - item = QListWidgetItem(p.name) + chats, tasks = counts.get(p.project_id, (0, 0)) + item = QListWidgetItem() item.setData(Qt.UserRole, p.project_id) if p.description: item.setToolTip(p.description) self.project_list.addItem(item) + row = _ProjectRow(p.name, tr("workspace.counts", chats=chats, tasks=tasks)) + item.setSizeHint(row.sizeHint()) + self.project_list.setItemWidget(item, row) if p.project_id == keep: row_to_select = i self.project_list.blockSignals(False) self.project_list.setCurrentRow(row_to_select) self._load_current() + @staticmethod + def _project_counts(): + """{project_id: (chats, tasks)} — read once per refresh, not per row.""" + from ..core.history import list_conversations + from ..core.tasks import list_tasks + + out: dict = {} + for conv in list_conversations(): + pid = conv.get("project_id") or "default" + chats, tasks = out.get(pid, (0, 0)) + out[pid] = (chats + 1, tasks) + for task in list_tasks(): + pid = task.get("project_id") or "default" + chats, tasks = out.get(pid, (0, 0)) + out[pid] = (chats, tasks + 1) + return out + def _selected_id(self) -> str: item = self.project_list.currentItem() return item.data(Qt.UserRole) if item else ""