docs(agent): thư viện instruction cho việc sửa bug UI/UX

Bộ 7 role chuyên biệt (triage → specialist → implementer → reviewer) cùng
lớp dùng chung: guardrail, tri thức về repo, checklist, và contract đầu ra.

Vì sao có: bug UI/UX được báo bằng lời kể triệu chứng, và người sửa hay bỏ
qua ba thứ mà repo này rất dễ vi phạm — luật "không file nào ngoài theme/
được đặt tên một màu", trần LOC theo bánh cóc, và việc ui/ với presentation/
cùng tồn tại nên sửa nhầm file là "đã fix mà vẫn thấy lỗi".

knowledge/qt_pitfalls.md chép lại 20 nguyên nhân gốc hay gặp của bug PySide6;
examples/bad_fix.md có hai ca CÓ THẬT, gồm ca chính bản vá trong nhánh này
từng mắc (compare_digest trên str ngoài ASCII) và lọt qua vòng review đầu.

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 5d23a415e1
commit 7bd2b95a57
29 changed files with 3513 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
# i18n — luật chuỗi hiển thị
Nguồn: docstring `i18n/__init__.py`.
---
## 1. Ba ngôn ngữ, mặc định tiếng Việt
```python
LANGUAGES = {"en": "English", "ja": "日本語", "vi": "Tiếng Việt"}
LANGUAGE_SHORT = {"en": "EN", "ja": "JP", "vi": "VN"} # switcher gọn ở top bar
DEFAULT_LANGUAGE = "vi"
```
`tr(key, **kwargs)` trả chuỗi theo ngôn ngữ hiện tại, fallback lần lượt:
**ngôn ngữ hiện tại → `en` → chính cái key**. Nghĩa là thiếu entry thì UI hiện ra
`workspace.tab_folder` chứ không crash — nếu người dùng chụp màn hình có chuỗi dạng
`a.b_c` thì đó chính là triệu chứng thiếu key.
`.format(**kwargs)` được áp dụng khi có placeholder: `tr("composer.attachments", n=3)`.
## 2. Widget nào phải đăng ký callback
| Loại widget | Cách xử lý |
|---|---|
| **Sống lâu** — chrome cửa sổ chính, tab, sidebar, composer | Đăng ký `on_language_changed(cb)`; `cb` áp lại `tr()` cho chính widget đó. Callback chạy **ngay một lần** và mỗi lần đổi ngôn ngữ |
| **Tạm thời** — Settings, Skills, Flow, Permission dialog | Dựng lại từ đầu mỗi lần mở, nên chỉ cần gọi `tr()` lúc construct, **không** đăng ký |
Quy ước đặt tên hàm callback trong repo: `_retranslate()` / `_apply_i18n()` — xem
`ui/workspace_tab.py:484` trở đi làm mẫu chuẩn.
**Bug điển hình:** "Đổi ngôn ngữ nhưng nhãn X không đổi" → widget sống lâu mà quên đăng ký,
hoặc có đăng ký nhưng callback bỏ sót đúng nhãn đó. Không sửa bằng cách gọi `tr()` lại ở
chỗ khác — sửa trong callback.
## 3. File từ điển
`i18n/` chia theo màn hình, không phải một file khổng lồ:
```text
i18n/login_dialog.py i18n/sidebar.py i18n/composer.py
i18n/cowork_tab.py i18n/settings_dialog.py i18n/skills_dialog.py
i18n/libreoffice_view.py i18n/agents_admin_tab.py i18n/monitoring_overview.py
i18n/hint.py
```
Mỗi file export dict `key -> {"en":..., "ja":..., "vi":...}`, được `i18n/__init__.py`
import và gộp lại. Thêm key mới:
1. Chọn đúng file theo màn hình (không nhét đại vào `login_dialog.py` chỉ vì nó lớn nhất).
2. Điền **đủ 3 ngôn ngữ**. Thiếu `ja` là lỗi hay gặp nhất và chỉ lộ ra khi khách Nhật dùng.
3. Đặt key theo `<màn>.<thành_phần>` — `workspace.tab_folder`, `app.nav.recents`.
## 4. Rủi ro riêng của tiếng Nhật và tiếng Việt
| Rủi ro | Triệu chứng | Cách xử lý |
|---|---|---|
| Tiếng Nhật ngắn hơn, tiếng Việt dài hơn tiếng Anh | Nút vừa với `EN`, tràn với `VI`; label bị `...` với `JA` | Không `setFixedWidth` theo chuỗi tiếng Anh. Dùng `sizeHint` + `minimumWidth`, hoặc cho phép wrap |
| Dấu tiếng Việt bị cắt phần trên/dưới | `Ắ`, `ộ` mất dấu ở nhãn cao cố định | Không đặt `setFixedHeight` cho label theo pixel; để layout tự tính |
| Font mặc định thiếu glyph Nhật | Ô vuông tofu `□□□` trên máy chưa cài font | Kiểm tra `_FONT` trong `theme/palettes.py`, khai báo fallback |
| Sắp xếp / so sánh chuỗi | Danh sách project sắp sai với tên có dấu | Dùng `locale`-aware sort, không `sorted()` thô |
| Chiều dài chuỗi tính bằng ký tự ≠ chiều rộng hiển thị | Elide sai với chữ Nhật | Đo bằng `QFontMetrics.horizontalAdvance`, không `len()` |
## 5. Checklist sửa bug i18n
- [ ] Key mới có đủ `en` / `ja` / `vi`?
- [ ] Đã thử đổi qua cả 3 ngôn ngữ **trong lúc app đang chạy** (không phải restart)?
- [ ] Widget sống lâu đã đăng ký `on_language_changed`?
- [ ] Không còn chuỗi hardcode nào trong bản vá?
- [ ] Layout còn đúng với chuỗi dài nhất trong 3 ngôn ngữ?
- [ ] Không dùng `len()` để đo bề rộng chữ?
+101
View File
@@ -0,0 +1,101 @@
# Project Map — Cowork Local (dành cho agent sửa bug UI/UX)
Nguồn sự thật: `README.md`, `docs/architecture/ADR-001-layered-architecture.md`,
`docs/governance/contributor-recipes.md`. File này chỉ tóm tắt phần **một người sửa bug
UI cần biết**.
---
## 1. Bốn tầng
```text
presentation/ PySide6 UI — Shell, NavRail, Chat, Scheduling, Settings, Dashboard
↓
application/ Orchestration thuần Python — Conversations, Scheduling, Workspaces, Monitoring, Routing
↓
domain/ Entity, ExecutionRequest bất biến, AgentEvent, Descriptor (thuần Python)
↑
infrastructure/ Adapter — LLM provider, persistence atomic JSON, Keyring SecretStore, MCP
```
- `domain/` và `application/` **không được** import PySide6/PyQt/`ui`/`app`
(`scripts/check_imports.py::FORBIDDEN_MODULE_PREFIXES`).
- Widget chỉ gọi xuống service của `application/`, không chạm SQLite/JSON/LLM trực tiếp.
- Mọi module production `<= 400 LOC`.
## 2. ⚠️ Hai thư mục UI cùng tồn tại — điểm dễ sửa nhầm file nhất
| Thư mục | Vai trò hiện tại | Sửa bug ở đây khi |
|---|---|---|
| `presentation/` | Kết quả refactor R08 — các màn đã tách module | Bug thuộc Chat, Co4E, Dashboard, Folder, Graph, Scheduling, Settings, Shell |
| `ui/` | **Vẫn đang chạy**, không phải code chết | Bug thuộc Monitoring, Workspace, các dialog, icon, widget dùng chung |
`presentation/` vẫn import ngược sang `ui/` cho phần dùng chung, ví dụ:
```text
presentation/shell/page_registry.py:14 from ...ui.monitoring_tab import MonitoringTab
presentation/shell/main_window.py:38 from ...ui.workspace_tab import WorkspaceTab
presentation/dashboard/dashboard_tab.py:24 from cowork_local.ui.icons import icon
```
**Luật:** trước khi sửa, `grep` tên class/hàm trên **cả hai** thư mục. Sửa bản không được
import vào runtime là lỗi "đã fix nhưng user vẫn thấy lỗi" phổ biến nhất của repo này.
```bash
grep -rn "class DashboardTab" ui/ presentation/
```
## 3. Điểm vào & trạng thái
| File | Vai trò |
|---|---|
| `app.py`, `__main__.py` | Bootstrap `QApplication`, dựng `MainWindow` |
| `presentation/shell/main_window.py` | Cửa sổ chính, `_nav_defs`, top bar, toast, help agent |
| `presentation/shell/page_registry.py` | Chuyển trang; Dashboard/Schedule/Monitoring **dựng lười** |
| `presentation/shell/nav_rail.py` | Nav rail trái, thu gọn/mở rộng, cây project & recents |
| `presentation/shell/top_bar.py` | Thanh trên: theme switch, language switch |
| `presentation/shell/toast.py` | Popup "task xong" góc trên trái |
| `state.py` | `AppContext` — cầu nối UI ↔ service |
| `config.py` | Đọc/ghi cấu hình người dùng (theme, ngôn ngữ, provider...) |
| `paths.py` | Vị trí dữ liệu runtime (`%USERPROFILE%\.cowork_local`) |
| `theme/` | Toàn bộ màu sắc & stylesheet (xem `theme_tokens.md`) |
| `i18n/` | Toàn bộ chuỗi hiển thị (xem `i18n_rules.md`) |
### Hệ quả của "dựng lười" khi debug
Dashboard, Schedule và Monitoring **chưa tồn tại** cho tới lần đầu người dùng bấm vào.
Nghĩa là:
- Bug "lần đầu mở màn X bị nhấp nháy / sai theme / sai ngôn ngữ" gần như luôn nằm ở
`_ensure_page` / `_goto` chứ không nằm trong widget của màn đó.
- Widget dựng lười **bỏ lỡ** các sự kiện đã phát trước đó (đổi theme, đổi ngôn ngữ).
Xem `qt_pitfalls.md` P07.
## 4. Bảng đối chiếu tính năng → file
| Khu vực | File chính |
|---|---|
| Chat / composer / bubble | `presentation/chat/` (`chat_panel.py`, `composer_widget.py`, `chat_bubble_style.py`) |
| Co4E canvas & node | `presentation/co4e/` (`co4e_canvas_widget.py`, `node_property_panel.py`, `canvas_geometry.py`) |
| Dashboard & biểu đồ | `presentation/dashboard/` + `ui/spline_chart.py`, `ui/widgets.py` |
| Folder / preview tài liệu | `presentation/folder/` (`folder_tab.py`, `code_editor.py`, `office_document_renderer.py`) |
| GraphRAG | `presentation/graph/` |
| Lịch / Kanban | `presentation/scheduling/` |
| Settings | `presentation/settings/` + `ui/settings_dialog.py` |
| Monitoring (8 sub-view) | `ui/monitoring_tab.py` + `presentation/monitoring/` |
| Workspace + sub-tab | `ui/workspace_tab.py`, `ui/cowork_tab.py`, `ui/co4e_tab.py` |
| Dialog (login, permission, skill, task...) | `ui/*_dialog.py` |
| Icon | `ui/icons.py` |
| Widget dùng chung (StatCard, BudgetCard...) | `ui/widgets.py` |
## 5. Test
| Đường dẫn | Nội dung |
|---|---|
| `tests/ui/` | Test widget, có `conftest.py` riêng |
| `tests/integration/` | Test ghép nhiều thành phần |
| `tests/e2e/test_smoke.py` | Smoke test bản release |
| `tests/characterization/` | Chốt hành vi hiện tại trước khi refactor |
Chạy headless: `QT_QPA_PLATFORM=offscreen pytest tests/ui -q`.
64/108 module test dựng widget thật, nên môi trường phải có PySide6.
+141
View File
@@ -0,0 +1,141 @@
# Nguyên nhân gốc hay gặp của bug UI PySide6
Danh mục để **chẩn đoán**, không phải để đoán bừa. Mỗi mục: triệu chứng người dùng mô tả →
nguyên nhân → cách xác minh → hướng sửa.
---
## Nhóm A — Layout & kích thước
### P01. Widget bị bóp/giãn sai khi resize
**Triệu chứng:** "kéo cửa sổ to ra thì bảng bên phải nuốt hết chỗ", "panel trái biến mất".
**Nguyên nhân:** thiếu `stretch` factor, hoặc `QSizePolicy` sai (`Preferred` vs `Expanding`).
**Xác minh:** đọc `addWidget(w, stretch)` / `setStretchFactor` / `setSizePolicy` quanh chỗ dựng.
**Sửa:** đặt stretch tường minh trên `QSplitter`/`QBoxLayout`. Không sửa bằng `setFixedWidth`.
### P02. Chữ bị cắt / hiện `...` ở một số ngôn ngữ hoặc scale
**Triệu chứng:** "nút bị mất chữ", "tên project chỉ hiện một nửa".
**Nguyên nhân:** `setFixedWidth`/`setFixedSize` tính theo chuỗi tiếng Anh ở 100% scale.
**Xác minh:** `grep -n "setFixedWidth\|setFixedSize\|setMaximumWidth" <file>`; thử với `vi`/`ja`.
**Sửa:** dùng `minimumWidth` + `sizeHint`, hoặc `QFontMetrics.horizontalAdvance` cho chuỗi
dài nhất trong 3 ngôn ngữ. Xem `i18n_rules.md` §4.
### P03. Nội dung trong `QScrollArea` không cuộn được / bị nén
**Nguyên nhân:** quên `setWidgetResizable(True)`, hoặc đặt widget con vào scroll area
**sau** khi đã `setWidget`.
**Sửa:** `setWidgetResizable(True)` và dựng xong nội dung rồi mới `setWidget`.
### P04. Khoảng trắng thừa quanh panel
**Nguyên nhân:** `setContentsMargins`/`setSpacing` mặc định của layout lồng nhau cộng dồn.
**Xác minh:** đếm số layout lồng; repo dùng `setContentsMargins(10,10,10,10)` +
`setSpacing(10)` ở shell (`main_window.py:145`), layout con thường phải là `(0,0,0,0)`.
### P05. Bug chỉ xảy ra trên màn hình scale 125%/150%
**Triệu chứng:** "máy em bình thường, máy sếp bị lệch".
**Nguyên nhân:** hằng số pixel cứng, icon raster không có bản @2x, `QPixmap` không set
`devicePixelRatio`.
**Xác minh:** hỏi người dùng độ phân giải + mức scale Windows; test lại bằng biến môi trường
`QT_SCALE_FACTOR=1.5`.
**Sửa:** dùng đơn vị theo `QFontMetrics`, icon SVG hoặc `icon()` từ `ui/icons.py`.
---
## Nhóm B — Stylesheet & theme
### P06. `setStyleSheet` cục bộ đè mất style toàn app
**Triệu chứng:** "một chỗ nhìn khác hẳn phần còn lại", "combo box mất mũi tên".
**Nguyên nhân:** gọi `widget.setStyleSheet(...)` — QSS con **thay thế** chứ không merge với
QSS ứng dụng cho subcontrol đó. Riêng `::drop-down` bị style là Qt ngừng vẽ mũi tên mặc
định (xem `theme_tokens.md` §5).
**Sửa:** gỡ stylesheet cục bộ, gán `objectName`, style trong `theme/qss.py`.
### P07. Widget dựng lười không nhận theme / ngôn ngữ mới
**Triệu chứng:** "đổi sang giao diện sáng rồi mà màn Giám sát vẫn tối", "chỉ màn đó bị".
**Nguyên nhân:** Dashboard / Schedule / Monitoring chỉ được dựng ở lần mở đầu tiên
(`presentation/shell/page_registry.py::_ensure_page`). Chúng **bỏ lỡ** sự kiện đổi theme
hoặc đổi ngôn ngữ đã phát trước đó.
**Xác minh:** mở app → đổi theme → *rồi mới* bấm vào màn đó. Nếu lỗi tái hiện thì đúng P07.
**Sửa:** áp lại stylesheet/`tr()` trong `_ensure_page` sau khi dựng, hoặc để widget tự đăng ký
listener ngay trong `__init__`. Không sửa trong từng widget con.
### P08. Style không áp lại sau khi đổi property động
**Triệu chứng:** "nút vẫn xám sau khi đã chọn xong".
**Nguyên nhân:** QSS selector dạng `[state="active"]` chỉ được đánh giá lại khi ép polish.
**Sửa:** `w.style().unpolish(w); w.style().polish(w)` sau khi `setProperty`.
### P09. Bug chỉ có ở một theme
**Xác minh bắt buộc:** đối chiếu `docs/screens/<slug>-dark.png` và `<slug>-light.png`.
**Nguyên nhân thường gặp:** dùng `accent` ở chỗ cần `accent_solid`, hoặc token bề mặt sai bậc
(`surface` thay vì `surface_raised`).
---
## Nhóm C — Signal, slot, luồng
### P10. Bấm một lần chạy hai lần
**Triệu chứng:** "gửi 1 tin mà hiện 2", "tạo trùng task".
**Nguyên nhân:** `connect()` được gọi lại mỗi lần refresh/rebuild mà không `disconnect()`.
**Xác minh:** `grep -n "\.connect(" <file>` và tìm xem có nằm trong hàm được gọi nhiều lần không.
**Sửa:** connect một lần trong `__init__`, hoặc `Qt.UniqueConnection`.
### P11. UI đứng khi chạy tác vụ dài
**Triệu chứng:** "app treo khi bấm Phân tích", "vòng xoay không quay".
**Nguyên nhân:** gọi LLM / đọc file lớn / gọi MCP ngay trong GUI thread.
**Sửa:** đẩy xuống service của `application/` chạy async/worker; GUI chỉ nhận signal.
Đây cũng là vi phạm kiến trúc (`guardrail.md` G3), không chỉ là bug hiệu năng.
### P12. Widget biến mất không lý do
**Nguyên nhân:** không có parent, bị Python GC thu hồi; hoặc bị `deleteLater` sớm.
**Sửa:** truyền `parent` khi khởi tạo, hoặc giữ tham chiếu trên `self`.
### P13. Truy cập widget đã bị xoá → crash
**Triệu chứng:** "đóng dialog xong app tắt luôn".
**Nguyên nhân:** slot vẫn chạy sau khi C++ object đã destroy (`RuntimeError: Internal C++ object already deleted`).
**Sửa:** `disconnect` trong `closeEvent`, hoặc dùng `QPointer`/kiểm tra `shiboken6.isValid`.
### P14. Dữ liệu cũ hiện lại sau khi đã cập nhật
**Nguyên nhân:** view đọc từ cache/model không được `beginResetModel`/`endResetModel`,
hoặc widget được `hide()` chứ không rebuild.
---
## Nhóm D — Vẽ tay & hiệu năng
### P15. Nhấp nháy khi chuyển màn hoặc khi cuộn
**Nguyên nhân:** `repaint()` gọi tay trong vòng lặp, hoặc `paintEvent` đọc file/config.
**Sửa:** dùng `update()` (gộp lần vẽ), và đọc màu qua `current_palette()` — đã được cache
sẵn chính vì lý do này (`theme_tokens.md` §2).
### P16. Chart / canvas vẽ đè, để lại vệt
**Nguyên nhân:** không xoá nền trong `paintEvent`, hoặc `QPainter` không `end()`.
### P17. Icon mờ hoặc sai màu ở dark/light
**Nguyên nhân:** icon raster một màu cố định.
**Sửa:** lấy qua `ui/icons.py::icon`, không load PNG trực tiếp.
---
## Nhóm E — Vòng đời & dữ liệu
### P18. Trạng thái rỗng/đang tải/lỗi không có giao diện riêng
**Triệu chứng:** "màn hình trắng trơn, không biết đang chạy hay hỏng".
Đây là **bug UX**, không phải bug kỹ thuật → route sang `3_ux_flow_fixer.md`.
### P19. Người dùng mất dữ liệu khi đóng nhầm
**Triệu chứng:** "gõ instruction xong đóng tab, mất hết".
**Nguyên nhân:** không có dirty-state, không chặn `closeEvent`.
Đây là bug UX mức nghiêm trọng, ưu tiên cao hơn phần lớn bug hiển thị.
### P20. Dialog mở sau lưng cửa sổ chính / mở lệch màn hình
**Nguyên nhân:** dialog không truyền `parent`, hoặc set vị trí bằng toạ độ tuyệt đối.
**Sửa:** luôn truyền parent; căn giữa theo `parent.geometry()`, không theo `screen(0)`.
---
## Cách dùng danh mục này
1. Ánh xạ triệu chứng người dùng → 1-3 mục khả dĩ.
2. Với mỗi mục, chạy đúng bước **Xác minh** — đọc code hoặc tái hiện.
3. Loại trừ cho tới khi còn một nguyên nhân có `file:line` cụ thể.
4. Nếu không mục nào khớp: ghi giả thuyết mới vào `fix_plan.md`, và **bổ sung mục mới vào
file này** khi đã xác nhận. Danh mục phải lớn dần theo bug thật của sản phẩm.
+124
View File
@@ -0,0 +1,124 @@
# CASAN Quality Gate — cổng bắt buộc trước PR
Nguồn: `README.md`, `scripts/run_quality_gate.py`.
---
## 1. Năm cổng
| Cổng | Script | Kiểm tra |
|---|---|---|
| **C** — Clean Architecture | `scripts/check_imports.py` | `domain/` và `application/` không import `PySide6`, `PySide2`, `PyQt6`, `PyQt5`, `ui`, `app` |
| **A** — Atomic & Secrets | `scripts/audit_security.py` | Secret/plaintext trong file `.py` và file config |
| **S** — Single Responsibility | `scripts/check_loc.py --max-lines 400` | Mọi module production `<= 400 LOC` |
| **O** — Orphan Module | `scripts/check_orphan_modules.py` | Module không được import từ đâu |
| **A/N** — Tests | `pytest` | Toàn bộ suite |
## 2. Lệnh
```bash
# Đủ 5 cổng — chạy trước khi tạo PR
python scripts/run_quality_gate.py
# Chỉ guard tĩnh, bỏ test — vòng lặp sửa nhanh
python scripts/run_quality_gate.py --skip-tests
# Từng cổng
python scripts/check_imports.py
python scripts/audit_security.py
python scripts/check_loc.py --max-lines 400
pytest tests/e2e/test_smoke.py -v
```
## 3. Chạy test UI headless
```bash
QT_QPA_PLATFORM=offscreen pytest tests/ui -q # bash
$env:QT_QPA_PLATFORM="offscreen"; pytest tests/ui -q # PowerShell
```
64/108 module test dựng widget thật và 20 module import PySide6 ở module scope, nên môi
trường test **phải** có đủ runtime dependency. Chỉ có **một** `requirements.txt`, không có
cặp runtime/test riêng.
## 4. Bẫy khi sửa bug UI
- **Gate S rất dễ vỡ khi vá bug.** Nhiều file UI đã sát 400 dòng. Trước khi thêm code:
```bash
python scripts/check_loc.py --max-lines 400 | grep <tên file>
```
Sắp vượt → tách module **và nêu trong `fix_plan.md` trước khi làm** (`guardrail.md` G6).
- **Gate O bắt module mồ côi.** Tách file mới ra mà chưa import vào đâu là Gate O đỏ.
Tách và nối dây trong cùng một commit.
- **Gate C ít khi liên quan bug UI** — trừ khi bản vá "tiện tay" import widget vào
`application/`. Đó là dấu hiệu sửa sai tầng.
- **File `.py` mới phải được `git add` ngay.**
`tests/test_no_ignored_source.py::test_khong_file_py_nao_bi_bo_quen_chua_theo_doi` quét
`git ls-files --others --exclude-standard` và làm suite đỏ nếu có file `.py` chưa theo dõi
trong thư mục nguồn. File test mới cũng tính. Triệu chứng giống hệt regression, nhưng
không phải:
```
AssertionError: File mã nguồn chưa được git add — clone sạch sẽ thiếu:
tests/ui/test_<...>.py
```
- **`.venv` không được nằm trong repo.** `install.bat` dựng venv ở
`%LOCALAPPDATA%\CoworkLocal` chính vì gate đi bộ toàn cây thư mục — một `.venv` trong repo
biến mọi module vendored thành vi phạm Gate O.
## 5. Định nghĩa "xong"
Từ `docs/governance/definition-of-done.md`:
- code xong;
- test liên quan pass;
- tài liệu cập nhật nếu cần;
- PR đã được review;
- đã merge vào nhánh mặc định.
**Một PR = một thay đổi logic.** Không gộp nhiều bug UI không liên quan vào một PR.
Đóng góp từ FSG AI Core Team chỉ "xong" khi PR đã merge vào Cowork Local — "Core AI code
xong" hoặc "pre-review pass" **không** phải Done. Bằng chứng bắt buộc: core issue reference,
PR, evidence test, reviewer phía Cowork, merge commit.
---
## 6. Suite này vốn đã KHÔNG xanh
Tại `e5fa21e` (2026-09-07), chạy đầy đủ trên Windows + Python 3.14 cho ra:
```
11 failed, 884 passed, 2 skipped, 66 errors
```
Nghĩa là **"pytest đỏ" không nói lên điều gì** về bản vá của bạn. Bắt buộc phải so với
baseline, và so bằng **danh sách tên test**:
```bash
git stash push --include-untracked -m baseline
QT_QPA_PLATFORM=offscreen pytest -q > /tmp/base.txt 2>&1
git stash pop
QT_QPA_PLATFORM=offscreen pytest -q > /tmp/after.txt 2>&1
grep "^FAILED" /tmp/base.txt | sed 's/ - .*//' | sort > /tmp/f_base.txt
grep "^FAILED" /tmp/after.txt | sed 's/ - .*//' | sort > /tmp/f_after.txt
comm -13 /tmp/f_base.txt /tmp/f_after.txt # rỗng = không regression
```
Không so con số tổng: một test cũ hỏng cộng một test mới xanh cho ra cùng con số.
Nhóm đỏ lớn nhất hiện nay là `tests/characterization/test_co4e_runs_page.py` —
`RuntimeError: libshiboken: Internal C++ object (QGraphicsScene) already deleted`
(bẫy P13 trong `qt_pitfalls.md`). Chưa ai nhận sửa.
Gate A và Gate S cũng đỏ sẵn:
- A — 3 phát hiện trong `tests/test_project_context_{e2e,issue,knowledge}.py`;
- S — `core/chat_agent.py` 423 LOC, `mcp_servers/project_context/providers/knowledge.py` 408 LOC.
Đừng nhận nhầm bốn thứ trên là do bản vá của mình (`guardrail.md` G10).
+95
View File
@@ -0,0 +1,95 @@
# Screen Map — dịch lời người dùng thành file:line
Người dùng báo lỗi bằng lời ("cái bảng bên phải màn thống kê"). File này để agent
Triage quy nó về đúng widget.
---
## 1. Nav rail — bốn màn chính
Định nghĩa tại `presentation/shell/main_window.py:151` (`_nav_defs`), thứ tự = page index:
| Row | i18n key | Icon | Dựng | Widget |
|---|---|---|---|---|
| 0 | `app.tab.dashboard` | `dashboard` | lười | `presentation/dashboard/dashboard_tab.py::DashboardTab` |
| 1 | `app.tab.schedule` | `schedule` | lười | `presentation/scheduling/schedule_task_tab.py::ScheduleTaskTab` |
| 2 | `app.tab.workspace` | `workspaces` | **ngay** (màn HOME) | `ui/workspace_tab.py::WorkspaceTab` |
| 3 | `app.tab.monitoring` | `monitoring` | lười | `ui/monitoring_tab.py::MonitoringTab` |
App mở lên là ở **Workspace ▸ Project**.
## 2. Sub-tab của Workspace
`ui/workspace_tab.py:214-245`:
| Tab | i18n key | Widget |
|---|---|---|
| Project | `workspace.tab_project` | `_build_project_tab()` trong chính file đó |
| Cowork | `workspace.tab_cowork` | `ui/cowork_tab.py` |
| Co4E | `workspace.tab_co4e` | `ui/co4e_tab.py` → `presentation/co4e/` |
| Folder | `workspace.tab_folder` | `presentation/folder/folder_tab.py` |
| GraphRAG | `workspace.tab_graphrag` | `presentation/graph/structure_graph_view.py` |
Monitoring **giữ tab strip riêng** với 8 sub-view (tổng quan, trạng thái agent, công cụ,
nhật ký hành động, lịch sử gọi MCP, sự kiện bảo mật, agents admin, icon). Workspace là màn
duy nhất giấu tab strip đi.
## 3. Thành phần luôn nổi trên mọi màn
| Thành phần | File | Triệu chứng người dùng hay mô tả |
|---|---|---|
| Nav rail trái, nút thu gọn | `presentation/shell/nav_rail.py` | "menu bị co lại", "không thấy tên project" |
| Top bar (theme, ngôn ngữ) | `presentation/shell/top_bar.py` | "đổi giao diện không ăn" |
| Toast góc trên trái | `presentation/shell/toast.py` | "thông báo xong việc che mất nút" |
| Help agent nổi góc dưới phải | `ui/help_agent_widget.py` | "con robot che nút gửi" |
| Status bar dưới cùng | `main_window.statusBar()` | "dòng chữ dưới đáy không đổi" |
## 4. Dialog
`ui/`: `login_dialog.py`, `permission_dialog.py`, `settings_dialog.py`, `skills_dialog.py`,
`task_editor_dialog.py`, `file_edit_dialog.py`, `flow_dialog.py`, `mcp_servers_dialog.py`,
`co4e_agent_dialog.py`, `ext_connector_dialog.py`.
## 5. 🔎 Hai file tra cứu bắt buộc dùng
### `docs/screens/manifest.json`
Mỗi màn đã chụp ảnh có một entry: `slug`, `title`, `theme`, `note` (**đúng `file.py:line`
nơi màn đó được dựng**), `file` (ảnh), `nav`.
```bash
# Người dùng nói "màn Kanban lịch trình"
python -c "import json;print([e for e in json.load(open('docs/screens/manifest.json')) if 'schedule' in e['slug']])"
```
Ảnh có **cả bản dark và light** (`*-dark.png` / `*-light.png`) — dùng để đối chiếu trước/sau
và để kiểm tra bug chỉ xảy ra ở một theme.
### `docs/screens/controls.json`
Danh mục **mọi control** đã trích tự động từ source: `file`, `var`, `type` (`QLineEdit`...),
`kind` (mô tả tiếng Việt: "ô nhập", "nút"...), `label`, `line`, `signals`, `object_name`.
```bash
# Người dùng nói "ô nhập email trong màn tài khoản"
python - <<'PY'
import json
for f in json.load(open('docs/screens/controls.json')):
for c in f['controls']:
if 'email' in (c['var'] + c['label']).lower():
print(f["file"], c["line"], c["var"], c["type"], c["object_name"])
PY
```
Cột `object_name` đặc biệt quan trọng khi sửa bug màu/style: rỗng nghĩa là widget **chưa**
được style qua `_TEMPLATE`, nên nó đang ăn style mặc định của class — thường chính là
nguyên nhân của "chỗ này nhìn khác chỗ kia".
## 6. Quy trình tra 4 bước cho Triage
1. Xác định **nav row** (Dashboard / Schedule / Workspace / Monitoring) từ mô tả hoặc ảnh.
2. Xác định **sub-tab / dialog**.
3. Tra `manifest.json` → lấy `note` = `file.py:line`.
4. Tra `controls.json` → lấy đúng `var` + `line` + `object_name` của control bị lỗi.
Không qua đủ 4 bước thì `confidence` tối đa là `low`.
+236
View File
@@ -0,0 +1,236 @@
# Secret & Config — nơi credential được phép nằm
Nguồn: `infrastructure/secrets/secret_store.py`, `infrastructure/secrets/keyring_adapter.py`,
`infrastructure/config/schema_migration.py`, `config.py`, `SECURITY.md`.
Đây là knowledge module của `security-defect-fixer`. Ba module UI (`theme_tokens`,
`i18n_rules`, `screen_map`) không đụng tới phần này.
---
## 1. Thang bậc: credential được phép nằm ở đâu
Từ an toàn nhất xuống:
| Bậc | Nơi | Dùng cho | API |
|---|---|---|---|
| 1 | **OS Keyring** qua `SecretStore` | API key, token, mật khẩu thật | `secrets.set/get/has/delete` |
| 2 | **Biến môi trường** | Giá trị do quản trị viên đặt lúc triển khai | `_apply_env_overrides` |
| 3 | **`config.json`** | Cấu hình **không bí mật** | `ctx.config.<nhóm>` |
| 4 | **Hằng số trong mã nguồn** | ❌ Không bao giờ cho credential | — |
Bậc 4 là lỗi bị Gate A bắt, và tệ hơn: nó đi vào Git history vĩnh viễn.
## 2. `SecretStore` — interface, không phải hàm tiện ích
```python
# infrastructure/secrets/secret_store.py
@runtime_checkable
class SecretStore(Protocol):
def get(self, key: str) -> str | None: ... # thiếu key KHÔNG được ném lỗi
def set(self, key: str, value: str) -> None: ...
def delete(self, key: str) -> None: ... # không có sẵn thì im lặng
def has(self, key: str) -> bool: ... # kiểm tra mà không đọc giá trị ra
def provider_key(name: str) -> str:
return f"provider:{name}" # quy ước đặt key
```
Lý do là Protocol chứ không phải hàm: bản thật gọi OS Keyring — chậm, có thể ném lỗi, và
**test không được đụng keyring máy thật**. Có interface thì test tiêm `FakeSecretStore`.
Bản thật: `KeyringAdapter`, `SERVICE = "cowork-local"`, có property `available`.
**Luật khi thêm secret mới:**
- Đặt key theo quy ước có sẵn, không tự nghĩ kiểu mới. Chưa có quy ước cho loại của bạn →
thêm một hàm `*_key()` cạnh `provider_key`, đừng rải chuỗi literal khắp nơi.
- Màn Settings hiển thị trạng thái bằng `has()`, **không** bằng `get()`. Không đọc giá trị bí
mật ra chỉ để vẽ dấu tích.
- `KeyringAdapter.available` là False (Linux thiếu backend, CI) → phải có đường thoái lui
không làm hỏng app.
## 3. Schema migration — cách đổi hình dạng config an toàn
```python
# infrastructure/config/schema_migration.py
CURRENT_VERSION = 2
ASSUMED_VERSION = 1 # file thiếu schema_version ⇒ coi là 1
STEPS = {1: _v1_to_v2} # mỗi bước v(n) → v(n+1), chạy tuần tự, không nhảy cóc
```
Bốn luật đã chốt:
1. **Sao lưu trước khi nâng** — `backup()` tạo `config.json.v<timestamp>.bak`. Người dùng lùi
về bản app cũ vẫn còn đường về.
2. **Chỉ nâng, không hạ.** File mới hơn app → log cảnh báo, dùng nguyên trạng, không đoán ngược.
3. **Mỗi bước là một hàm riêng** trong `STEPS`, không viết logic đoán mò kiểu
"có khoá `office` nghĩa là file cũ".
4. **Bước không nâng được version thì dừng**, không lặp vô hạn.
### Tiền lệ cần bắt chước: `_v1_to_v2`
Đây **chính là** bước đã gỡ `api_key` khỏi đĩa đẩy vào `SecretStore`. Đọc nó trước khi
thiết kế bất kỳ migration credential nào:
```python
def _v1_to_v2(data, secrets):
if secrets is None or not getattr(secrets, "available", True):
log.info("bỏ qua v1→v2: máy này chưa có kho bí mật dùng được")
return data # KHÔNG chuyển — thà để khoá nằm nguyên còn hơn
# xoá đi rồi người dùng mất khoá không hiểu vì sao
...
secrets.set(provider_key(name), key)
conf["api_key"] = ""
out["schema_version"] = 2
```
Hai quyết định đáng học:
- **Không có keyring thì không chuyển.** Giữ nguyên version 1, lần chạy sau trên máy có
keyring sẽ chuyển. Mất dữ liệu người dùng tệ hơn là hoãn migration.
- **Bỏ qua giá trị bù nhìn.** `api_key == "ollama"` là placeholder, đẩy vào keyring chỉ tổ rác.
## 4. ⚠️ Bẫy `.get(key, fallback)` trên config đã deep-merge
Đây là bẫy sinh ra cả một lớp lỗi, và nó **không hiển nhiên**.
```python
# config.py:265
def _deep_merge(base, override): ...
# infrastructure/config/json_config_repository.py:90
merged = _deep_merge(merged, stored) # bắt đầu từ DEFAULT_CONFIG
```
Config đưa tới UI **luôn** đã được deep-merge với `DEFAULT_CONFIG`. Nghĩa là:
> Mọi key có trong `DEFAULT_CONFIG` thì **luôn tồn tại** trong dict. Tham số thứ hai của
> `.get()` **không bao giờ chạy**.
```python
# DEFAULT_CONFIG có "sandbox_pw": ""
sec.get("sandbox_pw", "<literal đã bị gỡ>") # → "" , KHÔNG phải "<literal đã bị gỡ>"
```
Hệ quả:
- Fallback trông như "mặc định an toàn" thực ra là **code chết**.
- Giá trị thật sự đang chạy là giá trị trong `DEFAULT_CONFIG` — thường là `""`.
- Chuỗi rỗng đem đi so sánh mật khẩu là **mở khoá cho input rỗng**.
**Luật:** đọc credential từ config thì **không** dùng fallback trong `.get()`. Đọc giá trị
thật, rồi xử lý tường minh trường hợp rỗng — xem §9 về cách so sánh.
## 5. Ghi đè bằng biến môi trường
`config.py::_apply_env_overrides` (dòng 276) — các biến hiện có:
| Biến | Ghi vào |
|---|---|
| `COWORK_SANDBOX_PASSWORD` | `agent_security.sandbox_pw` |
| `COWORK_MS365_UNLOCK_CODE` | `ms365.unlock_code` |
| `COWORK_TEAMS_WEBHOOK` | `teams.webhook_url` |
| `COWORK_ACTIVE_PROVIDER` | `active_provider` |
| `COWORK_CA_BUNDLE` | `tls_ca_bundle` |
Env override chạy **sau** deep-merge, nên nó thắng cả default lẫn file. Thêm secret mới thì
cân nhắc có cần đường env cho triển khai theo tổ chức không.
## 6. Sinh giá trị ngẫu nhiên — dùng lại thứ có sẵn
```python
# core/accounts.py:89
_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" # bỏ I, L, O, 0, 1 dễ đọc nhầm
CODE_LENGTH = 12
def generate_code(existing_codes=None) -> str:
"""A random, non-repeating 12-character access code."""
code = "".join(secrets.choice(_CODE_ALPHABET) for _ in range(CODE_LENGTH))
```
Dùng `secrets`, **không** `random`. Bảng chữ đã loại ký tự dễ nhầm vì mã này được người
đọc bằng mắt rồi gõ lại. Cần mã cho người dùng đọc → gọi lại hàm này, đừng viết bản thứ hai.
Không cần người đọc (token nội bộ) → `secrets.token_urlsafe(32)`.
## 7. Gate A và Git history
```bash
python scripts/audit_security.py
```
Quét file `.py` và file config. Hiện có 3 phát hiện **có sẵn** trong
`tests/test_project_context_*.py` — đừng nhận nhầm là do bản vá của mình.
**Nếu secret đã nằm trong Git history** (`SECURITY.md`):
1. Dừng phân phối.
2. Báo Cowork Team.
3. **Không** rewrite history, **không** force-push nếu chưa có kế hoạch khắc phục phối hợp.
4. Xoay (rotate) credential có thể đã lộ.
Gỡ literal khỏi code ở commit hôm nay **không** gỡ nó khỏi lịch sử. Luôn nêu điều này trong plan.
## 8. Câu hỏi phải hỏi người, không được tự quyết
`docs/governance/review-policy.md`: thay đổi chạm credential cần Cowork Team soi thêm, và
**CI xanh không đủ để merge**. Bốn câu sau là quyết định sản phẩm/bảo mật, agent chỉ được đề xuất:
1. Đây là **khoá chống bấm nhầm** hay **cơ chế bảo mật thật**? (quyết định mức đầu tư)
2. Lưu plaintext trong Keyring, hay lưu **hash** để cả admin cũng không đọc được?
3. Người dùng hiện có sẽ ra sao — giữ mật khẩu cũ, hay bị buộc đặt lại?
4. Giá trị sinh ra hiển thị cho người dùng thế nào, và hiện **mấy lần**?
---
## 9. So sánh credential — hai bẫy đi liền nhau
Ghi lại từ defect `SEC-20260907-01`. Cả hai đều là bug **thật** đã xảy ra trong repo này.
### 9.1 Chuỗi rỗng phải bị chặn TRƯỚC khi so sánh
`DEFAULT_CONFIG` cho credential thường là `""`, và §4 giải thích vì sao giá trị đó luôn
đến tay chỗ dùng. Nên `entered == stored` biến ô nhập trống thành mật khẩu hợp lệ.
Mẫu đúng đã có sẵn trong repo — `infrastructure/config/json_config_repository.py`:
```python
if (code or "") and code == self.ms365.get("unlock_code", ""):
```
`(code or "") and ...` là chốt chặn. Bên sandbox thiếu đúng chốt này và thành lỗ hổng S1.
### 9.2 ⚠️ `secrets.compare_digest` KHÔNG nhận `str` ngoài ASCII
Đổi `==` sang `compare_digest` là nâng cấp đúng hướng (timing-safe), nhưng nó mang theo
một ràng buộc mới mà `==` không có:
```python
>>> secrets.compare_digest("mật khẩu", "mật khẩu")
TypeError: comparing strings with non-ASCII characters is not supported
```
Cowork Local mặc định **tiếng Việt** và phục vụ **khách Nhật**. Mật khẩu có dấu ở đây là
input bình thường, không phải trường hợp biên. Để nguyên là exception thoát ra khỏi Qt slot.
**Luật:** so sánh trên bytes.
```python
return secrets.compare_digest(entered.encode("utf-8"), stored.encode("utf-8"))
```
### 9.3 Bài học tổng quát — quan trọng hơn hai mục trên
> Một API "an toàn hơn" thường có **miền đầu vào hẹp hơn** thứ nó thay thế.
`compare_digest` an toàn hơn `==` về timing, nhưng chỉ nhận ASCII-`str` hoặc bytes.
Trước khi thay một phép toán bằng phiên bản "chuẩn bảo mật", luôn hỏi:
- [ ] Nó nhận những kiểu nào? Có hẹp hơn cái cũ không?
- [ ] Dữ liệu thật của app có nằm trọn trong miền đó không? (ngôn ngữ, độ dài, `None`)
- [ ] Nó ném exception hay trả `False` khi gặp đầu vào ngoài miền?
- [ ] Có test cho đúng đầu vào ngoài miền đó chưa?
Ba dòng đầu của checklist này chính là thứ đã bị bỏ qua ở `SEC-20260907-01`, và nó lọt
qua vòng review đầu tiên.
+101
View File
@@ -0,0 +1,101 @@
# Theme & Design Tokens — luật màu sắc của Cowork Local
Nguồn: docstring đầu `theme/__init__.py`, `theme/palettes.py`, `theme/qss.py`,
`theme/qss_controls.py`.
---
## 1. Luật gốc
> **Không file nào ngoài `theme/` được đặt tên một màu.**
Cơ chế duy nhất:
```text
Palette (token ngữ nghĩa) → _TEMPLATE (một QSS duy nhất) → stylesheet(theme)
```
Hai cách hợp lệ để một widget có màu:
1. **Khai báo** — gán `objectName` cho widget, style nó trong `_TEMPLATE`
(`theme/qss.py`). Đây là cách mặc định.
2. **Vẽ tay** — widget vẽ bằng `QPainter` (chart, canvas, syntax highlighter) thì gọi
`current_palette()` rồi đọc token.
Cách **không** hợp lệ, bị reject review:
```python
self.label.setStyleSheet("color: #dc2626;") # ❌ hex ngoài theme/
pen.setColor(QColor("red")) # ❌ tên màu literal
self.card.setStyleSheet("background: rgba(0,0,0,.1)") # ❌
```
## 2. API cần nhớ
| Hàm | Dùng khi |
|---|---|
| `theme.stylesheet(theme)` | Sinh QSS toàn app, truyền vào `QApplication.setStyleSheet` |
| `theme.set_active_theme(theme)` | **Phải** gọi ngay cạnh mỗi `setStyleSheet(stylesheet(...))` |
| `theme.current_theme()` | `'dark'` / `'light'` đang hiển thị |
| `theme.current_palette()` | Token của theme đang hiển thị — dùng trong `paintEvent` |
| `theme.palette(theme)` | Token của một theme cụ thể |
| `theme.resolve_theme('system')` | Suy ra dark/light từ color scheme của OS |
| `theme.role_colors(theme)` | Màu theo vai trò hội thoại: user/assistant/tool/result/error |
`current_palette()` tồn tại để code vẽ **không** phải đọc lại `config.json` mỗi lần
repaint — đó từng là bug hiệu năng thật. Không thay bằng đọc config.
## 3. Nhóm token
Palette là `@dataclass(frozen=True)`. Các nhóm chính:
| Nhóm | Token | Ý nghĩa |
|---|---|---|
| Bề mặt (thang 4 bậc) | `bg` | nền cửa sổ / canvas |
| | `surface` | panel, card, group box (**không** phải nav rail) |
| | `surface_raised` | input, list, tree — thứ người dùng gõ/chọn |
| | `overlay` | menu, tooltip, popup |
| | `sunken` | log, code, terminal — thứ để đọc vào |
| | `hover` / `active` | trạng thái hover / đang bấm |
| Chữ | `text`, `text_muted`, ... | |
| Nhấn | `accent`, `accent_solid` | **Hai token khác nhau có chủ đích**: màu đọc được *dạng chữ* trên nền tối thì quá nhạt để làm *nền* cho chữ trắng |
| Trạng thái | `danger`, ... | |
| Vai trò hội thoại | `role_user`, `role_assistant`, `role_tool`, `role_result`, `role_error` | |
| Code | `code_string`, ... | syntax highlighting |
Token là **ngữ nghĩa**, không phải literal: `danger` / `text_muted` — không bao giờ
`blue` / `grey2`. Thêm một theme = thêm một `Palette`, không phải sửa stylesheet.
## 4. Ràng buộc thiết kế (đừng "sửa" nhầm thành bug)
- **Không gradient, không glow.** Bảng màu lấy từ VS Code "Dark Modern" / "Light Modern".
Bề mặt phẳng, góc gần vuông, một màu accent chỉ dành cho thứ người dùng thao tác.
- **Chiều sâu đến từ thang bề mặt và viền mảnh**, không từ màu.
- **Silhouette VS Code:** nav rail **tối hơn** vùng nội dung, không sáng hơn.
Người dùng báo "menu trái tối quá" — đó là thiết kế, không phải bug. Xem `examples/bad_fix.md`.
- **Contrast giữ ở WCAG AA (4.5:1)** cho body text và cho chữ trên nút đặc.
- Bốn giá trị của VS Code không đạt AA đã được nhích lên vừa đủ (số dòng dark 3.59:1,
chữ mờ trên sidebar sáng 4.28:1, xanh lá sáng 4.33:1, hổ phách sáng 3.12:1). Mỗi chỗ có
comment ghi giá trị gốc — **không** trả chúng về giá trị VS Code.
## 5. Mũi tên combo box (`_chevron_asset`)
QSS `image:` chỉ nhận đường dẫn file/resource, không nhận `QPixmap`. Và một khi
`::drop-down` / `::up-button` / `::down-button` bị style, Qt **ngừng vẽ mũi tên mặc định**.
Vì vậy `theme/palettes.py::_chevron_asset` render sẵn PNG chevron ra thư mục tạm và cache
theo hash `(direction, color)`.
Hệ quả khi debug:
- "Combo box mất mũi tên" → gần như luôn do một stylesheet cục bộ đè lên `::drop-down`.
- File cache nằm ở `%TEMP%/cowork_local_theme/chevron_*.png`. Xoá nó để buộc render lại
khi test màu mới.
## 6. Checklist sửa bug liên quan màu sắc
- [ ] Đã kiểm tra bug xuất hiện ở **cả** dark và light chưa? (`docs/screens/*-dark.png` / `*-light.png`)
- [ ] Bản sửa dùng token, không dùng hex?
- [ ] Nếu thêm token mới: đã thêm cho **cả** `DARK` và `LIGHT`?
- [ ] Nếu là chữ trên nền đặc: đã dùng `accent_solid` thay vì `accent`?
- [ ] Contrast còn ≥ 4.5:1?
- [ ] Widget dựng sau khi đổi theme có nhận đúng stylesheet? (xem `qt_pitfalls.md` P07)