Compare commits
2
Commits
2f3cd100f8
...
0efc4bbf0e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0efc4bbf0e | ||
|
|
fe99bc8727 |
+62
-8
@@ -21,6 +21,7 @@ Anti-pattern mà bộ này cố tình tránh (mục 10 của tài liệu trainin
|
||||
| Không có Output Contract | Mọi output đi qua template trong `output/` |
|
||||
| Không có Quality Gate | Mỗi role có Quality Gate riêng + `checklist/` dùng chung |
|
||||
| Không có example | `examples/good_fix.md` và `examples/bad_fix.md` |
|
||||
| Effort cố định bất kể lỗi to nhỏ | `roles/0_fix_dispatcher.md` chấm tier trước, lỗi 4px chạy 0 agent |
|
||||
|
||||
Sáu role **không** bị tách thành 7 file nhỏ mỗi role (role/task/process/...). Lý do:
|
||||
phần bị lặp giữa các role chính là guardrail, knowledge và checklist — chúng đã được
|
||||
@@ -47,7 +48,8 @@ agent/
|
||||
│ ├─ qt_pitfalls.md ← 20 nguyên nhân gốc hay gặp của bug UI PySide6
|
||||
│ ├─ secrets_and_config.md ← SecretStore, schema migration, bẫy .get() trên config merge
|
||||
│ └─ quality_gates.md ← CASAN gate, lệnh chạy, test headless
|
||||
├─ roles/ ← 7 agent chuyên biệt
|
||||
├─ roles/ ← 1 hub + 7 agent chuyên biệt
|
||||
│ ├─ 0_fix_dispatcher.md ← HUB: chấm tier T0/T1/T2/T3, chọn lane, tách defect
|
||||
│ ├─ 1_ui_bug_triage.md
|
||||
│ ├─ 2_ui_visual_fixer.md
|
||||
│ ├─ 3_ux_flow_fixer.md
|
||||
@@ -55,14 +57,17 @@ agent/
|
||||
│ ├─ 5_fix_implementer.md
|
||||
│ ├─ 6_regression_reviewer.md
|
||||
│ └─ 7_security_defect_fixer.md
|
||||
├─ commands/
|
||||
│ └─ fix.md ← nguồn của slash command /fix (điểm vào của hub)
|
||||
├─ workflow/
|
||||
│ ├─ intake_to_fix.md ← pipeline end-to-end, ai làm gì ở bước nào
|
||||
│ ├─ intake_to_fix.md ← pipeline end-to-end, 4 lane theo tier
|
||||
│ └─ handoff_contract.md ← envelope truyền giữa các agent
|
||||
├─ checklist/
|
||||
│ ├─ ui_review.md
|
||||
│ ├─ ux_review.md
|
||||
│ └─ pr_readiness.md
|
||||
├─ output/
|
||||
│ ├─ dispatch_plan.md ← template điều phối (output của Hub)
|
||||
│ ├─ defect_record.md ← template hồ sơ lỗi (output của Triage)
|
||||
│ ├─ fix_plan.md ← template phương án sửa (output của Fixer)
|
||||
│ ├─ fix_report.md ← template báo cáo sau khi sửa (output của Implementer)
|
||||
@@ -74,10 +79,11 @@ agent/
|
||||
|
||||
---
|
||||
|
||||
## 3. Bảy agent và khi nào dùng
|
||||
## 3. Một hub + bảy agent, và khi nào dùng
|
||||
|
||||
| # | Agent | Pattern | Nhận vào | Trả ra |
|
||||
|---|---|---|---|---|
|
||||
| **0** | **Fix Dispatcher** (hub) | Router | Phản ánh thô của người dùng | `dispatch_plan.md` — tier + lane + tách defect |
|
||||
| 1 | **UI Bug Triage** | Reviewer | Lời kể lộn xộn của user, ảnh chụp màn hình, log | `defect_record.md` + phân loại + route |
|
||||
| 2 | **UI Visual Fixer** | Generator | defect_record (loại `visual`) | `fix_plan.md` — layout/QSS/theme/icon/DPI |
|
||||
| 3 | **UX Flow Fixer** | Generator | defect_record (loại `flow`) | `fix_plan.md` — luồng, trạng thái, phản hồi |
|
||||
@@ -86,19 +92,36 @@ agent/
|
||||
| 6 | **Regression Reviewer** | Reviewer | Patch + fix_report | Verdict PASS/FAIL + `pr_body.md` |
|
||||
| 7 | **Security Defect Fixer** | Generator | defect_record (loại `security`) | `fix_plan.md` — credential, secret, migration |
|
||||
|
||||
Đây là **Multi-Agent Pattern**: `Triage (Planner) → Specialist → Implementer (Executor)
|
||||
→ Reviewer`. Không bỏ bước. Đặc biệt không bỏ bước 1: 80% bug UI báo lên là mô tả
|
||||
triệu chứng, không phải nguyên nhân.
|
||||
Đây là **Multi-Agent Pattern**: `Dispatcher (Router) → Triage (Planner) → Specialist →
|
||||
Implementer (Executor) → Reviewer`.
|
||||
|
||||
**Số bước thực chạy do agent 0 quyết định, không phải mặc định 5.** Bộ v1.2 chạy đủ pipeline
|
||||
cho mọi lỗi, kể cả đổi một giá trị 4px — đó là lý do agent 0 ra đời. Bốn lane:
|
||||
|
||||
| Tier | Lỗi kiểu gì | Lane | Gọi agent |
|
||||
|---|---|---|---|
|
||||
| **T0** | Đổi số đo hiển thị, sai chính tả chuỗi có key sẵn, đổi token màu có sẵn | DIRECT | **0 lần** — hub sửa luôn + 4 cổng máy |
|
||||
| **T1** | Nguyên nhân gốc đã rõ kèm `file:line`, 1 màn, ≤ 3 file, ≤ 40 LOC | SOLO | 1 lần |
|
||||
| **T2** | Nguyên nhân chưa rõ nhưng đã khoanh 1 màn; chạm QSS/token/i18n dùng chung | PAIR | 3 lần |
|
||||
| **T3** | Mô tả thuần triệu chứng, không tái hiện được, nhiều category, > 150 LOC | FULL | 4–5 lần |
|
||||
|
||||
Bước 1 vẫn **không** được bỏ ở T3 — 80% bug UI báo lên là mô tả triệu chứng, không phải
|
||||
nguyên nhân. Ở T1/T2, phần triage do hub tự làm trong `dispatch_plan`, và chỉ hợp lệ khi
|
||||
phản ánh đã tự chỉ ra màn hình + triệu chứng cụ thể. Bước 6 chỉ được bỏ ở T0/T1, và phải
|
||||
nêu rõ cổng nào thay thế.
|
||||
|
||||
Agent 7 là specialist thứ tư, ngang hàng 2/3/4 trong pipeline, nhưng khác ở hai điểm: nó
|
||||
được phép chạm `config.py`, `infrastructure/`, `core/` (ba role kia bị chặn ở tầng
|
||||
presentation), và nó **không được tự quyết chính sách bảo mật** — bốn câu hỏi bắt buộc trả
|
||||
về cho Cowork Team.
|
||||
|
||||
### Routing rule (Triage quyết định)
|
||||
### Routing rule (Hub chấm tier → Triage chọn specialist)
|
||||
|
||||
```text
|
||||
Người dùng báo lỗi
|
||||
│
|
||||
├─ agent 0 tách thành N defect_id, chấm tier từng cái
|
||||
│ (≤ 5 lệnh đọc/grep, 0 subagent; hết mà chưa chấm được → T2)
|
||||
│
|
||||
├─ "nhìn sai / lệch / mất chữ / màu lạ / bị che" → 2. UI Visual Fixer
|
||||
├─ "bấm không ăn / không biết đang chạy / mất dữ liệu" → 3. UX Flow Fixer
|
||||
@@ -108,12 +131,36 @@ Người dùng báo lỗi
|
||||
Trả về, mở issue type:bug thường.
|
||||
|
||||
Nhóm `security` THẮNG mọi nhóm khác: lỗi vừa lệch layout vừa lộ credential thì đi 7 trước.
|
||||
Tín hiệu bảo mật cũng ép tier lên **T3-SEC** bất kể diff nhỏ cỡ nào — một dòng `==` so
|
||||
mật khẩu không bao giờ là T0.
|
||||
```
|
||||
|
||||
Tier chỉ đi **lên**. FAIL ở bước 6 → tier +1 rồi chạy lại, không sửa lại ở nguyên tier cũ.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cách dùng
|
||||
|
||||
### 4.0 Điểm vào (khuyến nghị)
|
||||
|
||||
Cài một lần cho mỗi máy — `.claude/` nằm trong `.gitignore`, nên nó **không** theo
|
||||
clone; `agent/` mới là bản gốc được version:
|
||||
|
||||
```bash
|
||||
mkdir -p .claude/agents .claude/commands
|
||||
cp agent/roles/[1-7]_*.md .claude/agents/
|
||||
cp agent/commands/fix.md .claude/commands/
|
||||
```
|
||||
|
||||
Rồi:
|
||||
|
||||
```text
|
||||
/fix màn Folder kéo to ra thì mất cây thư mục bên trái
|
||||
```
|
||||
|
||||
Hub sẽ chấm tier, in `dispatch_plan`, rồi tự chạy đúng lane. Chỉ gọi trực tiếp role 1–7
|
||||
khi đã biết chắc tier.
|
||||
|
||||
### 4.1 Dùng thủ công (mọi trợ lý AI)
|
||||
|
||||
Nạp theo đúng thứ tự này rồi dán bug report của user vào:
|
||||
@@ -122,6 +169,7 @@ Nạp theo đúng thứ tự này rồi dán bug report của user vào:
|
||||
agent/system/guardrail.md
|
||||
agent/system/security.md
|
||||
agent/system/response_policy.md
|
||||
agent/roles/0_fix_dispatcher.md ← luôn nạp trước, để biết cần chạy tới đâu
|
||||
agent/roles/<role đang dùng>.md
|
||||
+ các file knowledge/ mà role đó liệt kê ở mục "KNOWLEDGE"
|
||||
```
|
||||
@@ -133,9 +181,13 @@ subagent, copy sang `.claude/agents/`:
|
||||
|
||||
```bash
|
||||
mkdir -p .claude/agents
|
||||
cp agent/roles/*.md .claude/agents/
|
||||
cp agent/roles/[1-7]_*.md .claude/agents/
|
||||
```
|
||||
|
||||
`0_fix_dispatcher.md` **không** copy vào `.claude/agents/`: hub cần quyền gọi agent khác,
|
||||
mà subagent trong Claude Code không gọi được subagent. Hub chạy ở session chính, qua
|
||||
`/fix` (`.claude/commands/fix.md`).
|
||||
|
||||
Sau đó gọi bằng tên: `ui-bug-triage`, `ui-visual-fixer`, `ux-flow-fixer`,
|
||||
`i18n-a11y-fixer`, `fix-implementer`, `regression-reviewer`, `security-defect-fixer`.
|
||||
|
||||
@@ -154,4 +206,6 @@ trong commit message — instruction cũng là code.
|
||||
|---|---|---|
|
||||
| 1.0 | 2026-09-07 | Bản đầu: 6 role, 6 knowledge module, 4 output contract |
|
||||
| 1.1 | 2026-09-07 | Thêm role 7 `security-defect-fixer` + `knowledge/secrets_and_config.md`. Lý do: bộ v1.0 chỉ phủ UI/UX, nên credential hardcode phát hiện qua màn Settings bị rơi vào `not-ui` và không ai nhận |
|
||||
| 1.4 | 2026-09-08 | Nạp bài học từ lượt audit i18n toàn app. `knowledge/i18n_rules.md` §2.0 (`bind_*` là cách mặc định cho chuỗi tĩnh, `bind_dynamic` cho chữ theo trạng thái, không bind dữ liệu), §"Cách TÌM ra hết các chỗ bị lỗi" (grep chuỗi tiếng Việt ra 962 dòng mà **không** dòng nào là lỗi thật; phép đo đúng là thay `tr()` bằng chuỗi mốc trên `MainWindow` thật), và 3 mục checklist mới. Lý do: bộ v1.3 không có cách nào phát hiện lỗi "chữ không được áp lại" — nó không để lại dấu vết nào trong source |
|
||||
| 1.3 | 2026-09-08 | Thêm hub `0_fix_dispatcher` + `output/dispatch_plan.md` + `/fix`. Lý do: bộ v1.2 không có tầng điều phối, nên **mọi** lỗi đều kéo cả pipeline 4–5 agent — kể cả nới một `setMinimumWidth` lên 232px. Bổ sung 4 lane theo tier, danh sách đóng T0 (6 loại + 9 disqualifier), 4 cổng máy thay reviewer ở T0, luật escalate một chiều, và luật tách một phản ánh thành nhiều `defect_id` chấm tier riêng |
|
||||
| 1.2 | 2026-09-07 | Nạp bài học từ lần chạy thật đầu tiên (`SEC-20260907-01`). Bản vá của bước 5 mang một blocker mà **không mục nào trong bộ v1.1 bắt được** — reviewer tìm ra bằng tay. Bổ sung: `secrets_and_config.md` §9 (chặn rỗng, `compare_digest` + ASCII, và luật "API an toàn hơn thường có miền đầu vào hẹp hơn"); `6_regression_reviewer.md` Bước 2.1 (ràng buộc miền đầu vào) và 4.1 (test rỗng ruột); `5_fix_implementer.md` + `quality_gates.md` (baseline bằng `comm -13` trên tên test, guard `git add`, và thực tế suite vốn đã đỏ 11+66); `bad_fix.md` ca 11-12 — hai ví dụ **có thật** đầu tiên trong file |
|
||||
|
||||
+144
-39
@@ -1,53 +1,158 @@
|
||||
# Checklist sẵn sàng tạo PR
|
||||
|
||||
Dùng bởi `fix-implementer` (bước 9) và `regression-reviewer` (bước 8).
|
||||
Bám theo `.gitea/PULL_REQUEST_TEMPLATE.md` và `docs/governance/definition-of-done.md`.
|
||||
Checklist này được sử dụng bởi:
|
||||
|
||||
## A. Cổng chất lượng
|
||||
* `fix-implementer` — kiểm tra ở bước 9.
|
||||
* `regression-reviewer` — kiểm tra ở bước 8.
|
||||
|
||||
- [ ] `python scripts/run_quality_gate.py` — xanh cả 5 cổng, **có dán output thật**.
|
||||
- [ ] Gate C: `domain/`/`application/` không import PySide6/PyQt/`ui`/`app`.
|
||||
- [ ] Gate A: không secret/plaintext mới.
|
||||
- [ ] Gate S: không file nào > 400 LOC.
|
||||
- [ ] Gate O: không module mồ côi (file mới đã được import trong cùng commit).
|
||||
- [ ] Gate A/N: pytest xanh; test vốn đỏ từ trước được ghi riêng.
|
||||
Tham chiếu:
|
||||
|
||||
## B. Kiểm chứng
|
||||
* `.gitea/PULL_REQUEST_TEMPLATE.md`
|
||||
* `docs/governance/definition-of-done.md`
|
||||
|
||||
- [ ] Test regression tồn tại và **đỏ trước / xanh sau**.
|
||||
- [ ] Test chạy được headless (`QT_QPA_PLATFORM=offscreen`).
|
||||
- [ ] Đã kiểm bằng mắt ở dark + light — hoặc ghi rõ "chưa kiểm chứng bằng mắt" kèm lý do.
|
||||
- [ ] Đã kiểm ở các ngôn ngữ liên quan.
|
||||
---
|
||||
|
||||
## C. Phạm vi & lịch sử
|
||||
## A. Kiểm tra chất lượng
|
||||
|
||||
- [ ] Một PR = một thay đổi logic. Không refactor lẫn vào.
|
||||
- [ ] Không đổi format/indent toàn file; diff đọc được.
|
||||
- [ ] Nhánh riêng, không commit thẳng `main`.
|
||||
- [ ] Commit message nêu nguyên nhân gốc + `file:line` + issue.
|
||||
- [ ] Không commit `.env`, `config.json` local, dữ liệu dưới `.cowork_local/`, `.venv`.
|
||||
* [ ] Chạy `python scripts/run_quality_gate.py`.
|
||||
Cả **5 quality gate đều phải PASS** và phải ghi lại **output thực tế**.
|
||||
|
||||
## D. Bảo mật
|
||||
* [ ] **Gate C:** Các thư mục `domain/` và `application/` không được import:
|
||||
- `PySide6`
|
||||
- `PyQt`
|
||||
- `ui`
|
||||
- `app`
|
||||
|
||||
- [ ] Không secret/PII/đường dẫn cá nhân trong code, test fixture, commit message, PR body.
|
||||
- [ ] Ảnh chụp màn hình đính kèm đã được redact.
|
||||
- [ ] Nếu chạm permission / credential / MCP write-exec / sandbox / network / TLS /
|
||||
isolation / model routing / xoá dữ liệu → đánh dấu `security-review: required` và ghi
|
||||
rõ trong PR rằng **CI xanh không đủ để merge**.
|
||||
* [ ] **Gate A:** Không tạo thêm secret hoặc thông tin nhạy cảm dạng plaintext.
|
||||
|
||||
## E. Nội dung PR
|
||||
* [ ] **Gate S:** Không có file nào vượt quá **400 dòng code (LOC)**.
|
||||
|
||||
- [ ] Summary nói **tại sao**, không chỉ **cái gì**.
|
||||
- [ ] Change Type đã tick.
|
||||
- [ ] Scope: nêu rõ cả phần **cố ý không** làm.
|
||||
- [ ] Validation: có lệnh và output thật.
|
||||
- [ ] Security Impact: đã điền, kể cả khi là "không có".
|
||||
- [ ] Compatibility: đã tick.
|
||||
- [ ] Reviewer Notes: chỉ ra chỗ cần soi kỹ nhất.
|
||||
- [ ] Tài liệu (`docs/`, ảnh `docs/screens/`) đã cập nhật nếu cần.
|
||||
* [ ] **Gate O:** Không có file/module mới bị bỏ quên.
|
||||
File Python mới phải được sử dụng/import trong cùng thay đổi.
|
||||
|
||||
## F. Ranh giới
|
||||
* [ ] **Gate A/N:** Test phải PASS.
|
||||
Nếu đã có test FAIL từ trước thì phải ghi rõ đó là **lỗi có sẵn**, không phải lỗi do bản sửa này gây ra.
|
||||
|
||||
- [ ] Agent **không** tự merge, **không** tự đóng issue.
|
||||
- [ ] Nếu là đóng góp của FSG AI Core: hiểu rằng chỉ "Done" khi PR đã merge vào Cowork Local,
|
||||
kèm đủ core issue reference, PR, evidence, reviewer phía Cowork, merge reference.
|
||||
---
|
||||
|
||||
## B. Kiểm tra bản sửa
|
||||
|
||||
* [ ] Có **regression test** cho lỗi đã sửa.
|
||||
|
||||
* [ ] Regression test phải chứng minh được:
|
||||
- **Trước khi sửa:** test FAIL.
|
||||
- **Sau khi sửa:** test PASS.
|
||||
|
||||
* [ ] Test chạy được ở chế độ headless:
|
||||
`QT_QPA_PLATFORM=offscreen`
|
||||
|
||||
* [ ] Nếu thay đổi liên quan đến UI:
|
||||
- Đã kiểm tra giao diện ở **Dark Mode**.
|
||||
- Đã kiểm tra giao diện ở **Light Mode**.
|
||||
- Nếu chưa thể kiểm tra bằng mắt, phải ghi rõ:
|
||||
**"Chưa kiểm chứng bằng mắt"** và nêu lý do.
|
||||
|
||||
* [ ] Nếu thay đổi liên quan đến ngôn ngữ:
|
||||
đã kiểm tra các ngôn ngữ bị ảnh hưởng.
|
||||
|
||||
---
|
||||
|
||||
## C. Kiểm tra phạm vi thay đổi và Git
|
||||
|
||||
* [ ] Một PR chỉ giải quyết **một thay đổi logic chính**.
|
||||
Không đưa refactor không liên quan vào cùng PR.
|
||||
|
||||
* [ ] Không tự ý format hoặc thay đổi indent của toàn bộ file.
|
||||
Diff phải rõ ràng và dễ review.
|
||||
|
||||
* [ ] Làm việc trên **branch riêng**.
|
||||
Không commit trực tiếp vào `main`.
|
||||
|
||||
* [ ] Commit message phải nêu:
|
||||
- Nguyên nhân gốc của lỗi.
|
||||
- Vị trí code liên quan (`file:line`).
|
||||
- Issue liên quan.
|
||||
|
||||
* [ ] Không commit các file/dữ liệu sau:
|
||||
- `.env`
|
||||
- `config.json` local
|
||||
- `.cowork_local/`
|
||||
- `.venv/`
|
||||
|
||||
---
|
||||
|
||||
## D. Kiểm tra bảo mật
|
||||
|
||||
* [ ] Không có các thông tin sau trong code, test fixture, commit message hoặc PR body:
|
||||
- Secret
|
||||
- PII/thông tin cá nhân
|
||||
- Đường dẫn chứa thông tin cá nhân trên máy local
|
||||
|
||||
* [ ] Nếu có ảnh chụp màn hình trong PR:
|
||||
đã che (redact) toàn bộ thông tin nhạy cảm trước khi đính kèm.
|
||||
|
||||
* [ ] Nếu thay đổi liên quan đến một trong các nội dung sau:
|
||||
|
||||
```
|
||||
- Permission/quyền truy cập
|
||||
- Credential/thông tin xác thực
|
||||
- MCP write/exec
|
||||
- Sandbox
|
||||
- Network
|
||||
- TLS
|
||||
- Isolation
|
||||
- Model routing
|
||||
- Xóa dữ liệu
|
||||
|
||||
thì phải:
|
||||
|
||||
1. Đặt `security-review: required`.
|
||||
2. Ghi rõ trong PR rằng:
|
||||
**"CI xanh không có nghĩa là có thể merge ngay."**
|
||||
3. Chờ security review theo quy trình trước khi merge.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## E. Kiểm tra nội dung PR
|
||||
|
||||
* [ ] **Summary** phải giải thích **tại sao cần sửa**, không chỉ mô tả đã sửa cái gì.
|
||||
|
||||
* [ ] Đã chọn **Change Type** phù hợp.
|
||||
|
||||
* [ ] **Scope** phải ghi rõ:
|
||||
- Những gì đã thay đổi.
|
||||
- Những gì **cố ý không thay đổi**.
|
||||
|
||||
* [ ] **Validation** phải ghi:
|
||||
- Lệnh đã chạy.
|
||||
- Kết quả thực tế/output.
|
||||
|
||||
* [ ] **Security Impact** phải được điền.
|
||||
Nếu không ảnh hưởng bảo mật, ghi rõ **"Không có"**.
|
||||
|
||||
* [ ] Đã chọn **Compatibility** phù hợp.
|
||||
|
||||
* [ ] **Reviewer Notes** phải chỉ ra những phần reviewer cần kiểm tra kỹ nhất.
|
||||
|
||||
* [ ] Đã cập nhật tài liệu nếu cần:
|
||||
- `docs/`
|
||||
- Ảnh màn hình trong `docs/screens/`
|
||||
|
||||
---
|
||||
|
||||
## F. Giới hạn quyền của Agent
|
||||
|
||||
* [ ] Agent **không được tự merge PR**.
|
||||
|
||||
* [ ] Agent **không được tự đóng issue**.
|
||||
|
||||
* [ ] Nếu đây là đóng góp từ **FSG AI Core**, cần hiểu rằng trạng thái **"Done"** chỉ được xác nhận khi PR đã thực sự được merge vào Cowork Local và có đầy đủ:
|
||||
|
||||
```
|
||||
- Core issue reference
|
||||
- PR reference
|
||||
- Evidence
|
||||
- Reviewer phía Cowork
|
||||
- Merge reference
|
||||
```
|
||||
|
||||
+190
-36
@@ -1,49 +1,203 @@
|
||||
# Checklist review bản vá UI (visual)
|
||||
# Checklist review bản vá UI (Visual)
|
||||
|
||||
Dùng bởi `ui-visual-fixer` (bước 7) và `regression-reviewer` (bước 5).
|
||||
Checklist này được sử dụng bởi:
|
||||
|
||||
## A. Đúng file
|
||||
* `ui-visual-fixer` — kiểm tra ở bước 7.
|
||||
* `regression-reviewer` — kiểm tra ở bước 5.
|
||||
|
||||
- [ ] Đã `grep` cả `ui/` và `presentation/`; file được sửa là file thực sự import vào runtime.
|
||||
- [ ] Widget này không có bản trùng tên ở thư mục còn lại.
|
||||
Mục tiêu: đảm bảo bản vá UI sửa đúng nguyên nhân, không phá theme, layout, icon hoặc vòng đời của giao diện.
|
||||
|
||||
## B. Màu & theme
|
||||
---
|
||||
|
||||
- [ ] Không hex literal (`#rrggbb`), không tên màu (`"red"`) ngoài `theme/`.
|
||||
- [ ] Không `setStyleSheet` cục bộ mới; style đi qua `objectName` + `theme/qss.py`.
|
||||
- [ ] Token mới có ở **cả** `DARK` và `LIGHT`.
|
||||
- [ ] Chữ trên nền đặc dùng `accent_solid`, không dùng `accent`.
|
||||
- [ ] Bậc bề mặt đúng ngữ nghĩa: `bg` / `surface` / `surface_raised` / `overlay` / `sunken`.
|
||||
- [ ] Contrast ≥ 4.5:1 cho body text và chữ trên nút đặc, ở cả hai theme.
|
||||
- [ ] Không thêm gradient/glow (trái ràng buộc thiết kế).
|
||||
- [ ] Nav rail vẫn tối hơn vùng nội dung.
|
||||
- [ ] Không trả bốn giá trị đã nhích lên WCAG AA về giá trị VS Code gốc.
|
||||
- [ ] Nếu chạm `_TEMPLATE`: đã liệt kê phạm vi ảnh hưởng toàn app.
|
||||
## A. Kiểm tra đúng file
|
||||
|
||||
## C. Layout & kích thước
|
||||
* [ ] Đã tìm kiếm trong **cả `ui/` và `presentation/`** để xác định file thực sự được ứng dụng sử dụng khi chạy.
|
||||
|
||||
- [ ] Không thêm `setFixedWidth` / `setFixedSize` / `setFixedHeight` mới.
|
||||
- [ ] Stretch factor / size policy được đặt tường minh.
|
||||
- [ ] `QScrollArea` có `setWidgetResizable(True)`.
|
||||
- [ ] Margin/spacing của layout lồng nhau không cộng dồn ngoài ý muốn.
|
||||
- [ ] Còn đúng ở cửa sổ nhỏ nhất **và** maximize.
|
||||
- [ ] Còn đúng ở scale 125% / 150% nếu bản vá chạm kích thước.
|
||||
* [ ] Đã kiểm tra xem widget có file/bản triển khai trùng tên ở thư mục còn lại hay không.
|
||||
|
||||
## D. Icon & vẽ tay
|
||||
* [ ] Nếu có nhiều file cùng chức năng, đã xác định rõ **file nào thực sự được import và chạy**.
|
||||
|
||||
- [ ] Icon lấy qua `ui/icons.py::icon`, không load file trực tiếp.
|
||||
- [ ] `paintEvent` đọc màu qua `current_palette()`, không đọc lại config.
|
||||
- [ ] Dùng `update()`, không `repaint()` trong vòng lặp.
|
||||
- [ ] `QPainter` có `end()`; nền được xoá đúng cách.
|
||||
---
|
||||
|
||||
## E. Vòng đời
|
||||
## B. Kiểm tra màu sắc và Theme
|
||||
|
||||
- [ ] Bản vá còn đúng khi đổi theme **trước** rồi mới mở màn dựng lười (P07).
|
||||
- [ ] `setProperty` để đổi style động có kèm `unpolish`/`polish`.
|
||||
- [ ] Không `connect()` lặp lại trong hàm được gọi nhiều lần.
|
||||
* [ ] Không thêm mã màu trực tiếp như `#rrggbb` hoặc tên màu như `"red"` bên ngoài thư mục `theme/`.
|
||||
|
||||
## F. Bằng chứng
|
||||
* [ ] Không thêm `setStyleSheet()` trực tiếp vào widget.
|
||||
Style phải được quản lý thông qua:
|
||||
|
||||
- [ ] Đã đối chiếu `docs/screens/<slug>-dark.png` và `<slug>-light.png`.
|
||||
- [ ] Ảnh trong `docs/screens/` cần cập nhật thì đã nêu.
|
||||
- [ ] Có test regression chạy headless, đỏ-trước-xanh-sau.
|
||||
```
|
||||
`objectName` → `theme/qss.py`
|
||||
```
|
||||
|
||||
* [ ] Nếu thêm token màu mới, token đó phải được khai báo cho **cả `DARK` và `LIGHT`**.
|
||||
|
||||
* [ ] Khi đặt chữ trên nền màu đặc, dùng `accent_solid`.
|
||||
Không dùng `accent` cho trường hợp này.
|
||||
|
||||
* [ ] Dùng đúng loại màu nền theo mục đích:
|
||||
|
||||
```
|
||||
- `bg` — nền chính.
|
||||
- `surface` — bề mặt thông thường.
|
||||
- `surface_raised` — bề mặt nổi.
|
||||
- `overlay` — lớp phủ.
|
||||
- `sunken` — khu vực chìm.
|
||||
```
|
||||
|
||||
* [ ] Contrast của chữ đạt tối thiểu **4.5:1** đối với:
|
||||
- Body text.
|
||||
- Chữ trên nút có nền đặc.
|
||||
- Cả Dark Mode và Light Mode.
|
||||
|
||||
* [ ] Không thêm:
|
||||
- Gradient.
|
||||
- Glow.
|
||||
|
||||
```
|
||||
Đây là các kiểu không phù hợp với design constraint hiện tại.
|
||||
```
|
||||
|
||||
* [ ] `Nav rail` vẫn **tối hơn khu vực nội dung**.
|
||||
Đây là thiết kế có chủ ý, không tự ý làm sáng lên.
|
||||
|
||||
* [ ] Không khôi phục các giá trị màu cũ theo VS Code nếu các giá trị hiện tại đã được điều chỉnh để đạt WCAG AA.
|
||||
|
||||
* [ ] Nếu thay đổi `_TEMPLATE`:
|
||||
đã đánh giá và ghi rõ **phạm vi ảnh hưởng trên toàn ứng dụng** vì `_TEMPLATE` có thể ảnh hưởng nhiều màn hình.
|
||||
|
||||
---
|
||||
|
||||
## C. Kiểm tra Layout và kích thước
|
||||
|
||||
* [ ] Không thêm mới:
|
||||
|
||||
```
|
||||
- `setFixedWidth()`
|
||||
- `setFixedHeight()`
|
||||
- `setFixedSize()`
|
||||
|
||||
để che hoặc né lỗi layout.
|
||||
```
|
||||
|
||||
* [ ] `stretch factor` và `size policy` được thiết lập rõ ràng khi cần.
|
||||
|
||||
* [ ] Nếu sử dụng `QScrollArea`, phải có:
|
||||
|
||||
```
|
||||
`setWidgetResizable(True)`
|
||||
```
|
||||
|
||||
* [ ] Kiểm tra margin và spacing của các layout lồng nhau.
|
||||
Không được để chúng cộng dồn khiến UI bị lệch hoặc quá rộng.
|
||||
|
||||
* [ ] UI vẫn hiển thị đúng ở:
|
||||
- Kích thước cửa sổ nhỏ nhất.
|
||||
- Cửa sổ maximize.
|
||||
|
||||
* [ ] Nếu bản vá liên quan đến kích thước, phải kiểm tra thêm ở:
|
||||
- Scale 125%.
|
||||
- Scale 150%.
|
||||
|
||||
---
|
||||
|
||||
## D. Kiểm tra Icon và Custom Painting
|
||||
|
||||
* [ ] Icon phải được lấy thông qua:
|
||||
|
||||
```
|
||||
`ui/icons.py::icon`
|
||||
|
||||
Không tự load file icon trực tiếp.
|
||||
```
|
||||
|
||||
* [ ] Trong `paintEvent()`, màu sắc phải lấy từ:
|
||||
|
||||
```
|
||||
`current_palette()`
|
||||
|
||||
Không đọc lại màu trực tiếp từ config.
|
||||
```
|
||||
|
||||
* [ ] Trong các vòng lặp hoặc thao tác cập nhật UI, dùng:
|
||||
|
||||
```
|
||||
`update()`
|
||||
|
||||
Không dùng `repaint()` nếu không thực sự cần thiết.
|
||||
```
|
||||
|
||||
* [ ] `QPainter` được kết thúc đúng cách bằng `end()` khi sử dụng thủ công.
|
||||
|
||||
* [ ] Nền của khu vực custom painting được xử lý/xóa đúng cách, không để lại hình ảnh hoặc pixel cũ.
|
||||
|
||||
---
|
||||
|
||||
## E. Kiểm tra vòng đời UI
|
||||
|
||||
* [ ] UI vẫn hoạt động đúng nếu người dùng:
|
||||
|
||||
```
|
||||
1. Đổi theme trước.
|
||||
2. Sau đó mới mở màn hình được tạo theo kiểu lazy.
|
||||
|
||||
Đặc biệt kiểm tra lỗi **P07**.
|
||||
```
|
||||
|
||||
* [ ] Nếu dùng `setProperty()` để thay đổi style động:
|
||||
phải gọi `unpolish()` và `polish()` khi cần để QSS được áp dụng lại.
|
||||
|
||||
* [ ] Không gọi `connect()` nhiều lần trong một hàm có thể được gọi nhiều lần.
|
||||
|
||||
* [ ] Không tạo signal/slot bị kết nối lặp, gây ra:
|
||||
- Event chạy nhiều lần.
|
||||
- UI cập nhật nhiều lần.
|
||||
- Memory leak hoặc hành vi bất thường.
|
||||
|
||||
---
|
||||
|
||||
## F. Kiểm tra bằng chứng
|
||||
|
||||
* [ ] Đã đối chiếu với screenshot trong:
|
||||
|
||||
```
|
||||
`docs/screens/<slug>-dark.png`
|
||||
|
||||
và
|
||||
|
||||
`docs/screens/<slug>-light.png`
|
||||
```
|
||||
|
||||
* [ ] Nếu bản vá làm thay đổi giao diện, đã xác định screenshot nào cần cập nhật.
|
||||
|
||||
* [ ] Nếu cần cập nhật screenshot trong `docs/screens/`, phải ghi rõ trong phạm vi thay đổi.
|
||||
|
||||
* [ ] Có regression test cho lỗi đã sửa.
|
||||
|
||||
* [ ] Regression test chạy được ở chế độ headless:
|
||||
|
||||
```
|
||||
`QT_QPA_PLATFORM=offscreen`
|
||||
```
|
||||
|
||||
* [ ] Regression test chứng minh được:
|
||||
|
||||
```
|
||||
**Trước khi sửa → FAIL**
|
||||
|
||||
**Sau khi sửa → PASS**
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kết luận
|
||||
|
||||
Chỉ đánh giá bản vá là **PASS** khi:
|
||||
|
||||
1. Sửa đúng file thực sự chạy.
|
||||
2. Không phá theme hoặc layout hiện có.
|
||||
3. Không dùng workaround để che lỗi.
|
||||
4. Không tạo regression.
|
||||
5. Có regression test phù hợp.
|
||||
6. Có đủ bằng chứng kiểm chứng.
|
||||
7. Các vấn đề liên quan đến security hoặc product decision đã được route đúng agent/người phụ trách.
|
||||
|
||||
+198
-34
@@ -1,48 +1,212 @@
|
||||
# Checklist review bản vá UX (flow)
|
||||
# Checklist review bản vá UX (Flow)
|
||||
|
||||
Dùng bởi `ux-flow-fixer` (bước 8) và `regression-reviewer`.
|
||||
Checklist này được sử dụng bởi:
|
||||
|
||||
## A. Bốn trạng thái
|
||||
* `ux-flow-fixer` — kiểm tra ở bước 8.
|
||||
* `regression-reviewer` — kiểm tra trong quá trình review bản vá.
|
||||
|
||||
Cho mỗi view có dữ liệu bất đồng bộ:
|
||||
Mục tiêu: đảm bảo người dùng luôn biết **hệ thống đang làm gì, chuyện gì xảy ra và cần làm gì tiếp theo**, đồng thời không bị mất dữ liệu.
|
||||
|
||||
- [ ] **Rỗng** — hiện thông điệp có nghĩa, nói được bước tiếp theo (không phải màn trắng).
|
||||
- [ ] **Đang tải** — có dấu hiệu chuyển động; nút bị vô hiệu hoá để chống bấm đúp.
|
||||
- [ ] **Lỗi** — nói *cái gì hỏng* và *làm gì tiếp*; có đường thử lại; không in nguyên exception.
|
||||
- [ ] **Thành công** — có xác nhận rõ; có undo nếu hành động khó đảo ngược.
|
||||
---
|
||||
|
||||
## B. An toàn dữ liệu
|
||||
## A. Kiểm tra 4 trạng thái chính
|
||||
|
||||
- [ ] Ô nhập dài (instruction, composer, node property, AI Edit) không mất nội dung khi
|
||||
chuyển tab / đóng dialog / đổi project.
|
||||
- [ ] Có dirty-state; `closeEvent` chặn khi còn thay đổi chưa lưu.
|
||||
- [ ] Hành động phá huỷ (xoá project/task, ghi đè file) có xác nhận.
|
||||
- [ ] Xác nhận nêu rõ **cái gì** sẽ mất, không phải "Bạn có chắc không?".
|
||||
- [ ] Nút phá huỷ **không** phải default button, **không** nhận Enter.
|
||||
Đối với mỗi màn hình có dữ liệu hoặc thao tác chạy bất đồng bộ, phải kiểm tra đủ 4 trạng thái:
|
||||
|
||||
## C. Phản hồi theo thời gian
|
||||
### 1. Trạng thái Rỗng (Empty)
|
||||
|
||||
- [ ] 100ms-1s: đổi con trỏ hoặc vô hiệu hoá nút.
|
||||
- [ ] 1s-10s: chỉ báo tiến trình rõ ràng.
|
||||
- [ ] \>10s: có tiến trình, **huỷ được**, không chặn phần còn lại của UI.
|
||||
- [ ] Việc nặng chạy ở service `application/`, không ở GUI thread.
|
||||
- [ ] Bấm hai lần không chạy hai lần (kiểm `connect()` trùng — P10).
|
||||
* [ ] Khi chưa có dữ liệu, màn hình phải hiển thị thông báo có ý nghĩa.
|
||||
|
||||
## D. Khám phá được
|
||||
* [ ] Thông báo phải cho người dùng biết **cần làm gì tiếp theo**.
|
||||
|
||||
- [ ] Mọi nút icon-only có tooltip (nav rail thu gọn, toolbar Co4E, top bar).
|
||||
- [ ] Nút bị vô hiệu hoá nói được **lý do** (mẫu đúng: `app.nav.needs_project`).
|
||||
- [ ] Chức năng chính không bị chôn sau menu chuột phải mà không có lối vào khác.
|
||||
- [ ] Thứ tự control khớp thứ tự người dùng thực hiện.
|
||||
* [ ] Không để màn hình trắng khiến người dùng không biết chuyện gì đang xảy ra.
|
||||
|
||||
## E. Nhất quán
|
||||
### 2. Trạng thái Đang tải (Loading)
|
||||
|
||||
- [ ] Cùng một hành động dùng cùng một từ trên mọi màn (không chỗ "Lưu" chỗ "Cập nhật").
|
||||
- [ ] Vị trí nút chính/phụ giống các dialog khác.
|
||||
- [ ] Chuỗi mới đi qua `tr()` với đủ `en`/`ja`/`vi`.
|
||||
* [ ] Có dấu hiệu rõ ràng cho biết hệ thống đang xử lý, ví dụ loading indicator.
|
||||
|
||||
## F. Phạm vi
|
||||
* [ ] Các nút có thể gây chạy lại cùng một thao tác được vô hiệu hóa trong lúc đang xử lý.
|
||||
|
||||
- [ ] Bản vá chọn mức can thiệp thấp nhất (thêm thông tin trước, đổi luồng sau).
|
||||
- [ ] Thay đổi luồng được đánh dấu là **đề xuất** cần Cowork Team duyệt.
|
||||
- [ ] Có test regression cho signal/state, chạy headless.
|
||||
* [ ] Bấm liên tục hoặc bấm đúp không được tạo ra nhiều request/thao tác giống nhau.
|
||||
|
||||
### 3. Trạng thái Lỗi (Error)
|
||||
|
||||
* [ ] Thông báo lỗi phải cho biết:
|
||||
- **Chuyện gì đã xảy ra.**
|
||||
- **Người dùng cần làm gì tiếp theo.**
|
||||
|
||||
* [ ] Có cách để người dùng **thử lại** khi phù hợp.
|
||||
|
||||
* [ ] Không hiển thị nguyên exception, stack trace hoặc thông tin kỹ thuật khó hiểu cho người dùng.
|
||||
|
||||
### 4. Trạng thái Thành công (Success)
|
||||
|
||||
* [ ] Sau khi thao tác thành công, phải có thông báo/xác nhận rõ ràng.
|
||||
|
||||
* [ ] Với thao tác khó hoặc không thể hoàn tác, phải có cơ chế **Undo** nếu phù hợp.
|
||||
|
||||
---
|
||||
|
||||
## B. Kiểm tra an toàn dữ liệu
|
||||
|
||||
* [ ] Các ô nhập nội dung dài, ví dụ:
|
||||
- Instruction
|
||||
- Composer
|
||||
- Node property
|
||||
- AI Edit
|
||||
|
||||
```
|
||||
không được mất nội dung khi:
|
||||
|
||||
- Chuyển tab.
|
||||
- Đóng/mở dialog.
|
||||
- Đổi project.
|
||||
```
|
||||
|
||||
* [ ] Có cơ chế xác định **dirty-state** khi dữ liệu đã thay đổi nhưng chưa lưu.
|
||||
|
||||
* [ ] `closeEvent` phải cảnh báo hoặc chặn việc đóng màn hình khi vẫn còn thay đổi chưa lưu.
|
||||
|
||||
* [ ] Các thao tác có thể làm mất dữ liệu phải có bước xác nhận, ví dụ:
|
||||
- Xóa project.
|
||||
- Xóa task.
|
||||
- Ghi đè file.
|
||||
|
||||
* [ ] Nội dung xác nhận phải nói rõ **dữ liệu nào sẽ bị mất**.
|
||||
|
||||
```
|
||||
Không dùng thông báo quá chung chung như:
|
||||
|
||||
`"Bạn có chắc không?"`
|
||||
```
|
||||
|
||||
* [ ] Nút thực hiện thao tác phá hủy dữ liệu:
|
||||
- Không được đặt làm **default button**.
|
||||
- Không được thực hiện khi người dùng chỉ nhấn `Enter`.
|
||||
|
||||
---
|
||||
|
||||
## C. Kiểm tra phản hồi theo thời gian
|
||||
|
||||
Phản hồi của UI phải phù hợp với thời gian xử lý:
|
||||
|
||||
* [ ] **100ms – 1s:**
|
||||
Có thể thay đổi con trỏ hoặc vô hiệu hóa nút để người dùng biết thao tác đã được nhận.
|
||||
|
||||
* [ ] **1s – 10s:**
|
||||
Hiển thị chỉ báo tiến trình rõ ràng.
|
||||
|
||||
* [ ] **Trên 10s:**
|
||||
- Có chỉ báo tiến trình.
|
||||
- Người dùng có thể **hủy thao tác** khi phù hợp.
|
||||
- Không khóa toàn bộ UI nếu không cần thiết.
|
||||
|
||||
* [ ] Các tác vụ xử lý nặng không được chạy trực tiếp trên GUI thread.
|
||||
Phải chuyển phần xử lý nặng sang service trong `application/`.
|
||||
|
||||
* [ ] Một thao tác không được chạy hai lần khi người dùng bấm liên tục hoặc bấm đúp.
|
||||
|
||||
* [ ] Kiểm tra các `connect()` có bị đăng ký nhiều lần hay không, đặc biệt với lỗi **P10**.
|
||||
|
||||
---
|
||||
|
||||
## D. Kiểm tra khả năng khám phá chức năng
|
||||
|
||||
Người dùng phải dễ dàng biết **nút này làm gì và tìm chức năng ở đâu**.
|
||||
|
||||
* [ ] Tất cả các nút chỉ có icon (`icon-only`) đều có tooltip.
|
||||
|
||||
```
|
||||
Đặc biệt kiểm tra:
|
||||
- Nav rail khi thu gọn.
|
||||
- Toolbar Co4E.
|
||||
- Top bar.
|
||||
```
|
||||
|
||||
* [ ] Nút đang bị vô hiệu hóa phải cho người dùng biết **tại sao không thể bấm**.
|
||||
|
||||
```
|
||||
Ví dụ sử dụng key:
|
||||
|
||||
`app.nav.needs_project`
|
||||
```
|
||||
|
||||
* [ ] Chức năng chính không được chỉ nằm trong menu chuột phải nếu không có cách truy cập khác.
|
||||
|
||||
* [ ] Thứ tự các control trên màn hình phải phù hợp với **thứ tự người dùng thực hiện công việc**.
|
||||
|
||||
---
|
||||
|
||||
## E. Kiểm tra tính nhất quán
|
||||
|
||||
* [ ] Một hành động phải sử dụng **cùng một thuật ngữ** trên toàn bộ ứng dụng.
|
||||
|
||||
```
|
||||
Ví dụ:
|
||||
|
||||
Nếu dùng `"Lưu"` ở một màn hình thì không nên dùng `"Cập nhật"` ở màn hình khác cho cùng một hành động.
|
||||
```
|
||||
|
||||
* [ ] Vị trí của nút chính và nút phụ phải nhất quán với các dialog khác.
|
||||
|
||||
* [ ] Chuỗi text mới phải sử dụng `tr()`.
|
||||
|
||||
* [ ] Chuỗi mới phải có bản dịch đầy đủ cho:
|
||||
|
||||
```
|
||||
- `en`
|
||||
- `ja`
|
||||
- `vi`
|
||||
```
|
||||
|
||||
* [ ] Không hardcode text mới trực tiếp trong UI code nếu text đó cần hỗ trợ đa ngôn ngữ.
|
||||
|
||||
---
|
||||
|
||||
## F. Kiểm tra phạm vi thay đổi
|
||||
|
||||
* [ ] Bản vá sử dụng **cách can thiệp nhỏ nhất có thể**.
|
||||
|
||||
```
|
||||
Ưu tiên:
|
||||
|
||||
**Bổ sung thông tin → cải thiện feedback → điều chỉnh control → thay đổi flow**
|
||||
|
||||
Không thay đổi cả luồng khi chỉ cần bổ sung thông tin.
|
||||
```
|
||||
|
||||
* [ ] Nếu cần thay đổi flow của người dùng, thay đổi đó phải được ghi rõ là:
|
||||
|
||||
```
|
||||
**ĐỀ XUẤT**
|
||||
```
|
||||
|
||||
* [ ] Agent không tự quyết định thay đổi product/UX quan trọng.
|
||||
|
||||
* [ ] Các thay đổi flow cần được **Cowork Team xem xét và phê duyệt**.
|
||||
|
||||
* [ ] Có regression test kiểm tra:
|
||||
- Signal.
|
||||
- State.
|
||||
- Chuyển trạng thái.
|
||||
- Hành vi của user flow liên quan.
|
||||
|
||||
* [ ] Regression test chạy được ở chế độ headless:
|
||||
|
||||
```
|
||||
`QT_QPA_PLATFORM=offscreen`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kết luận
|
||||
|
||||
Bản vá UX chỉ nên được đánh giá là đạt khi:
|
||||
|
||||
1. Người dùng biết rõ trạng thái hiện tại của hệ thống.
|
||||
2. Không có nguy cơ mất dữ liệu ngoài ý muốn.
|
||||
3. UI phản hồi phù hợp với thời gian xử lý.
|
||||
4. Chức năng dễ tìm và dễ hiểu.
|
||||
5. Cách gọi tên và cách bố trí control nhất quán.
|
||||
6. Thay đổi flow lớn đã được đánh dấu để Cowork Team phê duyệt.
|
||||
7. Có regression test chứng minh flow vẫn hoạt động đúng.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
description: Điều phối fix bug UI/UX — chấm tier T0/T1/T2/T3 rồi chạy đúng số agent cần thiết
|
||||
argument-hint: <phản ánh của người dùng, dán nguyên văn>
|
||||
---
|
||||
|
||||
Bạn đang chạy với vai **`fix-dispatcher`** — agent hub điều phối của bộ agent trong `agent/`.
|
||||
|
||||
Nạp theo đúng thứ tự rồi làm theo:
|
||||
|
||||
1. @agent/system/guardrail.md
|
||||
2. @agent/system/security.md
|
||||
3. @agent/system/response_policy.md
|
||||
4. @agent/roles/0_fix_dispatcher.md
|
||||
5. @agent/output/dispatch_plan.md
|
||||
|
||||
Phản ánh cần xử lý:
|
||||
|
||||
$ARGUMENTS
|
||||
|
||||
Trình tự bắt buộc:
|
||||
|
||||
- Tách defect (Bước 1) → xét override bảo mật (Bước 2) → chấm tier (Bước 3).
|
||||
- Trần chấm điểm: **≤ 5 lệnh đọc/grep, 0 subagent**. Hết mà chưa chấm được → T2.
|
||||
- In `dispatch_plan` (≤ 30 dòng phần người đọc) **trước** khi chạy bất kỳ agent nào.
|
||||
- Rồi chạy đúng lane ở bảng Bước 4:
|
||||
- **T0** → tự sửa, sau đó chạy đủ 4 cổng máy ở §4.1 và dán output thật.
|
||||
- **T1** → gọi `fix-implementer`, rồi tự review bằng @agent/checklist/ui_review.md.
|
||||
- **T2** → specialist → `fix-implementer` → `regression-reviewer`.
|
||||
- **T3** → `ui-bug-triage` → specialist → `fix-implementer` → `regression-reviewer`.
|
||||
- **T3-SEC** → `security-defect-fixer`, dừng chờ Cowork Team trả 4 câu chính sách.
|
||||
- Các `defect_id` độc lập gọi song song trong **một** message. Các bước trong cùng một
|
||||
`defect_id` chạy tuần tự.
|
||||
- Escalate theo Bước 5. Tier chỉ đi lên. Không tự merge (`guardrail.md` G9).
|
||||
+354
-52
@@ -1,71 +1,373 @@
|
||||
# i18n — luật chuỗi hiển thị
|
||||
# i18n — Quy tắc xử lý chuỗi hiển thị
|
||||
|
||||
Nguồn: docstring `i18n/__init__.py`.
|
||||
**Nguồn:** docstring `i18n/__init__.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. Ba ngôn ngữ, mặc định tiếng Việt
|
||||
## 1. Ngôn ngữ được hỗ trợ
|
||||
|
||||
Cowork Local hỗ trợ 3 ngôn ngữ:
|
||||
|
||||
```python
|
||||
LANGUAGES = {"en": "English", "ja": "日本語", "vi": "Tiếng Việt"}
|
||||
LANGUAGE_SHORT = {"en": "EN", "ja": "JP", "vi": "VN"} # switcher gọn ở top bar
|
||||
LANGUAGES = {
|
||||
"en": "English",
|
||||
"ja": "日本語",
|
||||
"vi": "Tiếng Việt",
|
||||
}
|
||||
|
||||
LANGUAGE_SHORT = {
|
||||
"en": "EN",
|
||||
"ja": "JP",
|
||||
"vi": "VN",
|
||||
}
|
||||
|
||||
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.
|
||||
Ngôn ngữ mặc định là **Tiếng Việt (`vi`)**.
|
||||
|
||||
`.format(**kwargs)` được áp dụng khi có placeholder: `tr("composer.attachments", n=3)`.
|
||||
### Hàm `tr()`
|
||||
|
||||
## 2. Widget nào phải đăng ký callback
|
||||
Sử dụng:
|
||||
|
||||
| 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
|
||||
```python
|
||||
tr(key, **kwargs)
|
||||
```
|
||||
|
||||
Mỗi file export dict `key -> {"en":..., "ja":..., "vi":...}`, được `i18n/__init__.py`
|
||||
import và gộp lại. Thêm key mới:
|
||||
để lấy chuỗi hiển thị theo ngôn ngữ hiện tạ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`.
|
||||
Thứ tự fallback:
|
||||
|
||||
## 4. Rủi ro riêng của tiếng Nhật và tiếng Việt
|
||||
```text
|
||||
Ngôn ngữ hiện tại → English (en) → chính key
|
||||
```
|
||||
|
||||
| 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()` |
|
||||
Ví dụ, nếu đang dùng tiếng Nhật nhưng key `workspace.tab_folder` chưa có bản dịch tiếng Nhật:
|
||||
|
||||
## 5. Checklist sửa bug i18n
|
||||
```text
|
||||
JA → EN → workspace.tab_folder
|
||||
```
|
||||
|
||||
- [ ] 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ữ?
|
||||
Ứng dụng **không được crash** chỉ vì thiếu bản dịch.
|
||||
|
||||
Nếu UI hiển thị một chuỗi dạng:
|
||||
|
||||
```text
|
||||
workspace.tab_folder
|
||||
```
|
||||
|
||||
thì đây là dấu hiệu cho thấy **đang thiếu translation key**.
|
||||
|
||||
### Placeholder
|
||||
|
||||
Nếu chuỗi có placeholder, truyền giá trị thông qua `kwargs`:
|
||||
|
||||
```python
|
||||
tr("composer.attachments", n=3)
|
||||
```
|
||||
|
||||
Việc `.format(**kwargs)` được thực hiện sau khi lấy chuỗi dịch.
|
||||
|
||||
---
|
||||
|
||||
## 2. Widget nào phải cập nhật khi đổi ngôn ngữ?
|
||||
|
||||
Có 2 loại widget:
|
||||
|
||||
| Loại widget | Cách xử lý |
|
||||
| ------------------- | ----------------------------------------------------- |
|
||||
| **Widget sống lâu** | `bind_*` cho chuỗi tĩnh; `on_language_changed(cb)` cho phần còn lại |
|
||||
| **Widget tạm thời** | Không cần đăng ký callback; gọi `tr()` khi tạo widget |
|
||||
|
||||
### 2.0. `bind_*` — cách mặc định cho chuỗi tĩnh
|
||||
|
||||
`w.setToolTip(tr("k"))` chỉ đúng ở đúng thời điểm chạy dòng đó. `bind_*` gộp "gán ngay"
|
||||
và "gán lại sau mỗi lần đổi ngôn ngữ" vào một lời gọi, dùng `weakref` nên không giữ widget
|
||||
sống thêm và tự dọn khi widget bị xoá:
|
||||
|
||||
```python
|
||||
from ...i18n import bind_dynamic, bind_items, bind_placeholder, bind_text, bind_tip
|
||||
|
||||
self.save_btn = bind_text(QPushButton(), "co4e.save") # thay QPushButton(tr(...))
|
||||
bind_tip(self.save_btn, "co4e.tt_save") # thay .setToolTip(tr(...))
|
||||
bind_placeholder(self.chat_input, "co4e.chat_placeholder")
|
||||
bind_items(self.perm_combo, [f"co4e.perm.{p}" for p in PERMISSION_PRESETS])
|
||||
form.addRow(bind_text(QLabel(), "co4e.f_label"), self.label_edit) # KHÔNG addRow(tr(...))
|
||||
```
|
||||
|
||||
Ba luật:
|
||||
|
||||
1. **Chuỗi tĩnh → `bind_*`.** Đổi tại chỗ, **không thêm dòng** — quan trọng với file đã
|
||||
sát trần Gate S hoặc đang bị bánh cóc `LEGACY_ALLOWANCE` chốt (`quality_gates.md` §4).
|
||||
2. **Chữ phụ thuộc trạng thái → `bind_dynamic(w, setter, fn)`**, với `fn` đọc trạng thái:
|
||||
nút Chạy ⇄ Dừng, tooltip Thu gọn ⇄ Mở rộng, nhãn có số đếm. Các nhánh xử lý trạng thái
|
||||
**vẫn** gọi setter trực tiếp như cũ để phản hồi ngay khi bấm; `bind_dynamic` chỉ lo lúc
|
||||
đổi ngôn ngữ. Bind cứng một nhãn động sẽ **xoá** trạng thái khi người dùng đổi ngôn ngữ
|
||||
giữa lúc đang chạy.
|
||||
3. **Chữ là DỮ LIỆU thì không bind.** Tên agent, tên project, tên nhà cung cấp trong
|
||||
`config.PROVIDER_LABELS` — dịch danh tính là sai.
|
||||
|
||||
`QFormLayout.addRow(tr(...), w)` và `_add_section(outer, tr(...))` là hai bẫy hay gặp:
|
||||
chúng tự dựng `QLabel` bên trong, không giữ tham chiếu nào để áp lại. Truyền
|
||||
`bind_text(QLabel(), key)` hoặc truyền **khoá** thay vì chuỗi đã dịch.
|
||||
|
||||
### 2.1. Widget sống lâu
|
||||
|
||||
Ví dụ:
|
||||
|
||||
* Chrome của cửa sổ chính.
|
||||
* Tab.
|
||||
* Sidebar.
|
||||
* Composer.
|
||||
|
||||
Các widget này vẫn tồn tại khi người dùng đổi ngôn ngữ.
|
||||
|
||||
Vì vậy phải:
|
||||
|
||||
1. Đăng ký `on_language_changed(cb)`.
|
||||
2. Trong callback, gọi lại `tr()` cho các text của chính widget.
|
||||
3. Callback phải chạy:
|
||||
|
||||
* Một lần ngay khi đăng ký.
|
||||
* Mỗi lần người dùng đổi ngôn ngữ.
|
||||
|
||||
Tên callback được sử dụng trong repo:
|
||||
|
||||
```text
|
||||
_retranslate()
|
||||
_apply_i18n()
|
||||
```
|
||||
|
||||
Có thể tham khảo implementation chuẩn từ:
|
||||
|
||||
```text
|
||||
ui/workspace_tab.py:484
|
||||
```
|
||||
|
||||
### 2.2. Widget tạm thời
|
||||
|
||||
Ví dụ:
|
||||
|
||||
* Settings dialog.
|
||||
* Skills dialog.
|
||||
* Flow dialog.
|
||||
* Permission dialog.
|
||||
|
||||
Các dialog này được tạo lại từ đầu mỗi lần mở.
|
||||
|
||||
Vì vậy chỉ cần gọi `tr()` khi construct widget.
|
||||
|
||||
**Không cần đăng ký `on_language_changed()`**.
|
||||
|
||||
### Bug thường gặp
|
||||
|
||||
Triệu chứng:
|
||||
|
||||
> Đổi ngôn ngữ nhưng một label/nút vẫn giữ ngôn ngữ cũ.
|
||||
|
||||
Nguyên nhân thường là:
|
||||
|
||||
* Widget sống lâu nhưng chưa đăng ký `on_language_changed()`.
|
||||
* Callback có đăng ký nhưng quên cập nhật label đó.
|
||||
|
||||
**Cách sửa đúng:**
|
||||
|
||||
`bind_*` tại chính dòng đang gán (mục 2.0), hoặc — nếu chữ phụ thuộc trạng thái/dữ liệu —
|
||||
sửa trong `_retranslate()` / `_apply_i18n()` của chính widget.
|
||||
|
||||
**Không** giải quyết bằng cách gọi `tr()` ở một nơi khác chỉ để ép label thay đổi.
|
||||
|
||||
### Cách TÌM ra hết các chỗ bị lỗi
|
||||
|
||||
Đừng grep chuỗi tiếng Việt trong source: lượt audit tháng 9/2026 grep ra 962 dòng mà
|
||||
**không dòng nào** là lỗi thật (toàn docstring), trong khi 84 lỗi thật lại không xuất hiện
|
||||
— vì chúng đi qua `tr()` đúng cách, chỉ thiếu người áp lại.
|
||||
|
||||
Phép đo đúng nằm ở `tests/ui/test_i18n_khong_con_chu_cu.py`: dựng `MainWindow` thật, thay
|
||||
`tr()` bằng chuỗi **mốc**, gọi `set_language()`, rồi tìm chỗ **không** mang mốc. Hai chi
|
||||
tiết mà bản kiểm ngây thơ sẽ sai:
|
||||
|
||||
* `from ...i18n import tr` copy tham chiếu vào namespace từng module → phải thay `tr` ở
|
||||
**mọi** module đã import, không chỉ `i18n.tr`;
|
||||
* lưới vẽ lại bằng `deleteLater()` để lại widget cũ còn sống → không
|
||||
`sendPostedEvents(DeferredDelete)` thì báo oan hàng chục widget bóng ma (lượt audit đầu
|
||||
báo 84 lỗi, trong đó 65 là bóng ma và widget bị `id()` cấp lại làm cắt vòng quét).
|
||||
|
||||
Chạy: `QT_QPA_PLATFORM=offscreen pytest tests/ui/test_i18n_khong_con_chu_cu.py -q`
|
||||
|
||||
---
|
||||
|
||||
## 3. Tổ chức file translation
|
||||
|
||||
Thư mục `i18n/` được chia theo **màn hình/chức năng**, không gom tất cả translation vào một file lớn.
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text
|
||||
i18n/
|
||||
├── login_dialog.py
|
||||
├── sidebar.py
|
||||
├── composer.py
|
||||
├── cowork_tab.py
|
||||
├── settings_dialog.py
|
||||
├── skills_dialog.py
|
||||
├── libreoffice_view.py
|
||||
├── agents_admin_tab.py
|
||||
├── monitoring_overview.py
|
||||
└── hint.py
|
||||
```
|
||||
|
||||
Mỗi file export một dictionary có dạng:
|
||||
|
||||
```text
|
||||
key → {
|
||||
"en": "...",
|
||||
"ja": "...",
|
||||
"vi": "..."
|
||||
}
|
||||
```
|
||||
|
||||
`i18n/__init__.py` sẽ import và gộp các dictionary này.
|
||||
|
||||
### Khi thêm key mới
|
||||
|
||||
Thực hiện theo 3 bước:
|
||||
|
||||
#### Bước 1 — Chọn đúng file
|
||||
|
||||
Đưa key vào file tương ứng với màn hình/chức năng.
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text
|
||||
workspace.* → file liên quan đến workspace
|
||||
composer.* → composer.py
|
||||
settings.* → settings_dialog.py
|
||||
```
|
||||
|
||||
**Không** đưa key vào `login_dialog.py` chỉ vì file đó đang có nhiều key nhất.
|
||||
|
||||
#### Bước 2 — Điền đủ 3 ngôn ngữ
|
||||
|
||||
Mỗi key mới phải có:
|
||||
|
||||
```text
|
||||
en
|
||||
ja
|
||||
vi
|
||||
```
|
||||
|
||||
Thiếu `ja` là lỗi đặc biệt cần chú ý vì có thể chỉ được phát hiện khi khách hàng Nhật sử dụng.
|
||||
|
||||
#### Bước 3 — Đặt tên key nhất quán
|
||||
|
||||
Format khuyến nghị:
|
||||
|
||||
```text
|
||||
<màn hình>.<thành phần>
|
||||
```
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text
|
||||
workspace.tab_folder
|
||||
app.nav.recents
|
||||
```
|
||||
|
||||
Tên key phải mô tả rõ nó được dùng ở đâu và cho thành phần nào.
|
||||
|
||||
---
|
||||
|
||||
## 4. Các rủi ro thường gặp với tiếng Nhật và tiếng Việt
|
||||
|
||||
| Vấn đề | Triệu chứng | Cách xử lý |
|
||||
| ---------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| Độ dài chuỗi khác nhau | EN vừa nút nhưng VI bị tràn hoặc JA bị `...` | Không đặt width cố định dựa trên tiếng Anh. Dùng `sizeHint()`, `minimumWidth` hoặc cho phép wrap |
|
||||
| Dấu tiếng Việt bị cắt | Các chữ như `Ắ`, `ộ` bị mất dấu | Không dùng `setFixedHeight()` cho label. Để layout tự tính chiều cao |
|
||||
| Thiếu font/glyph tiếng Nhật | Xuất hiện `□□□` | Kiểm tra `_FONT` trong `theme/palettes.py` và khai báo font fallback |
|
||||
| Sắp xếp chuỗi | Project có dấu được sắp xếp không đúng | Dùng locale-aware sorting, không dùng `sorted()` một cách máy móc |
|
||||
| Số ký tự không phản ánh chiều rộng | Text bị elide sai, đặc biệt với tiếng Nhật | Dùng `QFontMetrics.horizontalAdvance()`, không dùng `len()` để đo chiều rộng |
|
||||
|
||||
### Đặc biệt lưu ý về độ dài text
|
||||
|
||||
Không được giả định:
|
||||
|
||||
```text
|
||||
số ký tự = chiều rộng hiển thị
|
||||
```
|
||||
|
||||
Ví dụ hai chuỗi có cùng số ký tự nhưng có thể có chiều rộng hiển thị khác nhau.
|
||||
|
||||
Khi cần đo text trên UI, dùng:
|
||||
|
||||
```python
|
||||
QFontMetrics.horizontalAdvance(...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Checklist khi sửa lỗi i18n
|
||||
|
||||
Trước khi hoàn thành bản vá i18n, phải kiểm tra:
|
||||
|
||||
* [ ] Key mới có đủ **`en` / `ja` / `vi`**?
|
||||
|
||||
* [ ] Đã chuyển qua cả 3 ngôn ngữ **ngay trong lúc app đang chạy** chưa?
|
||||
|
||||
```
|
||||
Không chỉ restart app rồi kiểm tra.
|
||||
```
|
||||
|
||||
* [ ] `ja` có **khác** `en` không? Bằng nhau nghĩa là chưa dịch — trừ tên thương hiệu /
|
||||
ký hiệu, và khi đó phải khai vào `KHOA_KHONG_CAN_DICH` kèm lý do.
|
||||
|
||||
* [ ] Chuỗi tĩnh đã dùng `bind_text` / `bind_tip` / `bind_placeholder` / `bind_items`
|
||||
thay cho `setX(tr(...))` một lần?
|
||||
|
||||
* [ ] Chữ phụ thuộc trạng thái đã dùng `bind_dynamic` (không bind cứng, kẻo mất trạng thái)?
|
||||
|
||||
* [ ] Nếu widget sống lâu và còn phần không bind được, đã đăng ký:
|
||||
|
||||
```
|
||||
`on_language_changed(...)`
|
||||
```
|
||||
|
||||
* [ ] Callback `_retranslate()` hoặc `_apply_i18n()` đã cập nhật **tất cả text liên quan**?
|
||||
|
||||
* [ ] Đã chạy `pytest tests/ui/test_i18n_khong_con_chu_cu.py -q` và nó **xanh**?
|
||||
|
||||
* [ ] Không còn chuỗi hardcode mới trong bản vá?
|
||||
|
||||
* [ ] Layout vẫn đúng với **chuỗi dài nhất** trong 3 ngôn ngữ?
|
||||
|
||||
* [ ] Không dùng `len()` để tính chiều rộng text?
|
||||
|
||||
* [ ] Nếu có thay đổi UI, đã kiểm tra cả Dark Mode và Light Mode?
|
||||
|
||||
---
|
||||
|
||||
## 6. Nguyên tắc quan trọng
|
||||
|
||||
Khi sửa lỗi i18n, **không sửa triệu chứng ở nơi khác**.
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text
|
||||
Đổi ngôn ngữ
|
||||
↓
|
||||
Label X không thay đổi
|
||||
↓
|
||||
Kiểm tra widget X
|
||||
↓
|
||||
Widget sống lâu?
|
||||
↓
|
||||
Có on_language_changed()?
|
||||
↓
|
||||
_retranslate() có cập nhật Label X?
|
||||
```
|
||||
|
||||
Nếu thiếu callback hoặc callback bỏ sót label, hãy sửa **đúng callback của widget đó**.
|
||||
|
||||
Không thêm các lệnh `tr()` rải rác ở nơi khác chỉ để làm cho UI thay đổi.
|
||||
|
||||
Mục tiêu là đảm bảo cơ chế i18n hoạt động đúng và nhất quán cho toàn bộ ứng dụng.
|
||||
|
||||
+443
-58
@@ -1,95 +1,480 @@
|
||||
# Screen Map — dịch lời người dùng thành file:line
|
||||
# Screen Map — Tra mô tả của người dùng về đúng 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.
|
||||
Người dùng thường mô tả lỗi bằng ngôn ngữ tự nhiên, ví dụ:
|
||||
|
||||
> "Cái bảng bên phải của màn thống kê bị lệch."
|
||||
|
||||
Agent phải dùng file này để chuyển mô tả đó thành:
|
||||
|
||||
```text
|
||||
Màn hình → Tab/View → Widget → File → Line → Control
|
||||
```
|
||||
|
||||
Mục tiêu là tìm được **đúng widget và đúng vị trí code**, thay vì đoán file dựa trên tên.
|
||||
|
||||
---
|
||||
|
||||
## 1. Nav rail — bốn màn chính
|
||||
## 1. Bốn màn hình chính trong Nav Rail
|
||||
|
||||
Định nghĩa tại `presentation/shell/main_window.py:151` (`_nav_defs`), thứ tự = page index:
|
||||
Các màn hình chính được định nghĩa tại:
|
||||
|
||||
| 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` |
|
||||
```text
|
||||
presentation/shell/main_window.py:151
|
||||
```
|
||||
|
||||
App mở lên là ở **Workspace ▸ Project**.
|
||||
Danh sách nằm trong `_nav_defs`.
|
||||
|
||||
## 2. Sub-tab của Workspace
|
||||
**Thứ tự trong bảng chính là page index.**
|
||||
|
||||
`ui/workspace_tab.py:214-245`:
|
||||
| Row | i18n key | Icon | Cách tạo | Widget |
|
||||
| --: | -------------------- | ------------ | --------------- | --------------------------------------------------------------- |
|
||||
| 0 | `app.tab.dashboard` | `dashboard` | Lazy | `presentation/dashboard/dashboard_tab.py::DashboardTab` |
|
||||
| 1 | `app.tab.schedule` | `schedule` | Lazy | `presentation/scheduling/schedule_task_tab.py::ScheduleTaskTab` |
|
||||
| 2 | `app.tab.workspace` | `workspaces` | Ngay khi mở app | `ui/workspace_tab.py::WorkspaceTab` |
|
||||
| 3 | `app.tab.monitoring` | `monitoring` | Lazy | `ui/monitoring_tab.py::MonitoringTab` |
|
||||
|
||||
| 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` |
|
||||
### Màn hình mặc định
|
||||
|
||||
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.
|
||||
Khi mở app, người dùng bắt đầu tại:
|
||||
|
||||
## 3. Thành phần luôn nổi trên mọi màn
|
||||
```text
|
||||
Workspace → Project
|
||||
```
|
||||
|
||||
| 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" |
|
||||
### Lưu ý về Lazy
|
||||
|
||||
## 4. Dialog
|
||||
`Dashboard`, `Schedule` và `Monitoring` được tạo **lazy** — chỉ được dựng khi người dùng mở màn hình.
|
||||
|
||||
`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`.
|
||||
Vì vậy, khi điều tra lỗi liên quan đến các màn hình này, phải kiểm tra cả **thời điểm widget được tạo** và **vòng đời của widget**.
|
||||
|
||||
## 5. 🔎 Hai file tra cứu bắt buộc dùng
|
||||
---
|
||||
|
||||
### `docs/screens/manifest.json`
|
||||
## 2. Các tab bên trong Workspace
|
||||
|
||||
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`.
|
||||
Các tab được định nghĩa trong:
|
||||
|
||||
```text
|
||||
ui/workspace_tab.py:214-245
|
||||
```
|
||||
|
||||
| Tab | i18n key | Widget/File |
|
||||
| -------- | ------------------------ | -------------------------------------------------- |
|
||||
| Project | `workspace.tab_project` | `_build_project_tab()` trong `ui/workspace_tab.py` |
|
||||
| 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 có cấu trúc khác
|
||||
|
||||
Monitoring có **tab strip riêng**, gồm 8 sub-view:
|
||||
|
||||
1. Tổng quan.
|
||||
2. Trạng thái Agent.
|
||||
3. Công cụ.
|
||||
4. Nhật ký hành động.
|
||||
5. Lịch sử gọi MCP.
|
||||
6. Sự kiện bảo mật.
|
||||
7. Agents Admin.
|
||||
8. Icon.
|
||||
|
||||
**Workspace là màn hình duy nhất không hiển thị tab strip theo cách này.**
|
||||
|
||||
Nếu người dùng nói:
|
||||
|
||||
> "Tab trạng thái agent trong màn Monitoring"
|
||||
|
||||
thì không được nhầm nó với một tab của Workspace.
|
||||
|
||||
---
|
||||
|
||||
## 3. Các thành phần luôn xuất hiện trên mọi màn hình
|
||||
|
||||
Một số thành phần nằm ngoài nội dung của từng màn hình.
|
||||
|
||||
| Thành phần | File | Cách người dùng thường mô tả |
|
||||
| ------------------------------- | -------------------------------- | -------------------------------------------------- |
|
||||
| Nav rail bên 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", "Đổi ngôn ngữ không đổi" |
|
||||
| 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 góc dưới phải | `ui/help_agent_widget.py` | "Con robot che nút gửi" |
|
||||
| Status bar phía dưới | `main_window.statusBar()` | "Dòng chữ dưới đáy không đổi" |
|
||||
|
||||
### Quy tắc
|
||||
|
||||
Nếu người dùng mô tả một thành phần thuộc nhóm trên, **không cần tìm sub-tab trước**.
|
||||
|
||||
Hãy kiểm tra trực tiếp file tương ứng.
|
||||
|
||||
---
|
||||
|
||||
## 4. Các Dialog
|
||||
|
||||
Các dialog chính nằm trong `ui/`:
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
Ví dụ:
|
||||
|
||||
> "Khi mở Permission thì nút Allow bị..."
|
||||
|
||||
→ kiểm tra trước:
|
||||
|
||||
```text
|
||||
ui/permission_dialog.py
|
||||
```
|
||||
|
||||
Không tự động tìm trong `presentation/` chỉ vì lỗi xảy ra trên UI.
|
||||
|
||||
---
|
||||
|
||||
# 5. Hai file tra cứu bắt buộc
|
||||
|
||||
Khi cần chuyển mô tả của người dùng thành `file:line`, phải ưu tiên sử dụng:
|
||||
|
||||
```text
|
||||
docs/screens/manifest.json
|
||||
docs/screens/controls.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5.1. `docs/screens/manifest.json`
|
||||
|
||||
File này chứa thông tin về các màn hình đã được chụp screenshot.
|
||||
|
||||
Mỗi màn hình có các thông tin chính:
|
||||
|
||||
```text
|
||||
slug
|
||||
title
|
||||
theme
|
||||
note
|
||||
file
|
||||
nav
|
||||
```
|
||||
|
||||
Trong đó:
|
||||
|
||||
* `slug` — tên định danh của màn hình.
|
||||
* `title` — tên hiển thị.
|
||||
* `theme` — Dark hoặc Light.
|
||||
* `note` — **vị trí code dựng màn hình (`file.py:line`)**.
|
||||
* `file` — đường dẫn đến screenshot.
|
||||
* `nav` — màn hình thuộc nav nào.
|
||||
|
||||
### Ví dụ
|
||||
|
||||
Người dùng nói:
|
||||
|
||||
> "Màn Kanban lịch trình bị lỗi."
|
||||
|
||||
Có thể tìm màn hình liên quan bằng:
|
||||
|
||||
```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.
|
||||
Sau đó lấy `note` để biết:
|
||||
|
||||
### `docs/screens/controls.json`
|
||||
```text
|
||||
file.py:line
|
||||
```
|
||||
|
||||
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`.
|
||||
### Screenshot Dark và Light
|
||||
|
||||
Mỗi màn hình thường có hai ảnh:
|
||||
|
||||
```text
|
||||
<slug>-dark.png
|
||||
<slug>-light.png
|
||||
```
|
||||
|
||||
Dùng hai ảnh này để:
|
||||
|
||||
* So sánh trước/sau.
|
||||
* Kiểm tra lỗi chỉ xảy ra ở một theme.
|
||||
* Kiểm tra sự khác biệt giữa Dark Mode và Light Mode.
|
||||
|
||||
---
|
||||
|
||||
## 5.2. `docs/screens/controls.json`
|
||||
|
||||
Đây là danh sách các control được trích tự động từ source code.
|
||||
|
||||
Mỗi control có thông tin như:
|
||||
|
||||
```text
|
||||
file
|
||||
var
|
||||
type
|
||||
kind
|
||||
label
|
||||
line
|
||||
signals
|
||||
object_name
|
||||
```
|
||||
|
||||
Trong đó:
|
||||
|
||||
* `file` — file chứa control.
|
||||
* `var` — tên biến.
|
||||
* `type` — loại widget, ví dụ `QLineEdit`.
|
||||
* `kind` — mô tả dễ hiểu, ví dụ `"ô nhập"`, `"nút"`.
|
||||
* `label` — text/label liên quan.
|
||||
* `line` — dòng code.
|
||||
* `signals` — signal liên quan.
|
||||
* `object_name` — `objectName` của widget.
|
||||
|
||||
### Ví dụ
|
||||
|
||||
Người dùng nói:
|
||||
|
||||
> "Ô nhập email trong màn tài khoản bị lỗi."
|
||||
|
||||
Có thể tìm control bằng:
|
||||
|
||||
```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"])
|
||||
text = (c['var'] + c['label']).lower()
|
||||
if 'email' in text:
|
||||
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".
|
||||
Từ kết quả có thể xác định:
|
||||
|
||||
## 6. Quy trình tra 4 bước cho Triage
|
||||
```text
|
||||
file
|
||||
line
|
||||
variable
|
||||
widget type
|
||||
objectName
|
||||
```
|
||||
|
||||
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`.
|
||||
## 6. `object_name` đặc biệt quan trọng khi điều tra UI
|
||||
|
||||
Khi sửa lỗi màu hoặc style, phải chú ý đến:
|
||||
|
||||
```text
|
||||
object_name
|
||||
```
|
||||
|
||||
Nếu `object_name` đang rỗng, có nghĩa widget đó **chưa được gắn `objectName` để áp style theo cơ chế template/QSS**.
|
||||
|
||||
Khi đó widget có thể đang sử dụng style mặc định của class.
|
||||
|
||||
Đây thường là nguyên nhân khiến người dùng thấy:
|
||||
|
||||
> "Chỗ này nhìn khác chỗ kia."
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text
|
||||
Widget A → objectName = "project_title"
|
||||
↓
|
||||
QSS áp style riêng
|
||||
|
||||
Widget B → objectName = ""
|
||||
↓
|
||||
dùng style mặc định
|
||||
```
|
||||
|
||||
Vì vậy, khi gặp lỗi visual liên quan đến màu/style, hãy kiểm tra `object_name` trước khi tự thêm màu hoặc `setStyleSheet()`.
|
||||
|
||||
---
|
||||
|
||||
# 7. Quy trình 4 bước dành cho Triage
|
||||
|
||||
Khi người dùng báo lỗi bằng ngôn ngữ tự nhiên, thực hiện theo thứ tự sau:
|
||||
|
||||
### Bước 1 — Xác định màn hình chính
|
||||
|
||||
Xác định lỗi thuộc:
|
||||
|
||||
```text
|
||||
Dashboard
|
||||
Schedule
|
||||
Workspace
|
||||
Monitoring
|
||||
```
|
||||
|
||||
Dựa trên mô tả của người dùng hoặc screenshot.
|
||||
|
||||
---
|
||||
|
||||
### Bước 2 — Xác định tab/view/dialog
|
||||
|
||||
Tiếp tục xác định:
|
||||
|
||||
```text
|
||||
Sub-tab
|
||||
→ View
|
||||
→ Dialog
|
||||
```
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text
|
||||
Workspace
|
||||
→ Co4E
|
||||
→ Agent Dialog
|
||||
```
|
||||
|
||||
hoặc:
|
||||
|
||||
```text
|
||||
Monitoring
|
||||
→ Security Events
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bước 3 — Tra `manifest.json`
|
||||
|
||||
Mở:
|
||||
|
||||
```text
|
||||
docs/screens/manifest.json
|
||||
```
|
||||
|
||||
Tìm màn hình tương ứng và lấy:
|
||||
|
||||
```text
|
||||
note → file.py:line
|
||||
```
|
||||
|
||||
Đây là điểm bắt đầu để tìm code dựng màn hình.
|
||||
|
||||
---
|
||||
|
||||
### Bước 4 — Tra `controls.json`
|
||||
|
||||
Nếu lỗi liên quan đến một control cụ thể, tiếp tục tìm trong:
|
||||
|
||||
```text
|
||||
docs/screens/controls.json
|
||||
```
|
||||
|
||||
Lấy:
|
||||
|
||||
```text
|
||||
var
|
||||
line
|
||||
type
|
||||
object_name
|
||||
```
|
||||
|
||||
Sau đó xác định chính xác widget bị lỗi.
|
||||
|
||||
---
|
||||
|
||||
# 8. Quy tắc về Confidence
|
||||
|
||||
Triage phải phản ánh đúng mức độ chắc chắn của kết quả.
|
||||
|
||||
Nếu chưa hoàn thành đủ 4 bước:
|
||||
|
||||
```text
|
||||
1. Nav
|
||||
2. Tab/View/Dialog
|
||||
3. manifest.json
|
||||
4. controls.json
|
||||
```
|
||||
|
||||
thì:
|
||||
|
||||
```yaml
|
||||
confidence: low
|
||||
```
|
||||
|
||||
Không được tự nâng lên `medium` hoặc `high` chỉ vì file nhìn có vẻ đúng.
|
||||
|
||||
### Khi nào có thể tăng Confidence?
|
||||
|
||||
Chỉ tăng khi có bằng chứng cụ thể, ví dụ:
|
||||
|
||||
```text
|
||||
User description
|
||||
↓
|
||||
Dashboard
|
||||
↓
|
||||
Statistics view
|
||||
↓
|
||||
manifest.json
|
||||
↓
|
||||
presentation/dashboard/dashboard_tab.py:123
|
||||
↓
|
||||
controls.json
|
||||
↓
|
||||
QTableView
|
||||
↓
|
||||
line 245
|
||||
```
|
||||
|
||||
Khi đó mới có đủ cơ sở để ghi nhận `file:line` và đánh giá confidence cao hơn.
|
||||
|
||||
---
|
||||
|
||||
# 9. Nguyên tắc quan trọng
|
||||
|
||||
**Không đoán file từ tên.**
|
||||
|
||||
Không nên suy luận kiểu:
|
||||
|
||||
> "Lỗi ở Workspace nên chắc chắn nằm trong `workspace_tab.py`."
|
||||
|
||||
Thay vào đó:
|
||||
|
||||
```text
|
||||
Mô tả của user
|
||||
↓
|
||||
Xác định màn hình
|
||||
↓
|
||||
Xác định tab/view/dialog
|
||||
↓
|
||||
Tra manifest.json
|
||||
↓
|
||||
Xác định file:line
|
||||
↓
|
||||
Tra controls.json
|
||||
↓
|
||||
Xác định widget/control
|
||||
↓
|
||||
Đánh giá confidence
|
||||
```
|
||||
|
||||
Mục tiêu cuối cùng của Screen Map là biến một mô tả mơ hồ của người dùng thành một đầu vào có thể sử dụng được cho `defect_record`, đặc biệt là:
|
||||
|
||||
```text
|
||||
screen
|
||||
widget
|
||||
file
|
||||
line
|
||||
object_name
|
||||
confidence
|
||||
```
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+635
-71
@@ -1,101 +1,665 @@
|
||||
# Theme & Design Tokens — luật màu sắc của Cowork Local
|
||||
# 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`.
|
||||
> Knowledge module dành cho các agent xử lý **UI Visual / Theme / QSS** của Cowork Local.
|
||||
|
||||
## Nguồn chính
|
||||
|
||||
* `theme/__init__.py` — docstring và API theme
|
||||
* `theme/palettes.py` — định nghĩa Palette/token
|
||||
* `theme/qss.py` — `_TEMPLATE` và stylesheet
|
||||
* `theme/qss_controls.py` — style cho các Qt controls
|
||||
|
||||
---
|
||||
|
||||
## 1. Luật gốc
|
||||
# 1. Luật quan trọng nhất
|
||||
|
||||
> **Không file nào ngoài `theme/` được đặt tên một màu.**
|
||||
> **Ngoài thư mục `theme/`, không file nào được tự định nghĩa màu.**
|
||||
|
||||
Cơ chế duy nhất:
|
||||
Luồng màu chuẩn của Cowork Local:
|
||||
|
||||
```text
|
||||
Palette (token ngữ nghĩa) → _TEMPLATE (một QSS duy nhất) → stylesheet(theme)
|
||||
Palette
|
||||
↓
|
||||
token ngữ nghĩa
|
||||
↓
|
||||
_TEMPLATE
|
||||
↓
|
||||
stylesheet(theme)
|
||||
↓
|
||||
QApplication.setStyleSheet(...)
|
||||
```
|
||||
|
||||
Hai cách hợp lệ để một widget có màu:
|
||||
Nói đơn giản:
|
||||
|
||||
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.
|
||||
> **Widget không tự chọn màu. Theme quyết định màu.**
|
||||
|
||||
Cách **không** hợp lệ, bị reject review:
|
||||
---
|
||||
|
||||
# 2. Hai cách hợp lệ để widget có màu
|
||||
|
||||
## Cách 1 — Style bằng QSS
|
||||
|
||||
Đây là cách mặc định.
|
||||
|
||||
Widget đặt `objectName`, sau đó style được định nghĩa trong:
|
||||
|
||||
```text
|
||||
theme/qss.py
|
||||
```
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```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)") # ❌
|
||||
widget.setObjectName("my_widget")
|
||||
```
|
||||
|
||||
## 2. API cần nhớ
|
||||
và style tương ứng nằm trong `_TEMPLATE`.
|
||||
|
||||
| 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.
|
||||
## Cách 2 — Widget tự vẽ bằng `QPainter`
|
||||
|
||||
## 3. Nhóm token
|
||||
Dùng cho các thành phần như:
|
||||
|
||||
Palette là `@dataclass(frozen=True)`. Các nhóm chính:
|
||||
* chart;
|
||||
* canvas;
|
||||
* syntax highlighter;
|
||||
* custom painting.
|
||||
|
||||
| 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 |
|
||||
Code phải lấy màu từ:
|
||||
|
||||
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.
|
||||
```python
|
||||
current_palette()
|
||||
```
|
||||
|
||||
## 4. Ràng buộc thiết kế (đừng "sửa" nhầm thành bug)
|
||||
Ví dụ:
|
||||
|
||||
- **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.
|
||||
```python
|
||||
palette = current_palette()
|
||||
```
|
||||
|
||||
## 5. Mũi tên combo box (`_chevron_asset`)
|
||||
Sau đó dùng token từ palette.
|
||||
|
||||
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:
|
||||
# 3. Những cách KHÔNG được phép
|
||||
|
||||
- "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.
|
||||
Không được tự đặt màu trong UI code.
|
||||
|
||||
## 6. Checklist sửa bug liên quan màu sắc
|
||||
### ❌ Hardcode HEX
|
||||
|
||||
- [ ] Đã 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)
|
||||
```python
|
||||
self.label.setStyleSheet("color: #dc2626;")
|
||||
```
|
||||
|
||||
### ❌ Hardcode tên màu
|
||||
|
||||
```python
|
||||
pen.setColor(QColor("red"))
|
||||
```
|
||||
|
||||
### ❌ Hardcode RGBA
|
||||
|
||||
```python
|
||||
self.card.setStyleSheet(
|
||||
"background: rgba(0,0,0,.1)"
|
||||
)
|
||||
```
|
||||
|
||||
Các trường hợp này phải bị reject khi review.
|
||||
|
||||
### Rule ngắn gọn
|
||||
|
||||
```text
|
||||
Không có màu literal ngoài theme/
|
||||
```
|
||||
|
||||
Không chỉ tránh `#hex`, mà cả:
|
||||
|
||||
* tên màu;
|
||||
* RGB;
|
||||
* RGBA;
|
||||
* stylesheet cục bộ chứa màu.
|
||||
|
||||
---
|
||||
|
||||
# 4. API Theme cần nhớ
|
||||
|
||||
| API | Dùng để |
|
||||
| ------------------------------- | --------------------------------------------------- |
|
||||
| `theme.stylesheet(theme)` | Tạo QSS cho toàn app |
|
||||
| `theme.set_active_theme(theme)` | Ghi nhận theme hiện đang active |
|
||||
| `theme.current_theme()` | Lấy theme hiện tại: `dark` / `light` |
|
||||
| `theme.current_palette()` | Lấy Palette của theme hiện tại |
|
||||
| `theme.palette(theme)` | Lấy Palette của một theme cụ thể |
|
||||
| `theme.resolve_theme("system")` | Xác định dark/light theo OS |
|
||||
| `theme.role_colors(theme)` | Lấy màu theo role: user/assistant/tool/result/error |
|
||||
|
||||
---
|
||||
|
||||
## Khi đổi theme
|
||||
|
||||
Hai lệnh này phải đi cùng nhau:
|
||||
|
||||
```python
|
||||
theme.set_active_theme(theme)
|
||||
app.setStyleSheet(theme.stylesheet(theme))
|
||||
```
|
||||
|
||||
Không được chỉ gọi `setStyleSheet()` mà quên cập nhật active theme.
|
||||
|
||||
---
|
||||
|
||||
# 5. `current_palette()` dùng để làm gì?
|
||||
|
||||
Code vẽ bằng `QPainter` phải dùng:
|
||||
|
||||
```python
|
||||
current_palette()
|
||||
```
|
||||
|
||||
Không được mỗi lần `paintEvent()` lại đọc:
|
||||
|
||||
```text
|
||||
config.json
|
||||
```
|
||||
|
||||
Lý do:
|
||||
|
||||
```text
|
||||
paintEvent()
|
||||
↓
|
||||
repaint
|
||||
↓
|
||||
đọc config
|
||||
↓
|
||||
lặp lại rất nhiều lần
|
||||
```
|
||||
|
||||
Điều này từng gây vấn đề hiệu năng thực tế.
|
||||
|
||||
Vì vậy:
|
||||
|
||||
> `current_palette()` tồn tại để custom painting lấy màu nhanh từ theme hiện tại.
|
||||
|
||||
---
|
||||
|
||||
# 6. Palette và Design Token
|
||||
|
||||
`Palette` là:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
```
|
||||
|
||||
Token phải mang **ý nghĩa**, không phải tên màu.
|
||||
|
||||
### ❌ Không đặt token kiểu:
|
||||
|
||||
```text
|
||||
blue
|
||||
grey2
|
||||
dark_blue
|
||||
light_grey
|
||||
```
|
||||
|
||||
### ✅ Đặt theo vai trò:
|
||||
|
||||
```text
|
||||
accent
|
||||
danger
|
||||
text
|
||||
text_muted
|
||||
surface
|
||||
surface_raised
|
||||
```
|
||||
|
||||
Lợi ích:
|
||||
|
||||
> Thêm theme mới = thêm một `Palette`, không phải viết lại stylesheet.
|
||||
|
||||
---
|
||||
|
||||
# 7. Các nhóm token chính
|
||||
|
||||
## 7.1. Surface — các mức bề mặt
|
||||
|
||||
| Token | Dùng cho |
|
||||
| ---------------- | -------------------------------------------- |
|
||||
| `bg` | Nền chính của cửa sổ/canvas |
|
||||
| `surface` | Panel, card, group box |
|
||||
| `surface_raised` | Input, list, tree — nơi người dùng nhập/chọn |
|
||||
| `overlay` | Menu, tooltip, popup |
|
||||
| `sunken` | Log, code, terminal — vùng chủ yếu để đọc |
|
||||
| `hover` | Trạng thái hover |
|
||||
| `active` | Trạng thái đang active/pressed |
|
||||
|
||||
### Lưu ý
|
||||
|
||||
`surface` **không có nghĩa là nav rail**.
|
||||
|
||||
Nav rail có chủ đích riêng về độ sáng/tối.
|
||||
|
||||
---
|
||||
|
||||
## 7.2. Text
|
||||
|
||||
Các token chính:
|
||||
|
||||
```text
|
||||
text
|
||||
text_muted
|
||||
...
|
||||
```
|
||||
|
||||
Dùng token theo vai trò thay vì tự chọn màu.
|
||||
|
||||
---
|
||||
|
||||
## 7.3. Accent
|
||||
|
||||
Có hai token:
|
||||
|
||||
```text
|
||||
accent
|
||||
accent_solid
|
||||
```
|
||||
|
||||
**Hai token này khác nhau có chủ đích.**
|
||||
|
||||
### `accent`
|
||||
|
||||
Dùng cho accent thông thường, ví dụ:
|
||||
|
||||
* trạng thái;
|
||||
* thành phần UI;
|
||||
* điểm nhấn.
|
||||
|
||||
### `accent_solid`
|
||||
|
||||
Dùng khi accent trở thành **nền đặc và bên trên có chữ**.
|
||||
|
||||
Lý do:
|
||||
|
||||
> Một màu accent có thể đủ sáng để đọc khi dùng như chữ trên nền tối, nhưng lại quá sáng khi dùng làm nền cho chữ trắng.
|
||||
|
||||
Vì vậy:
|
||||
|
||||
```text
|
||||
Chữ trên nền accent đặc
|
||||
↓
|
||||
accent_solid
|
||||
```
|
||||
|
||||
Không tự lấy `accent` chỉ vì nó có vẻ "cùng màu".
|
||||
|
||||
---
|
||||
|
||||
## 7.4. State
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text
|
||||
danger
|
||||
...
|
||||
```
|
||||
|
||||
Các state token cũng phải mang ý nghĩa, không đặt theo tên màu.
|
||||
|
||||
---
|
||||
|
||||
## 7.5. Conversation roles
|
||||
|
||||
Có các token:
|
||||
|
||||
```text
|
||||
role_user
|
||||
role_assistant
|
||||
role_tool
|
||||
role_result
|
||||
role_error
|
||||
```
|
||||
|
||||
Dùng để phân biệt các role trong giao diện hội thoại.
|
||||
|
||||
---
|
||||
|
||||
## 7.6. Code / Syntax
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text
|
||||
code_string
|
||||
...
|
||||
```
|
||||
|
||||
Dùng cho syntax highlighting.
|
||||
|
||||
---
|
||||
|
||||
# 8. Các nguyên tắc thiết kế — đừng nhầm thành bug
|
||||
|
||||
Một số đặc điểm nhìn "khác mắt" nhưng **có chủ đích**.
|
||||
|
||||
Không được tự ý sửa chỉ vì người dùng nói "trông hơi tối" hoặc "không giống app hiện đại".
|
||||
|
||||
---
|
||||
|
||||
## 8.1. Không gradient, không glow
|
||||
|
||||
Thiết kế lấy cảm hứng từ:
|
||||
|
||||
```text
|
||||
VS Code Dark Modern
|
||||
VS Code Light Modern
|
||||
```
|
||||
|
||||
Phong cách chính:
|
||||
|
||||
* surface phẳng;
|
||||
* góc gần vuông;
|
||||
* không gradient;
|
||||
* không glow;
|
||||
* một accent chính;
|
||||
* accent dành cho thứ người dùng tương tác.
|
||||
|
||||
---
|
||||
|
||||
## 8.2. Độ sâu đến từ surface và border
|
||||
|
||||
Không tạo chiều sâu bằng cách:
|
||||
|
||||
```text
|
||||
đổi màu quá mạnh
|
||||
```
|
||||
|
||||
Thay vào đó dùng:
|
||||
|
||||
```text
|
||||
surface hierarchy
|
||||
+
|
||||
border mảnh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 9. Nav rail tối hơn là thiết kế có chủ đích
|
||||
|
||||
Silhouette của Cowork Local lấy theo VS Code:
|
||||
|
||||
```text
|
||||
NAV RAIL
|
||||
↓
|
||||
tối hơn
|
||||
↓
|
||||
CONTENT AREA
|
||||
```
|
||||
|
||||
Không phải:
|
||||
|
||||
```text
|
||||
nav rail sáng hơn content
|
||||
```
|
||||
|
||||
Vì vậy nếu user báo:
|
||||
|
||||
> "Menu bên trái tối quá."
|
||||
|
||||
thì **chưa được kết luận ngay là visual bug**.
|
||||
|
||||
Đây có thể là design intent.
|
||||
|
||||
Xem thêm:
|
||||
|
||||
```text
|
||||
examples/bad_fix.md
|
||||
```
|
||||
|
||||
để tránh sửa nhầm.
|
||||
|
||||
---
|
||||
|
||||
# 10. Contrast — WCAG AA
|
||||
|
||||
Body text và chữ trên button nền đặc phải đạt:
|
||||
|
||||
```text
|
||||
Contrast ratio ≥ 4.5:1
|
||||
```
|
||||
|
||||
Đây là yêu cầu tối thiểu.
|
||||
|
||||
Khi thay token/màu:
|
||||
|
||||
```text
|
||||
Dark theme
|
||||
+
|
||||
Light theme
|
||||
+
|
||||
text/background
|
||||
```
|
||||
|
||||
đều phải được kiểm tra.
|
||||
|
||||
---
|
||||
|
||||
## Không khôi phục màu VS Code cũ nếu màu đó không đạt AA
|
||||
|
||||
Một số màu gốc của VS Code không đạt yêu cầu AA.
|
||||
|
||||
Các giá trị đã được Cowork Local điều chỉnh vừa đủ, ví dụ:
|
||||
|
||||
| Trường hợp | Contrast cũ |
|
||||
| ------------------------ | ----------: |
|
||||
| Dark line | 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 |
|
||||
|
||||
Các chỗ này có comment ghi lại giá trị gốc.
|
||||
|
||||
### Rule
|
||||
|
||||
**Không đưa chúng trở lại giá trị VS Code ban đầu.**
|
||||
|
||||
Mục tiêu của Cowork Local là:
|
||||
|
||||
```text
|
||||
VS Code silhouette
|
||||
+
|
||||
WCAG AA
|
||||
```
|
||||
|
||||
không phải copy nguyên xi mọi giá trị màu của VS Code.
|
||||
|
||||
---
|
||||
|
||||
# 11. ⚠️ Combo Box và `_chevron_asset`
|
||||
|
||||
Một lỗi dễ gặp:
|
||||
|
||||
> Combo box mất mũi tên.
|
||||
|
||||
Nguyên nhân liên quan đến cách Qt xử lý QSS.
|
||||
|
||||
---
|
||||
|
||||
## 11.1. `image:` trong QSS không nhận `QPixmap`
|
||||
|
||||
QSS:
|
||||
|
||||
```text
|
||||
image:
|
||||
```
|
||||
|
||||
chỉ nhận đường dẫn tới:
|
||||
|
||||
* file;
|
||||
* resource.
|
||||
|
||||
Không nhận trực tiếp:
|
||||
|
||||
```text
|
||||
QPixmap
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11.2. Style `::drop-down` sẽ làm Qt ngừng vẽ arrow mặc định
|
||||
|
||||
Khi style các selector như:
|
||||
|
||||
```text
|
||||
::drop-down
|
||||
::up-button
|
||||
::down-button
|
||||
```
|
||||
|
||||
Qt có thể ngừng vẽ mũi tên mặc định.
|
||||
|
||||
---
|
||||
|
||||
## 11.3. Cowork Local dùng `_chevron_asset`
|
||||
|
||||
Trong:
|
||||
|
||||
```text
|
||||
theme/palettes.py
|
||||
```
|
||||
|
||||
`_chevron_asset`:
|
||||
|
||||
1. render chevron thành PNG;
|
||||
2. lưu vào thư mục tạm;
|
||||
3. cache theo:
|
||||
|
||||
```text
|
||||
(direction, color)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Khi debug combo box
|
||||
|
||||
Nếu thấy:
|
||||
|
||||
> Combo box mất mũi tên.
|
||||
|
||||
Hãy kiểm tra trước:
|
||||
|
||||
```text
|
||||
stylesheet cục bộ
|
||||
↓
|
||||
::drop-down
|
||||
```
|
||||
|
||||
Đây thường là nguyên nhân.
|
||||
|
||||
Cache nằm tại:
|
||||
|
||||
```text
|
||||
%TEMP%/cowork_local_theme/chevron_*.png
|
||||
```
|
||||
|
||||
Nếu đang test màu mới, có thể xóa cache để buộc render lại.
|
||||
|
||||
---
|
||||
|
||||
# 12. Checklist sửa bug màu sắc/theme
|
||||
|
||||
Trước khi hoàn thành visual fix, kiểm tra:
|
||||
|
||||
### Theme coverage
|
||||
|
||||
* [ ] Bug đã được kiểm tra trên **Dark** chưa?
|
||||
* [ ] Bug đã được kiểm tra trên **Light** chưa?
|
||||
* [ ] Có thể dùng screenshot:
|
||||
|
||||
* `docs/screens/*-dark.png`
|
||||
* `docs/screens/*-light.png`
|
||||
|
||||
### Token
|
||||
|
||||
* [ ] Patch dùng semantic token thay vì hex literal?
|
||||
* [ ] Không có `setStyleSheet()` cục bộ để thay màu?
|
||||
* [ ] Không có `QColor("red")`, `QColor("blue")`, v.v.?
|
||||
* [ ] Nếu thêm token mới, đã thêm cho **cả `DARK` và `LIGHT`**?
|
||||
* [ ] Token mới có tên theo **ý nghĩa**, không theo màu?
|
||||
|
||||
### Accent
|
||||
|
||||
* [ ] Chữ trên nền accent đặc đã dùng `accent_solid`?
|
||||
* [ ] Không dùng `accent` chỉ vì hai token có vẻ giống nhau?
|
||||
|
||||
### Accessibility
|
||||
|
||||
* [ ] Contrast đạt **≥ 4.5:1**?
|
||||
* [ ] Đã kiểm tra cả text và button có nền đặc?
|
||||
|
||||
### Theme lifecycle
|
||||
|
||||
* [ ] Widget tạo sau khi đổi theme có nhận đúng stylesheet?
|
||||
* [ ] Đã kiểm tra vấn đề lazy screen theo `qt_pitfalls.md` **P07**?
|
||||
|
||||
### Design intent
|
||||
|
||||
* [ ] Không vô tình thêm gradient?
|
||||
* [ ] Không thêm glow?
|
||||
* [ ] Không làm nav rail sáng hơn content?
|
||||
* [ ] Không khôi phục các màu VS Code cũ đã bị loại vì không đạt WCAG AA?
|
||||
|
||||
---
|
||||
|
||||
# 13. Quy tắc review nhanh
|
||||
|
||||
Khi gặp một defect liên quan màu sắc, đi theo thứ tự:
|
||||
|
||||
```text
|
||||
1. Xác định widget
|
||||
↓
|
||||
2. Kiểm tra objectName
|
||||
↓
|
||||
3. Tìm rule trong theme/qss.py
|
||||
↓
|
||||
4. Kiểm tra token trong palettes.py
|
||||
↓
|
||||
5. Kiểm tra DARK + LIGHT
|
||||
↓
|
||||
6. Kiểm tra contrast
|
||||
↓
|
||||
7. Kiểm tra local setStyleSheet()
|
||||
↓
|
||||
8. Kiểm tra lazy theme lifecycle (P07)
|
||||
↓
|
||||
9. Xác định đây là bug thật hay design intent
|
||||
↓
|
||||
10. Chỉ sau đó mới tạo fix_plan
|
||||
```
|
||||
|
||||
## Nguyên tắc cuối
|
||||
|
||||
```text
|
||||
UI code
|
||||
↓
|
||||
không tự chọn màu
|
||||
↓
|
||||
semantic token
|
||||
↓
|
||||
Palette
|
||||
↓
|
||||
_TEMPLATE / current_palette()
|
||||
↓
|
||||
theme
|
||||
```
|
||||
|
||||
**Nếu một màu mới cần xuất hiện, trước tiên hỏi:**
|
||||
|
||||
> "Màu này đang đại diện cho vai trò gì?"
|
||||
|
||||
Sau đó tạo hoặc dùng **semantic token** phù hợp.
|
||||
|
||||
Không hỏi:
|
||||
|
||||
> "Mình muốn màu xanh nào?"
|
||||
|
||||
Vì trong Cowork Local, **ý nghĩa của màu quan trọng hơn bản thân màu**.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Output Contract — `dispatch_plan`
|
||||
|
||||
Do `fix-dispatcher` sinh ra, trước khi bất kỳ agent nào khác chạy.
|
||||
Đây là thứ quyết định **effort** của cả lượt xử lý, nên nó phải chứng minh được lựa chọn
|
||||
của mình — nhưng phải ngắn. Trần: **30 dòng** cho phần người đọc.
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
---
|
||||
report_id: RPT-<YYYYMMDD>-<NN> # một phản ánh của người dùng = một report_id
|
||||
defects:
|
||||
- defect_id: UI-<YYYYMMDD>-<NN>
|
||||
tier: <T0 | T1 | T2 | T3 | T3-SEC>
|
||||
lane: <DIRECT | SOLO | PAIR | FULL | FULL-SEC>
|
||||
category: <visual | flow | i18n-a11y | security | not-ui>
|
||||
severity: <S1 | S2 | S3 | S4>
|
||||
confidence: <low | medium | high>
|
||||
reproducible: <yes | no | intermittent>
|
||||
security_review: <required | not-required>
|
||||
entry_agent: <fix-implementer | ui-visual-fixer | ux-flow-fixer | i18n-a11y-fixer | security-defect-fixer | ui-bug-triage | SELF | RETURN_TO_REPORTER>
|
||||
affected_files: [path/to/file.py:123]
|
||||
tier_evidence: "<dòng nào của roles/0_fix_dispatcher.md Bước 3 đã trúng>"
|
||||
budget_calls: <số lần gọi agent dự kiến>
|
||||
execution:
|
||||
parallel: [[UI-...-01, UI-...-02]] # các defect_id độc lập, chạy cùng lúc
|
||||
sequential: [UI-...-03] # phụ thuộc, hoặc T3 cần triage trước
|
||||
blocked_on: []
|
||||
---
|
||||
```
|
||||
|
||||
# 1. Phản ánh gốc
|
||||
|
||||
Nguyên văn của người báo lỗi, **đã redact** (`system/security.md`). Không diễn giải lại.
|
||||
|
||||
# 2. Tách defect
|
||||
|
||||
| defect_id | Triệu chứng người dùng thấy | Category | Tier |
|
||||
|---|---|---|---|
|
||||
| | | | |
|
||||
|
||||
Một dòng = một nguyên nhân gốc. Chỉ có một defect thì bảng có một dòng — không xoá bảng.
|
||||
|
||||
# 3. Bằng chứng chấm tier
|
||||
|
||||
Mỗi defect **một dòng**, trích đúng tiêu chí đã trúng. Không được viết "trông đơn giản".
|
||||
|
||||
| defect_id | Tier | Trúng tiêu chí | Lệnh đã dùng để xác nhận |
|
||||
|---|---|---|---|
|
||||
| | T0 | loại 1 (số đo hiển thị), 0 disqualifier | `check_loc.py`, `grep -rn` blast radius |
|
||||
| | T2 | "chạm QSS/token dùng chung" | `grep -rn "<objectName>"` |
|
||||
|
||||
Với **T0** bắt buộc có cột lệnh — Gate S và blast radius phải đo, không được ước lượng.
|
||||
|
||||
# 4. Kế hoạch chạy
|
||||
|
||||
```text
|
||||
UI-...-01 T0 DIRECT → hub sửa luôn, cổng máy §4.1
|
||||
UI-...-02 T2 PAIR → ui-visual-fixer → fix-implementer → regression-reviewer
|
||||
UI-...-03 T3 FULL → ui-bug-triage → ... (chờ triage mới biết specialist nào)
|
||||
```
|
||||
|
||||
Ngân sách tổng: `___` lần gọi agent (bảng §4 của role 0 cho phép `___`).
|
||||
|
||||
# 5. Điều đã cố ý KHÔNG làm
|
||||
|
||||
- Không gọi `ui-bug-triage` cho defect nào? Vì sao được phép bỏ (phản ánh đã tự chỉ ra
|
||||
màn hình + triệu chứng cụ thể).
|
||||
- Không gọi `regression-reviewer` cho defect nào? Chỉ hợp lệ ở T0/T1 — nêu rõ cổng nào
|
||||
thay thế.
|
||||
|
||||
# 6. Open question
|
||||
|
||||
Tối đa 3, mỗi câu kèm phương án mặc định nếu người dùng không trả lời
|
||||
(`response_policy.md` R3). Câu hỏi **chặn** thì đưa vào `blocked_on`.
|
||||
File diff suppressed because it is too large
Load Diff
+429
-43
@@ -1,81 +1,467 @@
|
||||
# Guardrail — luật bất biến cho mọi agent trong `agent/`
|
||||
# Guardrail — Luật bất biến cho mọi agent trong `agent/`
|
||||
|
||||
Áp dụng cho cả 6 role. Role nào mâu thuẫn với file này thì **file này thắng**.
|
||||
> **PRECEDENCE:** File này áp dụng cho **tất cả 6 role** trong `agent/`.
|
||||
>
|
||||
> Nếu role-specific instruction mâu thuẫn với bất kỳ quy tắc nào dưới đây, **Guardrail này thắng**.
|
||||
|
||||
---
|
||||
|
||||
## G1. Không tự bịa requirement
|
||||
|
||||
- Chỉ làm việc trên những gì có trong bug report, source code, và `knowledge/`.
|
||||
- Thiếu thông tin → ghi vào mục **Assumption** hoặc **Open Question**, KHÔNG tự suy diễn
|
||||
rồi sửa theo suy diễn đó.
|
||||
- Không tự ý "tiện tay cải thiện UX" ngoài phạm vi lỗi được báo. Phát hiện vấn đề khác →
|
||||
ghi vào mục **Out of scope (đề xuất issue riêng)**.
|
||||
* Chỉ làm việc dựa trên:
|
||||
|
||||
* bug report;
|
||||
* source code thực tế;
|
||||
* các tài liệu trong `knowledge/`;
|
||||
* governance và security policy liên quan.
|
||||
* Nếu thiếu thông tin:
|
||||
|
||||
* ghi vào `Assumption`; hoặc
|
||||
* ghi vào `Open Question`.
|
||||
* **Không được tự suy diễn requirement rồi sửa theo suy diễn đó.**
|
||||
* Không tự ý "tiện tay cải thiện UX", refactor hoặc đổi behavior ngoài phạm vi bug.
|
||||
* Nếu phát hiện vấn đề khác:
|
||||
|
||||
* ghi vào `Out of scope (đề xuất issue riêng)`;
|
||||
* không sửa trong cùng patch.
|
||||
|
||||
---
|
||||
|
||||
## G2. Không đoán vị trí code
|
||||
|
||||
- Mọi khẳng định về code phải kèm `path/file.py:line`. Chưa đọc file thì chưa được kết luận.
|
||||
- Người dùng mô tả bằng tiếng Việt/Nhật → tra `knowledge/screen_map.md` và
|
||||
`docs/screens/controls.json` để tìm đúng widget, không đoán theo tên gọi.
|
||||
* Không được kết luận về code khi chưa đọc code thực tế.
|
||||
* Mọi khẳng định cụ thể về implementation phải kèm:
|
||||
|
||||
```text
|
||||
path/file.py:line
|
||||
```
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text
|
||||
Root cause nằm tại presentation/shell/nav_rail.py:242
|
||||
```
|
||||
|
||||
* Khi người dùng mô tả bằng tiếng Việt hoặc tiếng Nhật:
|
||||
|
||||
1. tra `knowledge/screen_map.md`;
|
||||
2. tra `docs/screens/manifest.json`;
|
||||
3. tra `docs/screens/controls.json`;
|
||||
4. xác nhận `screen → view → widget → file → line`.
|
||||
* **Không đoán file chỉ dựa vào tên widget hoặc tên màn hình.**
|
||||
* Nếu chưa đủ bằng chứng để xác định vị trí:
|
||||
|
||||
* `confidence: low`;
|
||||
* ghi rõ thông tin còn thiếu.
|
||||
|
||||
---
|
||||
|
||||
## G3. Sửa đúng tầng
|
||||
|
||||
Cowork Local là Clean Architecture 4 tầng, phụ thuộc chỉ hướng vào trong:
|
||||
Cowork Local sử dụng Clean Architecture 4 tầng:
|
||||
|
||||
```text
|
||||
presentation/ → application/ → domain/ ← infrastructure/
|
||||
```
|
||||
|
||||
- Bug UI/UX được sửa ở `presentation/`, `ui/`, `theme/`, `i18n/`. Đó là mặc định.
|
||||
- Nếu buộc phải đụng `application/` hoặc `domain/`, phải nêu rõ **lý do tại sao không
|
||||
sửa được ở tầng trên** trong `fix_plan.md`, và coi đó là thay đổi cần reviewer chú ý.
|
||||
- `domain/` và `application/` là **100% Pure Python**. Tuyệt đối không thêm import
|
||||
`PySide6`/`PyQt` vào hai tầng này — Gate C sẽ chặn.
|
||||
- Widget chỉ gọi xuống service của `application/`. Không query SQLite/JSON trực tiếp,
|
||||
không gọi LLM trực tiếp trong GUI thread.
|
||||
### Quy tắc
|
||||
|
||||
* Bug UI/UX mặc định được xử lý tại:
|
||||
|
||||
* `presentation/`
|
||||
* `ui/`
|
||||
* `theme/`
|
||||
* `i18n/`
|
||||
|
||||
* Nếu buộc phải sửa `application/` hoặc `domain/`:
|
||||
|
||||
* phải giải thích trong `fix_plan.md` **tại sao không thể giải quyết ở tầng trên**;
|
||||
* phải đánh dấu đây là thay đổi cần reviewer chú ý.
|
||||
|
||||
### Pure Python boundary
|
||||
|
||||
`domain/` và `application/` phải là **100% Pure Python**.
|
||||
|
||||
**Tuyệt đối không thêm:**
|
||||
|
||||
```python
|
||||
from PySide6 ...
|
||||
from PyQt...
|
||||
```
|
||||
|
||||
vào hai tầng này.
|
||||
|
||||
Gate C sẽ chặn vi phạm này.
|
||||
|
||||
### GUI boundary
|
||||
|
||||
Widget:
|
||||
|
||||
* chỉ gọi service/use case của `application/`;
|
||||
* không query SQLite trực tiếp;
|
||||
* không đọc/ghi JSON repository trực tiếp;
|
||||
* không gọi LLM trực tiếp trong GUI thread.
|
||||
|
||||
---
|
||||
|
||||
## G4. Không đặt tên màu ngoài `theme/`
|
||||
|
||||
- Không hex literal (`#1f6fb2`), không `QColor("red")`, không `setStyleSheet("color: blue")`
|
||||
trong bất kỳ file nào ngoài `theme/`.
|
||||
- Sửa màu = sửa/đọc token trong `theme/palettes.py`, hoặc gán `objectName` rồi style trong
|
||||
`theme/qss.py`. Chi tiết: `knowledge/theme_tokens.md`.
|
||||
- Đây là lỗi bị từ chối review thường xuyên nhất khi sửa bug UI.
|
||||
Ngoài `theme/`, tuyệt đối không định nghĩa màu trực tiếp.
|
||||
|
||||
### Không được dùng
|
||||
|
||||
```python
|
||||
"#1f6fb2"
|
||||
QColor("red")
|
||||
setStyleSheet("color: blue")
|
||||
```
|
||||
|
||||
Cũng không được tạo màu bằng:
|
||||
|
||||
* hex literal;
|
||||
* color name;
|
||||
* RGB/RGBA literal;
|
||||
* stylesheet màu viết trực tiếp.
|
||||
|
||||
### Cách đúng
|
||||
|
||||
Màu phải đi qua theme system:
|
||||
|
||||
```text
|
||||
Palette
|
||||
↓
|
||||
semantic token
|
||||
↓
|
||||
QSS template / current_palette()
|
||||
↓
|
||||
widget
|
||||
```
|
||||
|
||||
Có hai cách hợp lệ:
|
||||
|
||||
1. Widget có `objectName` và được style trong `theme/qss.py`.
|
||||
2. Custom painting dùng `current_palette()`.
|
||||
|
||||
Chi tiết xem:
|
||||
|
||||
```text
|
||||
knowledge/theme_tokens.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## G5. Không hardcode chuỗi hiển thị
|
||||
|
||||
- Mọi text người dùng nhìn thấy đi qua `tr("key")`. Chi tiết: `knowledge/i18n_rules.md`.
|
||||
- Sửa một nhãn = sửa cả 3 ngôn ngữ `en` / `ja` / `vi`, không sửa mỗi tiếng Việt.
|
||||
Mọi text người dùng nhìn thấy phải đi qua:
|
||||
|
||||
```python
|
||||
tr("key")
|
||||
```
|
||||
|
||||
Chi tiết xem:
|
||||
|
||||
```text
|
||||
knowledge/i18n_rules.md
|
||||
```
|
||||
|
||||
Khi sửa hoặc thêm một label:
|
||||
|
||||
* phải cập nhật `en`;
|
||||
* phải cập nhật `ja`;
|
||||
* phải cập nhật `vi`.
|
||||
|
||||
**Không chỉ sửa tiếng Việt.**
|
||||
|
||||
Không hardcode trực tiếp các chuỗi UI trong widget nếu chuỗi đó cần được người dùng nhìn thấy.
|
||||
|
||||
---
|
||||
|
||||
## G6. Giữ Single Responsibility
|
||||
|
||||
- Mọi module production `<= 400 LOC` (Gate S). Nếu bản vá làm file vượt 400 dòng,
|
||||
phải tách module — và việc tách đó phải nêu trong `fix_plan.md` trước khi làm.
|
||||
- Không "sửa bug" bằng cách nhét thêm 150 dòng vào một file đã 380 dòng.
|
||||
Mọi production module phải:
|
||||
|
||||
```text
|
||||
<= 400 LOC
|
||||
```
|
||||
|
||||
Đây là giới hạn của Gate S.
|
||||
|
||||
### Nếu patch làm file vượt 400 dòng
|
||||
|
||||
Không được tiếp tục nhồi code vào file.
|
||||
|
||||
Phải:
|
||||
|
||||
1. xác định phần cần tách;
|
||||
2. ghi kế hoạch tách trong `fix_plan.md`;
|
||||
3. thực hiện việc tách như một phần rõ ràng của patch;
|
||||
4. đảm bảo dependency direction không bị phá vỡ.
|
||||
|
||||
### Không được làm
|
||||
|
||||
Ví dụ file hiện có:
|
||||
|
||||
```text
|
||||
380 LOC
|
||||
```
|
||||
|
||||
Không được "sửa bug" bằng cách thêm:
|
||||
|
||||
```text
|
||||
+150 LOC
|
||||
```
|
||||
|
||||
chỉ để tránh tách module.
|
||||
|
||||
---
|
||||
|
||||
## G7. Không làm suy yếu kiểm thử
|
||||
|
||||
- Không xoá test, không `@pytest.mark.skip`, không nới assert để pass gate.
|
||||
- Test đang đỏ vì lý do khác → báo trong report, không sửa lén.
|
||||
- Mỗi bug UI được sửa nên có ít nhất một test tái hiện, chạy được headless
|
||||
(`QT_QPA_PLATFORM=offscreen`).
|
||||
Tuyệt đối không:
|
||||
|
||||
* xoá test;
|
||||
* disable test;
|
||||
* dùng `@pytest.mark.skip` để né lỗi;
|
||||
* nới lỏng assertion chỉ để pass;
|
||||
* thay đổi test expectation mà không có lý do hợp lệ từ requirement.
|
||||
|
||||
Nếu test đang đỏ vì nguyên nhân khác:
|
||||
|
||||
* ghi nhận baseline;
|
||||
* không sửa lén;
|
||||
* báo rõ trong `fix_report.md`.
|
||||
|
||||
### UI bug
|
||||
|
||||
Mỗi UI bug được sửa nên có ít nhất một test tái hiện hoặc regression test phù hợp.
|
||||
|
||||
Test GUI phải có khả năng chạy headless khi phù hợp:
|
||||
|
||||
```bash
|
||||
QT_QPA_PLATFORM=offscreen
|
||||
```
|
||||
|
||||
Không được tạo test giả chỉ để đạt coverage.
|
||||
|
||||
---
|
||||
|
||||
## G8. Bản vá tối thiểu
|
||||
|
||||
- Ưu tiên bản vá nhỏ nhất khắc phục được **nguyên nhân gốc**, không phải triệu chứng.
|
||||
- Không refactor kèm trong PR fix bug. Một PR = một thay đổi logic (Definition of Done).
|
||||
- Không đổi format/indent toàn file — diff phải đọc được.
|
||||
Mục tiêu là:
|
||||
|
||||
> **Bản vá nhỏ nhất có thể sửa đúng nguyên nhân gốc.**
|
||||
|
||||
Không chỉ sửa triệu chứng.
|
||||
|
||||
### Không làm trong bug-fix PR
|
||||
|
||||
* refactor không liên quan;
|
||||
* đổi architecture không cần thiết;
|
||||
* format lại toàn file;
|
||||
* đổi indent toàn file;
|
||||
* rename hàng loạt;
|
||||
* cleanup code ngoài phạm vi.
|
||||
|
||||
Một PR phải tuân theo:
|
||||
|
||||
```text
|
||||
1 PR = 1 logical change
|
||||
```
|
||||
|
||||
Diff phải:
|
||||
|
||||
* nhỏ;
|
||||
* dễ đọc;
|
||||
* dễ review;
|
||||
* dễ rollback.
|
||||
|
||||
---
|
||||
|
||||
## G9. Không tự merge, không tự đóng issue
|
||||
|
||||
- Agent chỉ đề xuất. Quyết định merge thuộc Cowork Team (`docs/governance/ownership.md`).
|
||||
- Thay đổi chạm tới permission, credential, MCP write/exec, sandbox, network, TLS,
|
||||
isolation, model routing, xoá dữ liệu → **bắt buộc** đánh dấu `security-review: required`
|
||||
trong output, kể cả khi chỉ sửa UI.
|
||||
Agent chỉ:
|
||||
|
||||
* phân tích;
|
||||
* đề xuất;
|
||||
* tạo `fix_plan`;
|
||||
* implement khi đúng role;
|
||||
* kiểm chứng;
|
||||
* tạo report;
|
||||
* handoff.
|
||||
|
||||
Agent **không tự quyết định merge**.
|
||||
|
||||
Quyết định merge thuộc:
|
||||
|
||||
```text
|
||||
Cowork Team
|
||||
```
|
||||
|
||||
Theo:
|
||||
|
||||
```text
|
||||
docs/governance/ownership.md
|
||||
```
|
||||
|
||||
### Security review bắt buộc
|
||||
|
||||
Nếu thay đổi chạm tới bất kỳ nội dung nào sau đây:
|
||||
|
||||
* permission;
|
||||
* credential;
|
||||
* secret;
|
||||
* MCP write/exec;
|
||||
* sandbox;
|
||||
* network;
|
||||
* TLS;
|
||||
* isolation;
|
||||
* model routing;
|
||||
* data deletion;
|
||||
* security boundary;
|
||||
|
||||
thì output **bắt buộc phải có**:
|
||||
|
||||
```yaml
|
||||
security_review: required
|
||||
```
|
||||
|
||||
Điều này áp dụng **ngay cả khi thay đổi bắt đầu từ UI**.
|
||||
|
||||
`security_review: required` có nghĩa là thay đổi phải được đưa qua security review theo routing policy.
|
||||
|
||||
Không được tự kết luận:
|
||||
|
||||
> "Chỉ sửa UI nên không cần security review."
|
||||
|
||||
---
|
||||
|
||||
## G10. Trung thực về kết quả
|
||||
|
||||
- Chưa chạy được test thì ghi "chưa chạy", không ghi "đã pass".
|
||||
- Sửa được 2/3 vấn đề trong report thì nói rõ phần còn lại và lý do.
|
||||
- Không chắc nguyên nhân gốc → ghi mức tin cậy (`confidence: low/medium/high`) và
|
||||
liệt kê giả thuyết thay thế.
|
||||
Agent phải báo cáo đúng những gì thực sự đã làm.
|
||||
|
||||
### Chưa chạy test
|
||||
|
||||
Không được viết:
|
||||
|
||||
```text
|
||||
Tests passed
|
||||
```
|
||||
|
||||
Phải viết:
|
||||
|
||||
```text
|
||||
Tests: not run
|
||||
```
|
||||
|
||||
hoặc:
|
||||
|
||||
```text
|
||||
Chưa chạy test do <lý do>.
|
||||
```
|
||||
|
||||
### Chỉ sửa được một phần
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text
|
||||
2/3 vấn đề đã được xử lý.
|
||||
Vấn đề còn lại: ...
|
||||
Lý do chưa xử lý: ...
|
||||
```
|
||||
|
||||
Không được báo cáo như thể toàn bộ bug đã được giải quyết.
|
||||
|
||||
### Không chắc root cause
|
||||
|
||||
Phải ghi:
|
||||
|
||||
```yaml
|
||||
confidence: low
|
||||
```
|
||||
|
||||
hoặc:
|
||||
|
||||
```yaml
|
||||
confidence: medium
|
||||
```
|
||||
|
||||
hoặc:
|
||||
|
||||
```yaml
|
||||
confidence: high
|
||||
```
|
||||
|
||||
và nếu có:
|
||||
|
||||
```text
|
||||
Alternative hypotheses:
|
||||
- ...
|
||||
- ...
|
||||
```
|
||||
|
||||
### Nguyên tắc
|
||||
|
||||
> **Evidence trước, kết luận sau.**
|
||||
|
||||
Không được biến:
|
||||
|
||||
```text
|
||||
chưa kiểm chứng
|
||||
```
|
||||
|
||||
thành:
|
||||
|
||||
```text
|
||||
đã xác nhận
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Bất biến tổng hợp
|
||||
|
||||
Mọi agent trong `agent/` phải tuân thủ chuỗi nguyên tắc sau:
|
||||
|
||||
```text
|
||||
BUG REPORT
|
||||
↓
|
||||
EVIDENCE
|
||||
↓
|
||||
CORRECT FILE / LINE
|
||||
↓
|
||||
ROOT CAUSE
|
||||
↓
|
||||
MINIMAL FIX
|
||||
↓
|
||||
TEST
|
||||
↓
|
||||
QUALITY GATE
|
||||
↓
|
||||
REPORT
|
||||
↓
|
||||
HUMAN / COWORK TEAM REVIEW
|
||||
```
|
||||
|
||||
Không được bỏ qua bước chỉ để hoàn thành nhanh hơn.
|
||||
|
||||
---
|
||||
|
||||
# Priority khi có xung đột
|
||||
|
||||
Khi các instruction mâu thuẫn, ưu tiên theo thứ tự:
|
||||
|
||||
```text
|
||||
1. Guardrail G1–G10
|
||||
2. Security policy / governance
|
||||
3. knowledge/
|
||||
4. Role-specific instruction
|
||||
5. Bug report / task-specific detail
|
||||
6. Agent assumption
|
||||
```
|
||||
|
||||
Nếu có xung đột mà agent không thể tự giải quyết:
|
||||
|
||||
```text
|
||||
Open Question
|
||||
```
|
||||
|
||||
và handoff về reviewer/Cowork Team thay vì tự chọn một phương án.
|
||||
|
||||
+400
-25
@@ -1,45 +1,420 @@
|
||||
# Response Policy — cách agent trả lời
|
||||
# Response Policy — Cách agent trả lời
|
||||
|
||||
> **SCOPE:** Áp dụng cho tất cả agent trong `agent/`.
|
||||
>
|
||||
> Response Policy quy định **cách agent giao tiếp và trình bày output**. Nếu mâu thuẫn với `Guardrail G1–G10`, **Guardrail thắng**.
|
||||
|
||||
---
|
||||
|
||||
## R1. Ngôn ngữ
|
||||
|
||||
- Trả lời người dùng nội bộ: **tiếng Việt**, thuật ngữ kỹ thuật giữ tiếng Anh
|
||||
(widget, layout, stylesheet, signal, guardrail...).
|
||||
- Docstring và comment trong code: **tiếng Anh**, khớp với codebase hiện tại.
|
||||
- Chuỗi hiển thị cho end-user: qua `tr()`, đủ `en` / `ja` / `vi`.
|
||||
### Trả lời người dùng nội bộ
|
||||
|
||||
* Sử dụng **tiếng Việt**.
|
||||
* Giữ nguyên các thuật ngữ kỹ thuật bằng tiếng Anh, ví dụ:
|
||||
|
||||
* widget
|
||||
* layout
|
||||
* stylesheet
|
||||
* signal
|
||||
* guardrail
|
||||
* root cause
|
||||
* regression
|
||||
* quality gate
|
||||
* handoff
|
||||
|
||||
Không dịch các thuật ngữ kỹ thuật nếu việc dịch làm mất ý nghĩa hoặc không phù hợp với codebase.
|
||||
|
||||
### Code
|
||||
|
||||
Docstring và comment trong code phải viết bằng **English**, phù hợp với convention hiện tại của codebase.
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```python
|
||||
def refresh(self) -> None:
|
||||
"""Refresh the current view."""
|
||||
```
|
||||
|
||||
Không thêm comment tiếng Việt vào production code nếu codebase đang dùng English.
|
||||
|
||||
### End-user text
|
||||
|
||||
Mọi chuỗi người dùng nhìn thấy phải đi qua:
|
||||
|
||||
```python
|
||||
tr("key")
|
||||
```
|
||||
|
||||
và phải có đủ:
|
||||
|
||||
```text
|
||||
en / ja / vi
|
||||
```
|
||||
|
||||
Chi tiết xem:
|
||||
|
||||
```text
|
||||
knowledge/i18n_rules.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## R2. Format
|
||||
|
||||
- Đi thẳng vào kết quả. Không mở bài, không "Chắc chắn rồi!", không tóm tắt lại đề bài.
|
||||
- Mọi output theo đúng template trong `output/`. Thiếu mục nào ghi `N/A` kèm lý do,
|
||||
không xoá mục.
|
||||
- Mọi tham chiếu code viết dạng `path/to/file.py:123`.
|
||||
- Code block phải ghi rõ ngôn ngữ. Diff dùng ` ```diff `.
|
||||
### Không mở bài
|
||||
|
||||
Đi thẳng vào kết quả.
|
||||
|
||||
Không dùng các câu mở đầu như:
|
||||
|
||||
```text
|
||||
Chắc chắn rồi!
|
||||
Tôi sẽ giúp bạn...
|
||||
Theo yêu cầu của bạn...
|
||||
```
|
||||
|
||||
Không lặp lại toàn bộ nội dung task trước khi xử lý.
|
||||
|
||||
### Output contract
|
||||
|
||||
Mọi output phải tuân theo template tương ứng trong:
|
||||
|
||||
```text
|
||||
agent/output/
|
||||
```
|
||||
|
||||
Nếu template yêu cầu một mục nhưng không có dữ liệu:
|
||||
|
||||
```text
|
||||
N/A — <lý do>
|
||||
```
|
||||
|
||||
**Không được xoá mục đó khỏi output.**
|
||||
|
||||
### Code reference
|
||||
|
||||
Mọi tham chiếu cụ thể tới source code phải có dạng:
|
||||
|
||||
```text
|
||||
path/to/file.py:123
|
||||
```
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text
|
||||
presentation/shell/nav_rail.py:242
|
||||
```
|
||||
|
||||
Không dùng:
|
||||
|
||||
```text
|
||||
nav_rail.py
|
||||
dòng 242
|
||||
file nav rail
|
||||
```
|
||||
|
||||
nếu đang chỉ tới một vị trí code cụ thể.
|
||||
|
||||
### Code block
|
||||
|
||||
Mọi code block phải khai báo language.
|
||||
|
||||
Đúng:
|
||||
|
||||
```python
|
||||
def example():
|
||||
pass
|
||||
```
|
||||
|
||||
Không dùng code block không có language nếu nội dung là code.
|
||||
|
||||
### Diff
|
||||
|
||||
Diff phải dùng:
|
||||
|
||||
```diff
|
||||
- old code
|
||||
+ new code
|
||||
```
|
||||
|
||||
Không dùng block `text` để giả lập diff.
|
||||
|
||||
---
|
||||
|
||||
## R3. Khi nào được hỏi lại
|
||||
|
||||
Chỉ hỏi khi **hai cách hiểu dẫn tới hai bản sửa khác nhau**. Ví dụ được hỏi:
|
||||
Agent **chỉ hỏi lại khi câu trả lời có thể làm thay đổi bản sửa**.
|
||||
|
||||
- Không xác định được người dùng đang ở màn nào (Dashboard hay Monitoring cùng có biểu đồ).
|
||||
- Không rõ hành vi mong muốn là gì (nút nên disable hay nên hiện cảnh báo).
|
||||
- Không tái hiện được và cần biết OS / độ phân giải / scale màn hình / theme.
|
||||
Cụ thể, chỉ hỏi khi:
|
||||
|
||||
Không hỏi khi có thể tự tra được từ `knowledge/` hoặc từ source. Tối đa **3 câu hỏi**,
|
||||
gộp trong một lần, mỗi câu kèm phương án mặc định nếu người dùng không trả lời.
|
||||
> **Hai cách hiểu khác nhau có thể dẫn tới hai implementation khác nhau.**
|
||||
|
||||
### Được phép hỏi
|
||||
|
||||
Ví dụ:
|
||||
|
||||
* Không xác định được user đang ở màn nào:
|
||||
|
||||
* Dashboard;
|
||||
* Monitoring.
|
||||
|
||||
* Không rõ expected behavior:
|
||||
|
||||
* disable button;
|
||||
* hay hiện warning.
|
||||
|
||||
* Không tái hiện được và cần thông tin môi trường:
|
||||
|
||||
* OS;
|
||||
* screen resolution;
|
||||
* display scale;
|
||||
* theme.
|
||||
|
||||
### Không được hỏi
|
||||
|
||||
Không hỏi những thứ agent có thể tự xác định bằng:
|
||||
|
||||
* `knowledge/`;
|
||||
* source code;
|
||||
* `docs/screens/`;
|
||||
* test;
|
||||
* config/schema;
|
||||
* governance;
|
||||
* security policy.
|
||||
|
||||
Ví dụ không được hỏi:
|
||||
|
||||
> "Widget này nằm ở file nào?"
|
||||
|
||||
nếu `knowledge/screen_map.md` và `docs/screens/controls.json` có thể xác định được.
|
||||
|
||||
### Số lượng câu hỏi
|
||||
|
||||
* Tối đa **3 câu hỏi**.
|
||||
* Gộp tất cả câu hỏi vào **một lần**.
|
||||
* Mỗi câu hỏi phải kèm phương án mặc định.
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text
|
||||
1. Expected behavior là disable button hay hiện warning?
|
||||
Mặc định: disable button.
|
||||
|
||||
2. Bug xảy ra ở Dark hay cả Light theme?
|
||||
Mặc định: kiểm tra cả hai.
|
||||
|
||||
3. Có xảy ra ở 150% display scale không?
|
||||
Mặc định: kiểm tra 100% và 150%.
|
||||
```
|
||||
|
||||
Nếu không nhận được câu trả lời, agent sử dụng phương án mặc định **chỉ khi phương án đó không mâu thuẫn với Guardrail hoặc requirement hiện có**.
|
||||
|
||||
---
|
||||
|
||||
## R4. Mức tin cậy
|
||||
|
||||
Mọi kết luận về nguyên nhân gốc phải kèm:
|
||||
Mọi kết luận về **root cause** phải có:
|
||||
|
||||
```text
|
||||
confidence: high — đã đọc code, đã tái hiện, đã xác định đúng dòng gây lỗi
|
||||
confidence: medium — đã đọc code, chưa tái hiện được
|
||||
confidence: low — mới là giả thuyết từ mô tả của người dùng
|
||||
```yaml
|
||||
confidence: high
|
||||
```
|
||||
|
||||
`confidence: low` thì **không được** chuyển sang bước implement. Quay lại triage.
|
||||
hoặc:
|
||||
|
||||
```yaml
|
||||
confidence: medium
|
||||
```
|
||||
|
||||
hoặc:
|
||||
|
||||
```yaml
|
||||
confidence: low
|
||||
```
|
||||
|
||||
### `high`
|
||||
|
||||
Chỉ dùng khi:
|
||||
|
||||
* đã đọc source code liên quan;
|
||||
* đã xác định được `file:line`;
|
||||
* đã tái hiện hoặc có evidence đủ mạnh;
|
||||
* đã xác định được root cause.
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text
|
||||
confidence: high
|
||||
|
||||
Root cause:
|
||||
presentation/shell/nav_rail.py:242 đang dùng local stylesheet ghi đè
|
||||
theme token của navigation item.
|
||||
```
|
||||
|
||||
### `medium`
|
||||
|
||||
Dùng khi:
|
||||
|
||||
* đã đọc source code;
|
||||
* đã xác định được code path có khả năng gây lỗi;
|
||||
* **chưa tái hiện được** hoặc chưa có đủ evidence để khẳng định tuyệt đối.
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text
|
||||
confidence: medium
|
||||
|
||||
Root cause hypothesis:
|
||||
theme/qss.py:318 có khả năng ghi đè rule của widget.
|
||||
Chưa tái hiện được trên runtime hiện tại.
|
||||
```
|
||||
|
||||
`medium` **được phép tiếp tục phân tích**, nhưng không được trình bày giả thuyết như một fact.
|
||||
|
||||
### `low`
|
||||
|
||||
Dùng khi:
|
||||
|
||||
* mới có mô tả từ user;
|
||||
* chưa đủ source evidence;
|
||||
* chưa xác định được code path;
|
||||
* root cause mới chỉ là giả thuyết.
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text
|
||||
confidence: low
|
||||
|
||||
Hypothesis:
|
||||
Có thể widget đang bị stylesheet override.
|
||||
Chưa đọc được source code liên quan.
|
||||
```
|
||||
|
||||
### Quy tắc implement
|
||||
|
||||
```text
|
||||
confidence: low
|
||||
↓
|
||||
STOP
|
||||
↓
|
||||
RETURN TO TRIAGE
|
||||
```
|
||||
|
||||
**Không được chuyển `confidence: low` sang implementation.**
|
||||
|
||||
`confidence: medium` cũng **không được tự coi là root cause đã xác nhận**. Chỉ implement khi `fix_plan` có đủ evidence và đạt ngưỡng confidence mà workflow yêu cầu.
|
||||
|
||||
---
|
||||
|
||||
## R5. Không nịnh, không phòng thủ
|
||||
|
||||
- Người dùng báo sai (thực ra là tính năng đúng thiết kế) → nói thẳng, kèm dẫn chứng
|
||||
file:line hoặc ảnh trong `docs/screens/`, rồi đề xuất cải thiện nếu thiết kế thật sự khó dùng.
|
||||
- Bản sửa trước đó của chính agent gây ra lỗi mới → nói rõ, sửa, không vòng vo.
|
||||
Agent phải ưu tiên **evidence** thay vì cố bảo vệ nhận định của mình.
|
||||
|
||||
### Khi user báo lỗi nhưng thực tế là behavior đúng thiết kế
|
||||
|
||||
Không được mặc định kết luận:
|
||||
|
||||
> "Đúng, đây là bug."
|
||||
|
||||
Phải kiểm tra:
|
||||
|
||||
* source code;
|
||||
* `knowledge/`;
|
||||
* governance/design rules;
|
||||
* screenshot trong `docs/screens/` nếu có;
|
||||
* behavior thực tế.
|
||||
|
||||
Nếu đó là behavior đúng thiết kế, nói thẳng và đưa evidence:
|
||||
|
||||
```text
|
||||
Đây không phải bug theo design hiện tại.
|
||||
|
||||
Evidence:
|
||||
presentation/shell/nav_rail.py:242
|
||||
docs/screens/<screen>.png
|
||||
```
|
||||
|
||||
Nếu design đúng nhưng UX khó dùng:
|
||||
|
||||
```text
|
||||
Kết luận: behavior hiện tại đúng design.
|
||||
Tuy nhiên UX có thể gây hiểu nhầm vì ...
|
||||
```
|
||||
|
||||
Đề xuất tạo **issue riêng** nếu cần thay đổi product/design.
|
||||
|
||||
Không tự sửa ngoài scope bug hiện tại.
|
||||
|
||||
### Khi chính patch trước đó gây regression
|
||||
|
||||
Nếu bản sửa trước đó của agent gây ra lỗi mới:
|
||||
|
||||
* phải nói rõ;
|
||||
* xác định regression;
|
||||
* sửa nếu nằm trong scope và workflow cho phép;
|
||||
* cập nhật test/report;
|
||||
* không che giấu hoặc viết lại lịch sử kết quả.
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text
|
||||
Regression detected:
|
||||
|
||||
fix trước tại presentation/foo.py:123 đã làm thay đổi behavior
|
||||
của widget Bar.
|
||||
|
||||
Đã bổ sung regression test tại tests/foo/test_bar.py:45
|
||||
và điều chỉnh patch để giữ behavior cũ.
|
||||
```
|
||||
|
||||
Không dùng cách diễn đạt né tránh như:
|
||||
|
||||
```text
|
||||
Có một vấn đề nhỏ phát sinh...
|
||||
```
|
||||
|
||||
khi thực tế patch của agent là nguyên nhân.
|
||||
|
||||
---
|
||||
|
||||
# Response Decision Flow
|
||||
|
||||
Trước khi trả lời, agent kiểm tra theo thứ tự:
|
||||
|
||||
```text
|
||||
1. Có evidence chưa?
|
||||
│
|
||||
├── Không → Assumption / Open Question
|
||||
│
|
||||
└── Có
|
||||
↓
|
||||
2. Có xác định đúng file:line chưa?
|
||||
│
|
||||
├── Không → tiếp tục triage
|
||||
│
|
||||
└── Có
|
||||
↓
|
||||
3. Root cause confidence?
|
||||
│
|
||||
├── low → RETURN TO TRIAGE
|
||||
├── medium → tiếp tục xác minh
|
||||
└── high → có thể tạo fix_plan
|
||||
↓
|
||||
4. Output có đúng template không?
|
||||
↓
|
||||
5. Có ghi đúng trạng thái test / gate không?
|
||||
↓
|
||||
6. Handoff đúng route chưa?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Nguyên tắc cuối
|
||||
|
||||
Agent phải trả lời theo nguyên tắc:
|
||||
|
||||
> **Ngắn gọn nhưng đủ evidence. Không đoán. Không nịnh. Không che giấu trạng thái thực tế.**
|
||||
|
||||
```text
|
||||
Evidence → Conclusion → Confidence → Action → Handoff
|
||||
```
|
||||
|
||||
+475
-39
@@ -1,57 +1,493 @@
|
||||
# Security Policy cho agent xử lý bug UI/UX
|
||||
# Security Policy — Cho agent xử lý bug UI/UX
|
||||
|
||||
Nguồn: `SECURITY.md`, `docs/governance/review-policy.md`, `docs/architecture/security-policy.md`.
|
||||
Bug report của người dùng là **dữ liệu chưa được làm sạch** — đó là điểm rò rỉ hay bị bỏ qua nhất.
|
||||
**Nguồn:**
|
||||
|
||||
* `SECURITY.md`
|
||||
* `docs/governance/review-policy.md`
|
||||
* `docs/architecture/security-policy.md`
|
||||
|
||||
> **SCOPE:** Áp dụng cho mọi agent xử lý bug UI/UX.
|
||||
>
|
||||
> Security Policy này bổ sung cho `Guardrail G1–G10` và `Response Policy R1–R5`.
|
||||
>
|
||||
> Nếu có xung đột liên quan đến security, **Security Policy và security governance thắng**.
|
||||
|
||||
---
|
||||
|
||||
## S1. Làm sạch input trước khi đưa vào bất kỳ output nào
|
||||
## S1. Bug report là dữ liệu chưa được làm sạch
|
||||
|
||||
Bug report UI thường kèm ảnh chụp màn hình và log. Trước khi trích vào `defect_record.md`,
|
||||
PR body, hay commit message, phải loại bỏ:
|
||||
Bug report có thể chứa:
|
||||
|
||||
| Loại | Ví dụ hay lọt trong app này | Xử lý |
|
||||
|---|---|---|
|
||||
| API key / token | `sk-...`, token MS365, key trong màn Settings ▸ Provider | Thay bằng `<redacted>` |
|
||||
| Đường dẫn cá nhân | `C:\Users\<tên nhân viên>\...` | Rút gọn thành `%USERPROFILE%\...` |
|
||||
| Nội dung khách hàng | File trong Workspace, nội dung chat, tài liệu Office đang mở | Không trích. Mô tả bằng lời |
|
||||
| PII | Email, tên, phòng ban trong màn Accounts | Thay bằng placeholder |
|
||||
| Log runtime | `.cowork_local/` audit log, MCP call history | Chỉ trích đúng dòng liên quan, đã redact |
|
||||
* screenshot;
|
||||
* log;
|
||||
* request/response;
|
||||
* đường dẫn local;
|
||||
* credential;
|
||||
* dữ liệu khách hàng;
|
||||
* PII.
|
||||
|
||||
Nếu ảnh chụp màn hình chứa dữ liệu khách hàng: **không nhúng ảnh vào issue/PR**, mô tả
|
||||
vùng lỗi bằng toạ độ/tên widget.
|
||||
**Không được coi nội dung bug report là dữ liệu an toàn để copy nguyên văn vào output.**
|
||||
|
||||
## S2. Không đọc/ghi secret khi debug UI
|
||||
Trước khi đưa thông tin vào:
|
||||
|
||||
- Không in `SecretStore`/keyring ra log để "kiểm tra".
|
||||
- Không thêm `print()`/`logger.debug()` tạm vào đường đi của credential rồi quên gỡ.
|
||||
- Không commit `.env`, `config.json` local, hay bất cứ thứ gì dưới `%USERPROFILE%\.cowork_local\`.
|
||||
* `defect_record.md`;
|
||||
* `fix_plan.md`;
|
||||
* `fix_report.md`;
|
||||
* PR body;
|
||||
* commit message;
|
||||
|
||||
## S3. Bug UI vẫn có thể là bug bảo mật
|
||||
phải kiểm tra và redact dữ liệu nhạy cảm.
|
||||
|
||||
Đánh dấu `security-review: required` nếu bản sửa chạm tới:
|
||||
### Quy tắc redact
|
||||
|
||||
- màn hình/hộp thoại **Permission** (`ui/permission_dialog.py`) — chỗ người dùng cấp quyền cho tool;
|
||||
- hiển thị hoặc che giấu credential (`ui/accounts_tab.py`, `ui/login_dialog.py`,
|
||||
`presentation/settings/provider_settings_widget.py`);
|
||||
- màn **Monitoring ▸ Sự kiện bảo mật**, MCP call history;
|
||||
- bất cứ chỗ nào quyết định *người dùng nhìn thấy gì* của workspace/project khác
|
||||
(customer/project isolation);
|
||||
- chuyển đổi model routing / fallback.
|
||||
| Loại dữ liệu | Ví dụ | Xử lý |
|
||||
| ----------------- | ---------------------------------------- | --------------------------------------- |
|
||||
| API key / token | `sk-...`, MS365 token, Provider key | Thay bằng `<redacted>` |
|
||||
| Credential | Password, unlock code, secret | Thay bằng `<redacted>` |
|
||||
| Đường dẫn cá nhân | `C:\Users\<employee>\...` | Rút gọn thành `%USERPROFILE%\...` |
|
||||
| Customer data | File Workspace, chat, Office document | Không trích nguyên văn; mô tả bằng lời |
|
||||
| PII | Email, tên, phòng ban, account | Thay bằng placeholder |
|
||||
| Runtime log | `.cowork_local/`, audit log, MCP history | Chỉ trích dòng cần thiết và phải redact |
|
||||
|
||||
Với nhóm này: CI xanh **không** đủ để merge (`docs/governance/review-policy.md`).
|
||||
### Screenshot
|
||||
|
||||
## S4. Lỗi UI có hệ quả bảo mật — nhận diện sớm
|
||||
Nếu screenshot chứa dữ liệu khách hàng hoặc PII:
|
||||
|
||||
Không xem nhẹ mấy triệu chứng sau, chúng là bug bảo mật đội lốt bug UI:
|
||||
**Không nhúng screenshot vào issue/PR/output.**
|
||||
|
||||
- Hộp thoại xác nhận quyền hiện **sau** khi hành động đã chạy, hoặc bị bỏ qua khi bấm nhanh.
|
||||
- Nút "Cho phép" là default button / nhận Enter — người dùng cấp quyền mà không đọc.
|
||||
- Ô mật khẩu không `QLineEdit.Password`, hoặc key hiện dạng plaintext khi resize/copy.
|
||||
- Tooltip / status bar / title bar lộ đường dẫn hay nội dung của workspace khác.
|
||||
- Toast lỗi in nguyên exception kèm request body.
|
||||
Thay bằng mô tả:
|
||||
|
||||
## S5. Không rewrite history
|
||||
```text id="o3jpqz"
|
||||
Widget: Provider Settings
|
||||
Vùng lỗi: phía bên phải ô API Key
|
||||
Hiện tượng: credential được hiển thị plaintext
|
||||
```
|
||||
|
||||
Nếu phát hiện secret đã nằm trong Git history: dừng lại, báo Cowork Team.
|
||||
Không force-push, không tự sửa history (`SECURITY.md`).
|
||||
Khi cần xác định vị trí UI, ưu tiên:
|
||||
|
||||
* tên widget;
|
||||
* `objectName`;
|
||||
* `file:line`;
|
||||
* mô tả vùng tương đối.
|
||||
|
||||
Không đưa dữ liệu thật vào artifact chỉ để minh họa.
|
||||
|
||||
---
|
||||
|
||||
## S2. Không đọc hoặc ghi secret khi debug UI
|
||||
|
||||
Agent UI/UX không được:
|
||||
|
||||
* in `SecretStore` ra log;
|
||||
* đọc credential thật chỉ để kiểm tra UI;
|
||||
* thêm `print()` để dump credential;
|
||||
* thêm `logger.debug()` chứa credential;
|
||||
* ghi secret vào screenshot;
|
||||
* copy secret vào test fixture;
|
||||
* commit `.env`;
|
||||
* commit local `config.json`;
|
||||
* commit dữ liệu dưới:
|
||||
|
||||
```text id="4sn9q8"
|
||||
%USERPROFILE%\.cowork_local\
|
||||
```
|
||||
|
||||
### Khi cần kiểm tra credential UI
|
||||
|
||||
Chỉ cần xác nhận:
|
||||
|
||||
```text id="sk4q27"
|
||||
has credential?
|
||||
masked / visible?
|
||||
empty / non-empty?
|
||||
```
|
||||
|
||||
Không cần biết giá trị thật.
|
||||
|
||||
Ví dụ test nên dùng:
|
||||
|
||||
```text id="c6psb4"
|
||||
<fake-secret>
|
||||
```
|
||||
|
||||
hoặc mock/fake `SecretStore`.
|
||||
|
||||
---
|
||||
|
||||
## S3. Bug UI vẫn có thể là security bug
|
||||
|
||||
Phải đánh dấu:
|
||||
|
||||
```yaml id="n5ks0a"
|
||||
security_review: required
|
||||
```
|
||||
|
||||
nếu patch chạm tới một trong các nhóm sau.
|
||||
|
||||
### Permission
|
||||
|
||||
* Permission dialog.
|
||||
* Permission confirmation.
|
||||
* Allow / Deny behavior.
|
||||
* Default button.
|
||||
* Keyboard shortcut có thể cấp quyền.
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text id="2amr9f"
|
||||
ui/permission_dialog.py
|
||||
```
|
||||
|
||||
### Credential
|
||||
|
||||
Các UI liên quan tới:
|
||||
|
||||
```text id="73t3s5"
|
||||
ui/accounts_tab.py
|
||||
ui/login_dialog.py
|
||||
presentation/settings/provider_settings_widget.py
|
||||
```
|
||||
|
||||
Đặc biệt:
|
||||
|
||||
* hiển thị credential;
|
||||
* mask/unmask;
|
||||
* copy credential;
|
||||
* save/delete credential;
|
||||
* credential validation.
|
||||
|
||||
### Security monitoring
|
||||
|
||||
* Monitoring → Security Events.
|
||||
* MCP call history.
|
||||
* Audit information.
|
||||
* Security-related toast/status.
|
||||
|
||||
### Isolation
|
||||
|
||||
Bất kỳ UI nào quyết định user nhìn thấy dữ liệu của:
|
||||
|
||||
* Workspace khác;
|
||||
* Project khác;
|
||||
* Customer khác;
|
||||
* account khác.
|
||||
|
||||
Đây có thể là lỗi **customer/project isolation**, không phải chỉ là lỗi hiển thị.
|
||||
|
||||
### Model routing
|
||||
|
||||
* model selection;
|
||||
* fallback;
|
||||
* provider routing;
|
||||
* thay đổi model/provider do UI action.
|
||||
|
||||
---
|
||||
|
||||
## S4. Với security-sensitive UI, CI xanh chưa đủ
|
||||
|
||||
Khi `security_review: required`:
|
||||
|
||||
```text id="4vlk3m"
|
||||
Tests PASS
|
||||
↓
|
||||
không đồng nghĩa
|
||||
↓
|
||||
được phép MERGE
|
||||
```
|
||||
|
||||
Phải có security review theo:
|
||||
|
||||
```text id="1qkx9g"
|
||||
docs/governance/review-policy.md
|
||||
```
|
||||
|
||||
Agent không được tự kết luận:
|
||||
|
||||
> "Test đã pass nên security risk không còn."
|
||||
|
||||
---
|
||||
|
||||
## S5. Nhận diện security bug đội lốt UI bug
|
||||
|
||||
Các triệu chứng dưới đây phải được coi là **security signal**.
|
||||
|
||||
### Permission timing
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```text id="s5vq4y"
|
||||
Action chạy
|
||||
↓
|
||||
Permission dialog xuất hiện
|
||||
```
|
||||
|
||||
thay vì:
|
||||
|
||||
```text id="d9skx4u"
|
||||
Permission dialog
|
||||
↓
|
||||
User xác nhận
|
||||
↓
|
||||
Action chạy
|
||||
```
|
||||
|
||||
Đặc biệt nguy hiểm nếu action có thể chạy khi user:
|
||||
|
||||
* bấm nhanh;
|
||||
* double-click;
|
||||
* nhấn Enter;
|
||||
* dialog chưa hiển thị hoàn chỉnh.
|
||||
|
||||
### Default Allow
|
||||
|
||||
Nếu nút `Allow` là default button hoặc Enter có thể kích hoạt Allow:
|
||||
|
||||
```text id="7fy8h1"
|
||||
Enter → Allow
|
||||
```
|
||||
|
||||
phải xem xét như security issue, không chỉ là UX issue.
|
||||
|
||||
### Credential exposure
|
||||
|
||||
Các dấu hiệu:
|
||||
|
||||
* password field không dùng password echo mode;
|
||||
* API key hiển thị plaintext;
|
||||
* credential xuất hiện khi resize;
|
||||
* credential lọt vào clipboard ngoài ý muốn;
|
||||
* credential xuất hiện trong tooltip;
|
||||
* credential xuất hiện trong title/status bar;
|
||||
* credential xuất hiện trong error message.
|
||||
|
||||
### Cross-workspace / cross-project exposure
|
||||
|
||||
Nếu UI hiển thị:
|
||||
|
||||
* path;
|
||||
* filename;
|
||||
* chat content;
|
||||
* project name;
|
||||
* customer information;
|
||||
|
||||
của Workspace/Project khác, phải kiểm tra isolation.
|
||||
|
||||
### Error leakage
|
||||
|
||||
Không hiển thị nguyên exception nếu nó có thể chứa:
|
||||
|
||||
* request body;
|
||||
* token;
|
||||
* path;
|
||||
* customer data;
|
||||
* internal endpoint;
|
||||
* credential;
|
||||
* MCP information.
|
||||
|
||||
Ví dụ nguy hiểm:
|
||||
|
||||
```text id="l1mrxq"
|
||||
Toast:
|
||||
Request failed: POST /api/... body={"token":"..."}
|
||||
```
|
||||
|
||||
Phải redact và hiển thị thông báo an toàn cho user.
|
||||
|
||||
---
|
||||
|
||||
## S6. Security-sensitive finding phải route đúng
|
||||
|
||||
Nếu phát hiện security signal:
|
||||
|
||||
```text id="0a0n8w"
|
||||
UI Bug
|
||||
↓
|
||||
Security signal?
|
||||
├── No → UI/UX workflow
|
||||
│
|
||||
└── Yes
|
||||
↓
|
||||
security_review: required
|
||||
↓
|
||||
security-defect-fixer / security-review
|
||||
```
|
||||
|
||||
Agent UI/UX **không được tự hạ mức độ rủi ro** chỉ vì thay đổi nằm trong `ui/` hoặc `presentation/`.
|
||||
|
||||
Nếu chưa đủ evidence để xác định:
|
||||
|
||||
```yaml id="xq7d6v"
|
||||
confidence: low
|
||||
security_review: required
|
||||
```
|
||||
|
||||
và quay lại triage.
|
||||
|
||||
---
|
||||
|
||||
## S7. Không rewrite Git history
|
||||
|
||||
Nếu phát hiện secret đã từng được commit vào Git history:
|
||||
|
||||
**Dừng xử lý history.**
|
||||
|
||||
Phải:
|
||||
|
||||
1. báo Cowork Team;
|
||||
2. xác định credential nào có khả năng bị lộ;
|
||||
3. đề xuất rotation/revocation theo security policy;
|
||||
4. giữ nguyên evidence cần thiết để team xử lý.
|
||||
|
||||
Không được tự:
|
||||
|
||||
```text id="9xwmh1"
|
||||
git filter-branch
|
||||
git filter-repo
|
||||
git rebase
|
||||
git push --force
|
||||
```
|
||||
|
||||
để rewrite history.
|
||||
|
||||
Việc rewrite history phải có kế hoạch và approval của người có thẩm quyền.
|
||||
|
||||
---
|
||||
|
||||
## S8. Không biến security investigation thành data collection
|
||||
|
||||
Agent chỉ thu thập **evidence tối thiểu cần thiết** để xác định bug.
|
||||
|
||||
Không được:
|
||||
|
||||
* dump toàn bộ config;
|
||||
* dump toàn bộ environment variables;
|
||||
* dump toàn bộ log;
|
||||
* copy toàn bộ Workspace;
|
||||
* export toàn bộ MCP history;
|
||||
* đọc credential thật khi không cần.
|
||||
|
||||
Nguyên tắc:
|
||||
|
||||
> **Collect the minimum evidence necessary to prove the defect.**
|
||||
|
||||
Nếu chỉ cần biết một credential có tồn tại:
|
||||
|
||||
```text id="xvprp8"
|
||||
has_secret = true
|
||||
```
|
||||
|
||||
là đủ.
|
||||
|
||||
Không cần biết:
|
||||
|
||||
```text id="k3uw5w"
|
||||
secret_value = "..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Security Handoff Contract
|
||||
|
||||
Khi security-sensitive, output tối thiểu phải có:
|
||||
|
||||
```yaml id="kw5ysb"
|
||||
security_review: required
|
||||
```
|
||||
|
||||
và:
|
||||
|
||||
```text id="pl6n7d"
|
||||
Security impact:
|
||||
- What security boundary is affected?
|
||||
- What data/permission/credential is involved?
|
||||
- Is customer/project isolation affected?
|
||||
- Is additional security review required?
|
||||
```
|
||||
|
||||
Nếu chưa có đủ thông tin:
|
||||
|
||||
```text id="xqk2uj"
|
||||
Open Question:
|
||||
- ...
|
||||
```
|
||||
|
||||
Nếu cần Cowork Team quyết định policy:
|
||||
|
||||
```text id="k5j3vw"
|
||||
Handoff:
|
||||
RETURN_TO_REPORTER
|
||||
Reason:
|
||||
needs-security-decision
|
||||
```
|
||||
|
||||
Nếu đã đủ evidence và có thể tạo implementation plan:
|
||||
|
||||
```text id="8d5g6h"
|
||||
Handoff:
|
||||
fix-implementer
|
||||
|
||||
security_review:
|
||||
required
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Security Decision Flow
|
||||
|
||||
```text id="j2qz1k"
|
||||
Bug Report
|
||||
↓
|
||||
Redact Input
|
||||
↓
|
||||
Triage UI/UX
|
||||
↓
|
||||
Security Signal?
|
||||
│
|
||||
├── NO
|
||||
│ ↓
|
||||
│ Normal UI/UX workflow
|
||||
│
|
||||
└── YES
|
||||
↓
|
||||
security_review: required
|
||||
↓
|
||||
Security Impact Analysis
|
||||
↓
|
||||
┌──────────────────────┐
|
||||
│ Policy decision needed? │
|
||||
└──────────────────────┘
|
||||
│
|
||||
YES ─────→ RETURN_TO_REPORTER
|
||||
│
|
||||
NO
|
||||
↓
|
||||
Security Review
|
||||
↓
|
||||
fix-implementer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Nguyên tắc cuối
|
||||
|
||||
> **UI không phải security boundary thấp hơn security.**
|
||||
>
|
||||
> Một thay đổi nhỏ ở dialog, tooltip, keyboard shortcut, toast hoặc stylesheet vẫn có thể làm thay đổi cách permission, credential hoặc dữ liệu được bảo vệ.
|
||||
|
||||
Vì vậy:
|
||||
|
||||
```text id="s5gh1v"
|
||||
Redact first
|
||||
↓
|
||||
Collect minimum evidence
|
||||
↓
|
||||
Detect security boundary
|
||||
↓
|
||||
Mark security_review
|
||||
↓
|
||||
Route correctly
|
||||
↓
|
||||
Never expose secrets
|
||||
↓
|
||||
Never rewrite history
|
||||
```
|
||||
|
||||
@@ -8,6 +8,7 @@ Mọi agent kết thúc lượt bằng khối YAML này, đặt **ngay trên** p
|
||||
defect_id: UI-2026-0907-01 # UI-<YYYYMMDD>-<số thứ tự trong ngày>
|
||||
from_agent: ui-bug-triage
|
||||
next_agent: ui-visual-fixer # xem bảng giá trị hợp lệ bên dưới
|
||||
tier: T2 # T0 | T1 | T2 | T3 | T3-SEC — do fix-dispatcher chấm
|
||||
category: visual # visual | flow | i18n-a11y | security | not-ui
|
||||
severity: S2 # S1 | S2 | S3 | S4
|
||||
confidence: high # low | medium | high
|
||||
@@ -26,6 +27,7 @@ blocked_on: [] # danh sách open question CHẶN bước ti
|
||||
|
||||
| Giá trị | Nghĩa |
|
||||
|---|---|
|
||||
| `fix-dispatcher` | Escalate về hub: vượt phạm vi tier hiện tại, cần chấm lại |
|
||||
| `ui-visual-fixer` / `ux-flow-fixer` / `i18n-a11y-fixer` | Route sang specialist UI |
|
||||
| `security-defect-fixer` | Route sang specialist bảo mật (`category: security`) |
|
||||
| `fix-implementer` | Plan đã sẵn sàng để hiện thực |
|
||||
@@ -50,3 +52,11 @@ blocked_on: [] # danh sách open question CHẶN bước ti
|
||||
`next_agent: security-defect-fixer`; phần UI tách thành `defect_id` riêng, xử lý sau.
|
||||
9. `blocked_on` của role 7 có thể chứa câu hỏi **chính sách** (`needs-security-decision`).
|
||||
Đó là chờ hợp lệ — người trả lời là Cowork Team, không phải agent khác.
|
||||
10. **`tier` chỉ đi lên.** Không agent nào được hạ `tier` trong envelope nhận được. Thấy
|
||||
việc lớn hơn tier đang mang → đặt `next_agent: fix-dispatcher`, ghi lý do vào
|
||||
`blocked_on`, dừng. Hub là chỗ duy nhất được ghi `tier`.
|
||||
11. `tier: T0` mà `next_agent` khác `HUMAN_REVIEW` là mâu thuẫn: T0 không gọi agent nào.
|
||||
`tier: T3-SEC` thì `security_review` **luôn** là `required`.
|
||||
12. `report_id` (nếu có) gom các `defect_id` tách ra từ **cùng một** phản ánh. Nó chỉ để
|
||||
truy vết ngược về người báo lỗi; không dùng nó để gộp PR — một PR vẫn là một
|
||||
`defect_id` (`guardrail.md` G8).
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
# Workflow — từ phản ánh của người dùng tới PR
|
||||
|
||||
## 1. Pipeline
|
||||
## 0. Lane theo tier — đọc trước
|
||||
|
||||
Pipeline dưới đây là **lane FULL (T3)**, không phải mặc định. `0_fix_dispatcher` chấm tier
|
||||
trước và cắt bớt bước:
|
||||
|
||||
| Tier | Lane | Bước thực chạy | Gọi agent |
|
||||
|---|---|---|---|
|
||||
| **T0** | DIRECT | hub sửa → 4 cổng máy (`roles/0_fix_dispatcher.md` §4.1) | 0 |
|
||||
| **T1** | SOLO | hub triage inline → **5** → hub review bằng `checklist/ui_review.md` | 1 |
|
||||
| **T2** | PAIR | hub triage inline → **2/3/4** → **5** → **6** | 3 |
|
||||
| **T3** | FULL | **1** → **2/3/4** → **5** → **6** | 4–5 |
|
||||
| **T3-SEC** | FULL-SEC | **7** → *(Cowork Team)* → **5** → **6** | 3 + chờ người |
|
||||
|
||||
Bỏ bước nào cũng phải **nêu rõ trong `dispatch_plan`** cổng nào thay thế. Bước **6** chỉ
|
||||
được bỏ ở T0 và T1.
|
||||
|
||||
## 1. Pipeline (lane FULL)
|
||||
|
||||
```text
|
||||
Người dùng báo lỗi (chat / issue / miệng)
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────┐
|
||||
│ 0. fix-dispatcher HUB │ → dispatch_plan.md
|
||||
│ Router │ + tách N defect_id + tier + lane
|
||||
└───────────┬───────────────┘
|
||||
│ T0 → hub tự sửa, KHÔNG đi tiếp
|
||||
│ T1 → nhảy thẳng xuống bước 5
|
||||
│ T2 → nhảy thẳng xuống bước 2/3/4
|
||||
│ T3 → đi tiếp bước 1
|
||||
▼
|
||||
┌───────────────────────────┐
|
||||
│ 1. ui-bug-triage │ → defect_record.md
|
||||
│ Planner │ + category + severity + confidence
|
||||
└───────────┬───────────────┘
|
||||
@@ -39,6 +64,7 @@
|
||||
|
||||
| Agent | Đọc | Sửa file | Chạy lệnh | Quyết định |
|
||||
|---|---|---|---|---|
|
||||
| 0. dispatcher | ✅ | ✅ **chỉ ở T0** | ✅ (grep, gate) | tier + lane + tách defect |
|
||||
| 1. triage | ✅ | ❌ | ✅ (grep, tra manifest) | phân loại + route |
|
||||
| 2/3/4. specialist | ✅ | ❌ | ✅ (đọc, kiểm LOC) | nguyên nhân gốc + phương án |
|
||||
| 7. security | ✅ | ❌ | ✅ (đọc, `git log -S`) | lỗ hổng + migration; **không** quyết chính sách |
|
||||
@@ -48,12 +74,19 @@
|
||||
|
||||
Chỉ **một** agent được sửa file. Ranh giới này là thứ giữ cho pipeline review được.
|
||||
|
||||
Ngoại lệ duy nhất là hub ở **T0**, và nó bị bó rất chặt để đổi lại: danh sách đóng 6 loại
|
||||
thay đổi, 9 disqualifier, trần ≤ 2 file / ≤ 10 dòng, và 4 cổng máy bắt buộc dán output thật.
|
||||
Vượt bất kỳ ràng buộc nào → `git checkout --` rồi chấm lại T2. Hub **không** được sửa file ở
|
||||
T1/T2/T3 — ở đó nó chỉ điều phối và (ở T1) review, vì reviewer không được là người viết patch.
|
||||
|
||||
## 3. Cổng chuyển bước
|
||||
|
||||
Không bước nào được đi tiếp nếu chưa đạt:
|
||||
|
||||
| Từ → Đến | Điều kiện |
|
||||
|---|---|
|
||||
| 0 → bất kỳ | Mỗi defect_id có đúng 1 tier + 1 lane, tier ≠ T0 dẫn được về một dòng cụ thể của Bước 3, đã xét override bảo mật trước |
|
||||
| 0 → tự sửa (T0) | Trúng danh sách đóng, 0 disqualifier, Gate S + blast radius đã **đo bằng lệnh** |
|
||||
| 1 → 2/3/4 | `confidence >= medium`, có ít nhất một `file:line`, đã redact |
|
||||
| 2/3/4 → 5 | Đúng **một** nguyên nhân gốc, có cách kiểm chứng, không vượt 400 LOC (hoặc đã có kế hoạch tách) |
|
||||
| 7 → 5 | Như trên, **cộng thêm**: có đường di trú cho cả 4 nhóm người dùng, và 4 câu chính sách đã có đáp án của Cowork Team |
|
||||
@@ -65,27 +98,49 @@ Không bước nào được đi tiếp nếu chưa đạt:
|
||||
## 4. Vòng lặp và giới hạn
|
||||
|
||||
- FAIL ở bước 6 → về bước 5 (lỗi hiện thực) hoặc về 2/3/4 (sai nguyên nhân gốc).
|
||||
- **Tier +1 mỗi lần FAIL.** Chạy lại ở nguyên tier cũ là lỗi điều phối: hai lần thất bại ở
|
||||
cùng độ sâu gần như luôn có nghĩa là hồ sơ lỗi sai từ đầu.
|
||||
- Tier chỉ đi **lên**. Không có đường hạ tier giữa dòng, kể cả khi diff hoá ra nhỏ.
|
||||
- Quá **2 vòng** mà vẫn FAIL → dừng, đưa người thật vào. Vòng thứ ba thường có nghĩa là
|
||||
`defect_record` sai từ đầu, không phải bản vá sai.
|
||||
|
||||
## 5. Đường tắt hợp lệ
|
||||
|
||||
| Tình huống | Đường tắt |
|
||||
|---|---|
|
||||
| Lỗi chính tả một chuỗi, đã biết chính xác key | 1 → 4 → 5 → 6, bỏ giai đoạn điều tra ở bước 4 |
|
||||
| Thiếu key i18n, UI hiện ra `a.b_c` | 1 → 4 → 5 → 6 |
|
||||
| Lỗi do chính bản vá vừa merge | về thẳng 5 nếu nguyên nhân gốc chưa đổi |
|
||||
| Dev báo thẳng một lỗ hổng, không qua triệu chứng giao diện | vào thẳng 7, bỏ bước 1 |
|
||||
Đây là các đường tắt hub được phép chọn ở Bước 3. Chúng **thay thế** phần "đường tắt" của
|
||||
bộ v1.2 — trước đây tự phát, giờ có tier và có cổng bù.
|
||||
|
||||
Không có đường tắt nào bỏ qua bước **6**.
|
||||
| Tình huống | Tier | Đường tắt |
|
||||
|---|---|---|
|
||||
| Nới một số đo hiển thị (px, margin, spacing) | T0 | hub sửa, 0 agent |
|
||||
| Sai chính tả / sai dấu một chuỗi đã có key | T0 | hub sửa, đủ 3 ngôn ngữ, **vẫn phải có test** |
|
||||
| Đổi token màu có sẵn sang token có sẵn | T0 | hub sửa, 0 agent |
|
||||
| Thiếu key i18n, UI hiện ra `a.b_c`, đã biết file | T1 | 5 → hub review |
|
||||
| Nguyên nhân gốc đã có `file:line` từ người báo (dev) | T1 | 5 → hub review |
|
||||
| Chạm QSS/token dùng chung, phải kiểm 2 theme | T2 | 4 (hoặc 2) → 5 → 6 |
|
||||
| Lỗi do chính bản vá vừa merge | T3 | đủ pipeline — regression nghĩa là nguyên nhân gốc lần trước sai |
|
||||
| Dev báo thẳng một lỗ hổng | T3-SEC | vào thẳng 7, bỏ bước 1 |
|
||||
|
||||
Bước **6** chỉ được bỏ ở T0 và T1. Ở T0 nó được thay bằng 4 cổng máy; ở T1 nó được thay bằng
|
||||
hub review với `checklist/ui_review.md` (hợp lệ vì hub không viết patch ở T1). Ở T2/T3/T3-SEC
|
||||
không có đường tắt nào bỏ qua bước 6.
|
||||
|
||||
## 6. Chạy bằng Claude Code
|
||||
|
||||
```bash
|
||||
mkdir -p .claude/agents && cp agent/roles/*.md .claude/agents/
|
||||
mkdir -p .claude/agents .claude/commands
|
||||
cp agent/roles/[1-7]_*.md .claude/agents/
|
||||
cp agent/commands/fix.md .claude/commands/
|
||||
```
|
||||
|
||||
Rồi lần lượt:
|
||||
`.claude/` nằm trong `.gitignore` (dòng 109) nên phải cài lại trên mỗi clone — `agent/`
|
||||
là bản gốc. `0_fix_dispatcher.md` không copy sang `agents/`: hub chạy ở session chính vì
|
||||
subagent không gọi được subagent. Điểm vào:
|
||||
|
||||
```text
|
||||
> /fix màn Folder kéo to ra thì mất cây thư mục bên trái
|
||||
```
|
||||
|
||||
Hub in `dispatch_plan` rồi tự chạy lane. Muốn chạy tay lane FULL:
|
||||
|
||||
```text
|
||||
> dùng ui-bug-triage cho phản ánh này: "màn Folder kéo to ra thì mất cây thư mục bên trái"
|
||||
@@ -94,4 +149,5 @@ Rồi lần lượt:
|
||||
> dùng regression-reviewer với patch vừa rồi
|
||||
```
|
||||
|
||||
Chạy tuần tự, không song song — mỗi bước phụ thuộc output của bước trước.
|
||||
Các bước trong **một** `defect_id` chạy tuần tự — mỗi bước phụ thuộc output của bước trước.
|
||||
Các `defect_id` **độc lập** thì chạy song song được, gọi trong cùng một message.
|
||||
|
||||
+95
-2
@@ -13,10 +13,19 @@ again every time the language changes. Transient dialogs (Settings, Skills,
|
||||
Flow, Permission...) are rebuilt from scratch each time they are opened, so
|
||||
they simply call ``tr()`` while constructing their widgets and need no
|
||||
registration.
|
||||
|
||||
``setText(tr("k"))`` on its own is only correct for the instant it runs, and a
|
||||
screen with dozens of such one-shot calls is where "I picked English and half
|
||||
the screen is still Vietnamese" comes from. The :func:`bind_text` family
|
||||
attaches the key to the widget instead, so every future language change
|
||||
re-applies it — one line per widget, and nothing to remember in a separate
|
||||
``retranslate`` method. Bindings hold the widget WEAKLY, so they are safe for
|
||||
widgets that get rebuilt constantly (Kanban rows, calendar cells).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Dict, List
|
||||
import weakref
|
||||
from typing import Any, Callable, Dict, Iterable, List, Tuple
|
||||
|
||||
LANGUAGES: Dict[str, str] = {"en": "English", "ja": "日本語", "vi": "Tiếng Việt"}
|
||||
# Short codes shown in the compact top-bar switcher (Settings keeps the full names above).
|
||||
@@ -25,6 +34,8 @@ DEFAULT_LANGUAGE = "vi"
|
||||
|
||||
_current = DEFAULT_LANGUAGE
|
||||
_listeners: List[Callable[[], None]] = []
|
||||
#: (weak ref to the widget, how to re-apply its text) — see :func:`bind_text`.
|
||||
_bindings: List[Tuple["weakref.ref", Callable[[Any], None]]] = []
|
||||
|
||||
# key -> {"en": ..., "ja": ..., "vi": ...}
|
||||
from . import login_dialog as _login_dialog
|
||||
@@ -62,6 +73,7 @@ def set_language(lang: str) -> None:
|
||||
if lang == _current:
|
||||
return
|
||||
_current = lang
|
||||
_apply_bindings()
|
||||
for fn in list(_listeners):
|
||||
try:
|
||||
fn()
|
||||
@@ -96,6 +108,87 @@ def on_language_changed(fn: Callable[[], None]) -> None:
|
||||
"""Register a callback that re-applies translations to a persistent widget.
|
||||
|
||||
Called once immediately (to apply the current language) and again on every
|
||||
future call to :func:`set_language`."""
|
||||
future call to :func:`set_language`. For a single widget whose text is one
|
||||
key, prefer :func:`bind_text` and friends — they need no callback of their
|
||||
own and cannot keep a destroyed widget alive."""
|
||||
_listeners.append(fn)
|
||||
fn()
|
||||
|
||||
|
||||
# ---- per-widget bindings -------------------------------------------------
|
||||
|
||||
def _bind(widget: Any, apply: Callable[[Any], None]) -> Any:
|
||||
"""Attach a text re-application to one widget, run it now, return the widget.
|
||||
|
||||
``apply`` takes the widget as its argument rather than closing over it: a
|
||||
closure would keep the widget alive for the life of the process, which is
|
||||
exactly what the weak reference here exists to avoid.
|
||||
|
||||
The widget comes back out so a call site can bind IN PLACE of the one-shot
|
||||
call it replaces — ``bind_text(QLabel(), k)`` where ``QLabel(tr(k))`` was —
|
||||
without spending a line, which several screens here cannot afford (Gate S).
|
||||
"""
|
||||
_bindings.append((weakref.ref(widget), apply))
|
||||
apply(widget)
|
||||
return widget
|
||||
|
||||
|
||||
def _apply_bindings() -> None:
|
||||
"""Re-apply every live binding; drop the ones whose widget is gone.
|
||||
|
||||
Both halves of "gone" are handled: the Python wrapper collected (the weak
|
||||
ref answers None) and the C++ object deleted underneath a live wrapper
|
||||
(``RuntimeError``). Neither may stop the remaining widgets from updating.
|
||||
"""
|
||||
alive: List[Tuple["weakref.ref", Callable[[Any], None]]] = []
|
||||
for ref, apply in _bindings:
|
||||
widget = ref()
|
||||
if widget is None:
|
||||
continue
|
||||
try:
|
||||
apply(widget)
|
||||
except RuntimeError:
|
||||
continue
|
||||
alive.append((ref, apply))
|
||||
_bindings[:] = alive
|
||||
|
||||
|
||||
def bind_text(widget: Any, key: str, **kwargs) -> Any:
|
||||
"""Keep ``widget``'s label on ``key`` through every language change."""
|
||||
return _bind(widget, lambda w: w.setText(tr(key, **kwargs)))
|
||||
|
||||
|
||||
def bind_tip(widget: Any, key: str, **kwargs) -> Any:
|
||||
"""Keep ``widget``'s tooltip on ``key`` through every language change."""
|
||||
return _bind(widget, lambda w: w.setToolTip(tr(key, **kwargs)))
|
||||
|
||||
|
||||
def bind_placeholder(widget: Any, key: str, **kwargs) -> Any:
|
||||
"""Keep an input's placeholder on ``key`` through every language change."""
|
||||
return _bind(widget, lambda w: w.setPlaceholderText(tr(key, **kwargs)))
|
||||
|
||||
|
||||
def bind_items(widget: Any, keys: Iterable[str]) -> Any:
|
||||
"""Keep a combo's item LABELS on ``keys``, by position.
|
||||
|
||||
``setItemText`` on purpose: clearing and re-adding the items would drop the
|
||||
per-item data every caller persists (routing mode, task type) and reset the
|
||||
current selection as a side effect of a translation.
|
||||
"""
|
||||
keys = list(keys)
|
||||
|
||||
def _apply(w: Any) -> None:
|
||||
"""Re-label each item that still exists, leaving its data alone."""
|
||||
for i, key in enumerate(keys[:w.count()]):
|
||||
w.setItemText(i, tr(key))
|
||||
|
||||
return _bind(widget, _apply)
|
||||
|
||||
|
||||
def bind_dynamic(widget: Any, apply: Callable[[], None]) -> Any:
|
||||
"""Bind text that is not one plain key — a count, a name, a joined list.
|
||||
|
||||
``apply`` takes no argument and re-reads whatever it needs itself; the
|
||||
widget is still what decides how long the binding lives.
|
||||
"""
|
||||
return _bind(widget, lambda _w: apply())
|
||||
|
||||
@@ -158,7 +158,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"vi": "Không tìm thấy agent '{name}'."},
|
||||
|
||||
# ---- agents_admin_tab.py — Admin-only agent catalog -------------------
|
||||
"agents_admin.page_title": {"en": "Agents Admin", "ja": "Agents Admin", "vi": "Agents Admin"},
|
||||
"agents_admin.page_title": {"en": "Agents Admin", "ja": "エージェント管理", "vi": "Agents Admin"},
|
||||
"agents_admin.edit_row_tooltip": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
|
||||
"agents_admin.delete_row_tooltip": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
|
||||
"agents_admin.hint": {
|
||||
@@ -179,7 +179,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"en": "Extra instructions this agent always follows (optional)…",
|
||||
"ja": "このエージェントが常に従う追加指示(任意)…",
|
||||
"vi": "Chỉ dẫn bổ sung agent này luôn tuân theo (tùy chọn)…"},
|
||||
"agents_admin.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Provider"},
|
||||
"agents_admin.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Nhà cung cấp"},
|
||||
"agents_admin.provider_default": {
|
||||
"en": "(machine's active provider)", "ja": "(各マシンの現在のプロバイダー)",
|
||||
"vi": "(provider hiện tại của máy)"},
|
||||
|
||||
+16
-16
@@ -168,8 +168,8 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"composer.manage_skills": {"en": "Manage skills…", "ja": "スキルを管理…", "vi": "Quản lý skill…"},
|
||||
|
||||
# ---- schedule_task_tab.py / task_editor_dialog.py -------------------
|
||||
"schedtask.title": {"en": "Schedule Task", "ja": "Schedule Task", "vi": "Schedule Task"},
|
||||
"schedtask.view.kanban": {"en": "Kanban", "ja": "Kanban", "vi": "Kanban"},
|
||||
"schedtask.title": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"},
|
||||
"schedtask.view.kanban": {"en": "Kanban", "ja": "カンバン", "vi": "Kanban"},
|
||||
"schedtask.view.calendar": {"en": "Calendar", "ja": "カレンダー", "vi": "Lịch"},
|
||||
"schedtask.no_title": {"en": "(untitled)", "ja": "(無題)", "vi": "(chưa có tên)"},
|
||||
"schedtask.cal_today": {"en": "Today", "ja": "今日", "vi": "Hôm nay"},
|
||||
@@ -195,23 +195,23 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"en": "Describe what you want in natural language — AI proposes tasks/schedule/chain, you confirm before anything is created.",
|
||||
"ja": "自然文で説明すると、AIがタスク・スケジュール・チェーンを提案します。確認後に作成されます。",
|
||||
"vi": "Mô tả bằng ngôn ngữ tự nhiên — AI đề xuất task/lịch/chuỗi, bạn xác nhận rồi mới tạo."},
|
||||
"schedtask.no_tasks": {"en": "No tasks", "ja": "タスクなし", "vi": "No tasks"},
|
||||
"schedtask.no_tasks": {"en": "No tasks", "ja": "タスクなし", "vi": "Chưa có task"},
|
||||
"schedtask.no_schedule": {"en": "No schedule", "ja": "スケジュールなし", "vi": "Chưa đặt lịch"},
|
||||
"schedtask.last_success": {"en": "Last: Success", "ja": "前回: 成功", "vi": "Lần cuối: Thành công"},
|
||||
"schedtask.last_failed": {"en": "Last: Failed", "ja": "前回: 失敗", "vi": "Lần cuối: Lỗi"},
|
||||
"schedtask.last_never": {"en": "Last: not run", "ja": "前回: 未実行", "vi": "Lần cuối: chưa chạy"},
|
||||
"schedtask.status.backlog": {"en": "Backlog", "ja": "Backlog", "vi": "Backlog"},
|
||||
"schedtask.status.scheduled": {"en": "Scheduled", "ja": "Scheduled", "vi": "Scheduled"},
|
||||
"schedtask.status.running": {"en": "Running", "ja": "Running", "vi": "Running"},
|
||||
"schedtask.status.waiting_input": {"en": "Waiting Input", "ja": "Waiting Input", "vi": "Waiting Input"},
|
||||
"schedtask.status.done": {"en": "Done", "ja": "Done", "vi": "Done"},
|
||||
"schedtask.status.failed": {"en": "Failed", "ja": "Failed", "vi": "Failed"},
|
||||
"schedtask.status.paused": {"en": "Paused", "ja": "Paused", "vi": "Paused"},
|
||||
"schedtask.status.backlog": {"en": "Backlog", "ja": "バックログ", "vi": "Chờ xử lý"},
|
||||
"schedtask.status.scheduled": {"en": "Scheduled", "ja": "予約済み", "vi": "Đã lên lịch"},
|
||||
"schedtask.status.running": {"en": "Running", "ja": "実行中", "vi": "Đang chạy"},
|
||||
"schedtask.status.waiting_input": {"en": "Waiting Input", "ja": "入力待ち", "vi": "Chờ nhập"},
|
||||
"schedtask.status.done": {"en": "Done", "ja": "完了", "vi": "Hoàn thành"},
|
||||
"schedtask.status.failed": {"en": "Failed", "ja": "失敗", "vi": "Thất bại"},
|
||||
"schedtask.status.paused": {"en": "Paused", "ja": "一時停止", "vi": "Tạm dừng"},
|
||||
"schedtask.type.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
|
||||
"schedtask.type.co4e_code": {"en": "Code", "ja": "Code", "vi": "Code"},
|
||||
"schedtask.type.flow": {"en": "Flow", "ja": "Flow", "vi": "Flow"},
|
||||
"schedtask.type.script": {"en": "Script", "ja": "Script", "vi": "Script"},
|
||||
"schedtask.type.manual": {"en": "Manual", "ja": "Manual", "vi": "Manual"},
|
||||
"schedtask.type.flow": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
|
||||
"schedtask.type.script": {"en": "Script", "ja": "スクリプト", "vi": "Script"},
|
||||
"schedtask.type.manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"},
|
||||
"schedtask.priority.low": {"en": "Low", "ja": "低", "vi": "Thấp"},
|
||||
"schedtask.priority.medium": {"en": "Medium", "ja": "中", "vi": "Trung bình"},
|
||||
"schedtask.priority.high": {"en": "High", "ja": "高", "vi": "Cao"},
|
||||
@@ -229,7 +229,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"vi": "Double-click một dòng để mở thư mục artifact của lần chạy đó."},
|
||||
"schedtask.hist_col_time": {"en": "Finished at", "ja": "完了時刻", "vi": "Hoàn thành lúc"},
|
||||
"schedtask.hist_col_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"},
|
||||
"schedtask.hist_col_run": {"en": "Run ID", "ja": "実行ID", "vi": "Run ID"},
|
||||
"schedtask.hist_col_run": {"en": "Run ID", "ja": "実行ID", "vi": "Mã lần chạy"},
|
||||
"schedtask.hist_col_error": {"en": "Error", "ja": "エラー", "vi": "Lỗi"},
|
||||
"schedtask.menu_create_next": {
|
||||
"en": "Create next task from output", "ja": "出力から次タスクを作成",
|
||||
@@ -261,7 +261,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"schedtask.no_workspace": {"en": "— No workspace —", "ja": "— ワークスペースなし —", "vi": "— Không có workspace —"},
|
||||
"schedtask.f_agent": {"en": "Agent", "ja": "エージェント", "vi": "Agent"},
|
||||
"schedtask.no_agent": {"en": "— No agent preset —", "ja": "— エージェントなし —", "vi": "— Không dùng agent —"},
|
||||
"schedtask.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Provider"},
|
||||
"schedtask.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Nhà cung cấp"},
|
||||
"schedtask.provider_default": {
|
||||
"en": "— Default (Settings) —", "ja": "— 既定(設定)—", "vi": "— Mặc định (Settings) —"},
|
||||
"schedtask.f_model": {"en": "Model", "ja": "モデル", "vi": "Model"},
|
||||
@@ -317,7 +317,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"schedtask.repeat.daily": {"en": "Daily", "ja": "毎日", "vi": "Hằng ngày"},
|
||||
"schedtask.repeat.weekly": {"en": "Weekly", "ja": "毎週", "vi": "Hằng tuần"},
|
||||
"schedtask.repeat.monthly": {"en": "Monthly", "ja": "毎月", "vi": "Hằng tháng"},
|
||||
"schedtask.repeat.cron": {"en": "Cron expression", "ja": "Cron式", "vi": "Cron expression"},
|
||||
"schedtask.repeat.cron": {"en": "Cron expression", "ja": "Cron式", "vi": "Biểu thức cron"},
|
||||
"schedtask.f_task_mode": {"en": "Task type", "ja": "タスク種別", "vi": "Loại task"},
|
||||
# Run kind: an AI agent vs a saved Co4E flow + multi-format import
|
||||
"schedtask.f_run_kind": {"en": "Run", "ja": "実行対象", "vi": "Chạy"},
|
||||
|
||||
+2
-2
@@ -93,7 +93,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"en": "Enable the predefined Req→Demo flow feature (off by default).",
|
||||
"ja": "定義済みの Req→Demo フロー機能を有効化(初期値はオフ)。",
|
||||
"vi": "Bật tính năng Flow Req→Demo dựng sẵn (mặc định tắt)."},
|
||||
"code.flow_btn": {"en": "Flow Management", "ja": "Flow Management", "vi": "Flow Management"},
|
||||
"code.flow_btn": {"en": "Flow Management", "ja": "フロー管理", "vi": "Flow Management"},
|
||||
"code.flow_btn_tooltip": {
|
||||
"en": "Build and run a multi-stage flow from requirement to demo.",
|
||||
"ja": "要件からデモまでの多段フローを作成・実行します。",
|
||||
@@ -152,7 +152,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
# because until the index existed nothing had to refer to it.
|
||||
"settings.group.general": {"en": "General", "ja": "一般", "vi": "Chung"},
|
||||
"settings.group.provider": {"en": "AI Provider", "ja": "AI プロバイダー", "vi": "Nhà cung cấp AI"},
|
||||
"settings.group.parameter": {"en": "Parameter", "ja": "Parameter", "vi": "Parameter"},
|
||||
"settings.group.parameter": {"en": "Parameter", "ja": "パラメータ", "vi": "Tham số"},
|
||||
"settings.group.about": {"en": "About", "ja": "このアプリについて", "vi": "Giới thiệu"},
|
||||
"settings.param_section_pricing": {
|
||||
"en": "Model pricing", "ja": "モデル価格", "vi": "Bảng giá model"},
|
||||
|
||||
+6
-6
@@ -45,8 +45,8 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"schedtask.step_prompt_ph": {"en": "Prompt / command", "ja": "プロンプト/コマンド", "vi": "Prompt / lệnh"},
|
||||
"schedtask.stepexec.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
|
||||
"schedtask.stepexec.co4e": {"en": "Code", "ja": "Code", "vi": "Code"},
|
||||
"schedtask.stepexec.script": {"en": "Script", "ja": "Script", "vi": "Script"},
|
||||
"schedtask.stepexec.manual": {"en": "Manual", "ja": "Manual", "vi": "Manual"},
|
||||
"schedtask.stepexec.script": {"en": "Script", "ja": "スクリプト", "vi": "Script"},
|
||||
"schedtask.stepexec.manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"},
|
||||
"schedtask.del_step_tooltip": {"en": "Delete the selected step", "ja": "選択したステップを削除",
|
||||
"vi": "Xóa bước đang chọn"},
|
||||
"schedtask.guide_tooltip": {
|
||||
@@ -168,7 +168,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"en": "Safety: the scheduler will NEVER auto-run this — it parks in Waiting Input until you right-click → Run now.",
|
||||
"ja": "安全: 自動実行されず、Run nowまで待機します。",
|
||||
"vi": "An toàn: scheduler KHÔNG BAO GIỜ tự chạy task này — nó nằm ở Waiting Input tới khi bạn chuột phải → Chạy ngay."},
|
||||
"schedtask.g_input": {"en": "Input", "ja": "入力", "vi": "Input"},
|
||||
"schedtask.g_input": {"en": "Input", "ja": "入力", "vi": "Đầu vào"},
|
||||
"schedtask.f_input_mode": {"en": "Input mode", "ja": "入力モード", "vi": "Chế độ input"},
|
||||
"schedtask.inmode.empty": {"en": "Empty (default)", "ja": "空(既定)", "vi": "Trống (mặc định)"},
|
||||
"schedtask.inmode.manual": {"en": "Manual text", "ja": "手入力テキスト", "vi": "Văn bản nhập tay"},
|
||||
@@ -199,7 +199,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"ja": "各URLを取得し(ベストエフォート)、テキストをコンテキストとして渡します。",
|
||||
"vi": "Mỗi link được tải nội dung (khi có thể) và đưa vào ngữ cảnh cho agent."},
|
||||
"schedtask.f_prev_task": {"en": "Previous task", "ja": "前タスク", "vi": "Task trước"},
|
||||
"schedtask.g_output": {"en": "Output", "ja": "出力", "vi": "Output"},
|
||||
"schedtask.g_output": {"en": "Output", "ja": "出力", "vi": "Đầu ra"},
|
||||
"schedtask.f_output_mode": {"en": "Output mode", "ja": "出力モード", "vi": "Chế độ output"},
|
||||
"schedtask.g_dependency": {"en": "Dependency / Next task", "ja": "依存 / 次タスク", "vi": "Phụ thuộc / Task tiếp theo"},
|
||||
"schedtask.f_next_task": {"en": "Next task", "ja": "次タスク", "vi": "Task tiếp theo"},
|
||||
@@ -244,8 +244,8 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"ja": "プレビュー(確認するまで作成されません):",
|
||||
"vi": "Xem trước (chưa tạo gì cho tới khi bạn xác nhận):"},
|
||||
"schedtask.ai_confirm": {"en": "Create tasks", "ja": "タスクを作成", "vi": "Tạo các task"},
|
||||
"schedtask.tab_ai": {"en": "AI gen task", "ja": "AIタスク生成", "vi": "AI gen task"},
|
||||
"schedtask.tab_import": {"en": "Import", "ja": "インポート", "vi": "Import"},
|
||||
"schedtask.tab_ai": {"en": "AI gen task", "ja": "AIタスク生成", "vi": "Tạo task bằng AI"},
|
||||
"schedtask.tab_import": {"en": "Import", "ja": "インポート", "vi": "Nhập"},
|
||||
"schedtask.export_template_btn": {
|
||||
"en": "Create Excel template…", "ja": "Excelテンプレートを作成…",
|
||||
"vi": "Tạo template Excel…"},
|
||||
|
||||
@@ -149,8 +149,14 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"app.provider": {"en": "Provider:", "ja": "プロバイダー:", "vi": "Nhà cung cấp:"},
|
||||
"app.language": {"en": "Language:", "ja": "言語:", "vi": "Ngôn ngữ:"},
|
||||
"app.settings": {"en": "Settings", "ja": "設定", "vi": "Cài đặt"},
|
||||
"app.tab.dashboard": {"en": "Dashboard", "ja": "Dashboard", "vi": "Dashboard"},
|
||||
"app.tab.schedule": {"en": "Schedule Task", "ja": "Schedule Task", "vi": "Schedule Task"},
|
||||
# Shown on the cover while a language switch blocks the GUI thread. It is
|
||||
# deliberately read BEFORE the switch, so it appears in the language the
|
||||
# user is leaving — the only one they can still read at that moment.
|
||||
"app.lang.switching": {
|
||||
"en": "Switching language…", "ja": "言語を切り替えています…",
|
||||
"vi": "Đang đổi ngôn ngữ…"},
|
||||
"app.tab.dashboard": {"en": "Dashboard", "ja": "ダッシュボード", "vi": "Dashboard"},
|
||||
"app.tab.schedule": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"},
|
||||
"app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
|
||||
"app.tab.code": {"en": "Code", "ja": "Code", "vi": "Code"},
|
||||
"app.tab.structure": {"en": "GraphRAG", "ja": "GraphRAG", "vi": "GraphRAG"},
|
||||
@@ -158,7 +164,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"app.tab.monitoring": {"en": "Monitoring", "ja": "モニタリング", "vi": "Giám sát"},
|
||||
"app.nav.collapse_tooltip": {"en": "Collapse menu to icons only", "ja": "メニューをアイコンのみに折りたたむ", "vi": "Thu gọn menu về icon"},
|
||||
"app.nav.expand_tooltip": {"en": "Expand menu", "ja": "メニューを展開", "vi": "Mở rộng menu"},
|
||||
"app.nav.menu_label": {"en": "MENU", "ja": "MENU", "vi": "MENU"},
|
||||
"app.nav.menu_label": {"en": "MENU", "ja": "メニュー", "vi": "MENU"},
|
||||
# Shown on the rail rows the project gate disables (Cowork, GraphRAG) —
|
||||
# they stay listed and greyed instead of disappearing from the menu.
|
||||
"app.nav.needs_project": {
|
||||
|
||||
@@ -123,7 +123,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"vi": "Không phân tích được template này. Kiểm tra file .pptx/.xlsx hợp lệ và provider AI trong Settings hoạt động tốt, hoặc tự thêm skill thủ công."},
|
||||
|
||||
# ---- flow_dialog.py -----------------------------------------------
|
||||
"flow.title": {"en": "Flow Management", "ja": "Flow Management", "vi": "Flow Management"},
|
||||
"flow.title": {"en": "Flow Management", "ja": "フロー管理", "vi": "Flow Management"},
|
||||
"flow.tab_flow": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
|
||||
"flow.tab_agents": {"en": "Agents", "ja": "エージェント", "vi": "Agents"},
|
||||
"flow.tab_skills": {"en": "Skills", "ja": "スキル", "vi": "Skills"},
|
||||
@@ -140,7 +140,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
||||
"flow.task_prompt": {"en": "Task (prompt)", "ja": "タスク(プロンプト)", "vi": "Nhiệm vụ (prompt)"},
|
||||
"flow.skill": {"en": "Skill", "ja": "スキル", "vi": "Skill"},
|
||||
"flow.agent": {"en": "AI provider", "ja": "AI プロバイダー", "vi": "AI provider"},
|
||||
"flow.model_label": {"en": "Agent:", "ja": "Agent:", "vi": "Agent:"},
|
||||
"flow.model_label": {"en": "Agent:", "ja": "エージェント:", "vi": "Agent:"},
|
||||
"flow.default_model": {"en": "(provider default)", "ja": "(プロバイダー既定)", "vi": "(mặc định của provider)"},
|
||||
"flow.gen_task_from_hint": {"en": "Generate task from hint", "ja": "ヒントからタスクを生成", "vi": "Tạo task từ gợi ý"},
|
||||
"flow.gen_task_tooltip": {
|
||||
|
||||
@@ -35,7 +35,7 @@ from __future__ import annotations
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from ...i18n import tr
|
||||
from ...i18n import bind_text, bind_tip
|
||||
from ...ui.icons import icon
|
||||
from .palette_list import _PaletteList
|
||||
|
||||
@@ -55,9 +55,11 @@ class AgentListPanel(QWidget):
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
"""Danh sách agent ở cột trái Co4E Studio, kèm nút tạo mới."""
|
||||
super().__init__(parent)
|
||||
self.new_btn = QPushButton(tr("co4e.new"))
|
||||
# Bound, not set once: this panel has no retranslate hook of its own, and
|
||||
# Co4ETab (which owns the language callback) cannot reach these tooltips.
|
||||
self.new_btn = bind_text(QPushButton(), "co4e.new")
|
||||
self.new_btn.setIcon(icon("plus"))
|
||||
self.new_btn.setToolTip(tr("co4e.tt_new_agent"))
|
||||
bind_tip(self.new_btn, "co4e.tt_new_agent")
|
||||
self.new_btn.setObjectName("co4eSectionAction")
|
||||
self.new_btn.setFlat(True)
|
||||
self.new_btn.setCursor(Qt.PointingHandCursor)
|
||||
@@ -75,11 +77,11 @@ class AgentListPanel(QWidget):
|
||||
# Edit/delete act on the selected row, so they stay with the list.
|
||||
self.edit_btn = QPushButton()
|
||||
self.edit_btn.setIcon(icon("edit"))
|
||||
self.edit_btn.setToolTip(tr("co4e.tt_edit_agent"))
|
||||
bind_tip(self.edit_btn, "co4e.tt_edit_agent")
|
||||
self.edit_btn.setFixedWidth(34)
|
||||
self.del_btn = QPushButton()
|
||||
self.del_btn.setIcon(icon("trash"))
|
||||
self.del_btn.setToolTip(tr("co4e.tt_del_agent"))
|
||||
bind_tip(self.del_btn, "co4e.tt_del_agent")
|
||||
self.del_btn.setFixedWidth(34)
|
||||
# KHONG noi .clicked o day: cung ly do nhu new_btn o tren.
|
||||
btns.addWidget(self.edit_btn)
|
||||
|
||||
@@ -13,7 +13,7 @@ from PySide6.QtWidgets import QSplitter, QWidget
|
||||
from ...core import co4e, skills as skills_mod
|
||||
from ...core.co4e_builtins import BUILTIN_AGENTS
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import tr
|
||||
from ...i18n import bind_dynamic, tr
|
||||
from ...ui.chat_view import ChatView
|
||||
from ...ui.icons import icon
|
||||
from ...presentation.co4e.co4e_chat_view import ChatPanel
|
||||
@@ -55,6 +55,12 @@ class Co4EChatMixin:
|
||||
self._co4e_routed_provider = None # routing provider override for the next turn
|
||||
self._vsplit_sizes = [540, 220] # sizes to restore when expanded
|
||||
self._msgs_collapsed = True
|
||||
# The tooltip names the action the button would perform, so it depends on
|
||||
# which way the box is folded — and the fold state lives here, not in the
|
||||
# panel. Bound so a language change re-reads it instead of freezing the
|
||||
# wording set when the tab was built.
|
||||
bind_dynamic(self.chat_toggle_btn, lambda: self.chat_toggle_btn.setToolTip(
|
||||
tr("co4e.tt_expand_msgs" if self._msgs_collapsed else "co4e.tt_collapse_msgs")))
|
||||
return panel
|
||||
def _toggle_messages(self) -> None:
|
||||
"""Show/hide the WHOLE chat box (message list + composer) below the
|
||||
|
||||
@@ -48,7 +48,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from ...core import co4e, skills as skills_mod
|
||||
from ...core.co4e_builtins import BUILTIN_AGENTS
|
||||
from ...i18n import tr
|
||||
from ...i18n import bind_placeholder, bind_text, tr
|
||||
from ...theme import current_palette
|
||||
from ...ui.icons import icon
|
||||
from ...ui.routing_toggle import RoutingToggle
|
||||
@@ -223,7 +223,8 @@ class ChatPanel(QWidget):
|
||||
self.header = QWidget(); self.header.setObjectName("msgHeader")
|
||||
mh = QHBoxLayout(self.header); mh.setContentsMargins(6, 3, 6, 3); mh.setSpacing(6)
|
||||
self.msgs_icon = QLabel(); self.msgs_icon.setPixmap(icon("message").pixmap(14, 14))
|
||||
self.msgs_title = QLabel(tr("co4e.messages")); self.msgs_title.setObjectName("hint")
|
||||
self.msgs_title = bind_text(QLabel(), "co4e.messages")
|
||||
self.msgs_title.setObjectName("hint")
|
||||
self.chat_toggle_btn = QPushButton()
|
||||
self.chat_toggle_btn.setObjectName("msgToggle")
|
||||
self.chat_toggle_btn.setFlat(True)
|
||||
@@ -253,10 +254,11 @@ class ChatPanel(QWidget):
|
||||
crow.addWidget(self.usage_total_lbl)
|
||||
_inp = QWidget(); row = QHBoxLayout(_inp); row.setContentsMargins(0, 0, 0, 0)
|
||||
self.chat_input = _ChatInput()
|
||||
self.chat_input.setPlaceholderText(tr("co4e.chat_placeholder"))
|
||||
bind_placeholder(self.chat_input, "co4e.chat_placeholder")
|
||||
# KHONG noi .submit o day: cung ly do nhu chat_toggle_btn o tren
|
||||
# (ben goi noi toi _chat_send cua chinh no).
|
||||
self.chat_send_btn = QPushButton(tr("co4e.send")); self.chat_send_btn.setIcon(icon("send"))
|
||||
self.chat_send_btn = bind_text(QPushButton(), "co4e.send")
|
||||
self.chat_send_btn.setIcon(icon("send"))
|
||||
# KHONG noi .clicked o day: cung ly do nhu tren.
|
||||
row.addWidget(self.chat_input, 1)
|
||||
# Off/Auto/Manual routing toggle for Co4E (surface key "co4e").
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import List
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import QComboBox, QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QSizePolicy, QSpacerItem, QSplitter, QTabBar, QTabWidget, QVBoxLayout, QWidget
|
||||
from ...core import co4e
|
||||
from ...i18n import tr
|
||||
from ...i18n import bind_text, tr
|
||||
from ...theme import current_palette
|
||||
from ...ui.co4e_canvas import Co4ECanvas
|
||||
from ...ui.icons import icon
|
||||
@@ -141,7 +141,9 @@ class Co4ELayoutMixin:
|
||||
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.runs_btn.toggled.connect(self._show_runs)
|
||||
|
||||
bar.addWidget(QLabel(tr("co4e.flow_name")))
|
||||
# Bound: nothing else holds this label, so a one-shot tr() here would
|
||||
# leave "Flow" stuck in the language the toolbar was built in.
|
||||
bar.addWidget(bind_text(QLabel(), "co4e.flow_name"))
|
||||
bar.addWidget(self.name_edit, 1)
|
||||
bar.addWidget(self.add_step_btn)
|
||||
bar.addWidget(self.save_btn)
|
||||
|
||||
@@ -39,7 +39,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ...i18n import tr
|
||||
from ...i18n import bind_text, bind_tip
|
||||
from ...ui.icons import icon
|
||||
|
||||
|
||||
@@ -66,13 +66,15 @@ class RunsPagePanel(QWidget):
|
||||
hdr = QHBoxLayout()
|
||||
# The Runs page covers the flow toolbar, so it carries its own way back —
|
||||
# otherwise the toggle that opened it is off screen.
|
||||
self.back_btn = QPushButton(tr("co4e.back_to_flow"))
|
||||
# Bound, not set once: this panel has no retranslate hook of its own, and
|
||||
# Co4ETab (which owns the language callback) cannot reach these strings.
|
||||
self.back_btn = bind_text(QPushButton(), "co4e.back_to_flow")
|
||||
self.back_btn.setIcon(icon("chevron-left"))
|
||||
self.back_btn.setToolTip(tr("co4e.tt_back_to_flow"))
|
||||
bind_tip(self.back_btn, "co4e.tt_back_to_flow")
|
||||
# KHONG noi .clicked o day: ben goi (Co4ETab) tu quyet dinh slot nao
|
||||
# xu ly - panel chi dung widget, khong biet _show_runs la gi.
|
||||
hdr.addWidget(self.back_btn)
|
||||
self.title_label = QLabel(tr("co4e.running_flows"))
|
||||
self.title_label = bind_text(QLabel(), "co4e.running_flows")
|
||||
self.title_label.setObjectName("hint")
|
||||
hdr.addWidget(self.title_label)
|
||||
# Show + open the workspace folder where flow outputs land (below the tab,
|
||||
@@ -85,21 +87,21 @@ class RunsPagePanel(QWidget):
|
||||
# ca hai deu thuoc Co4ETab (can ctx/manager de biet duong dan that).
|
||||
hdr.addWidget(self.ws_folder_btn)
|
||||
hdr.addStretch(1)
|
||||
self.stop_btn = QPushButton(tr("co4e.stop"))
|
||||
self.stop_btn = bind_text(QPushButton(), "co4e.stop")
|
||||
self.stop_btn.setIcon(icon("stop"))
|
||||
self.stop_btn.setObjectName("danger")
|
||||
self.stop_btn.setToolTip(tr("co4e.tt_stop_run"))
|
||||
bind_tip(self.stop_btn, "co4e.tt_stop_run")
|
||||
# KHONG noi .clicked o day: cung ly do nhu back_btn o tren.
|
||||
self.rename_btn = QPushButton(tr("co4e.rename_run"))
|
||||
self.rename_btn = bind_text(QPushButton(), "co4e.rename_run")
|
||||
self.rename_btn.setIcon(icon("edit"))
|
||||
self.rename_btn.setToolTip(tr("co4e.tt_rename_run"))
|
||||
bind_tip(self.rename_btn, "co4e.tt_rename_run")
|
||||
# KHONG noi .clicked o day: cung ly do nhu back_btn o tren.
|
||||
self.del_btn = QPushButton(tr("co4e.delete_run"))
|
||||
self.del_btn = bind_text(QPushButton(), "co4e.delete_run")
|
||||
self.del_btn.setIcon(icon("trash"))
|
||||
self.del_btn.setToolTip(tr("co4e.tt_delete_run"))
|
||||
bind_tip(self.del_btn, "co4e.tt_delete_run")
|
||||
# KHONG noi .clicked o day: cung ly do nhu back_btn o tren.
|
||||
self.clear_btn = QPushButton(tr("co4e.clear_done"))
|
||||
self.clear_btn.setToolTip(tr("co4e.tt_clear_runs"))
|
||||
self.clear_btn = bind_text(QPushButton(), "co4e.clear_done")
|
||||
bind_tip(self.clear_btn, "co4e.tt_clear_runs")
|
||||
# KHONG noi .clicked o day: cung ly do nhu back_btn o tren. (Ban goc
|
||||
# noi thang toi lambda: self.manager.clear_finished(), khong qua mot
|
||||
# method rieng - Co4ETab van giu dung quirk do khi noi lai signal nay.)
|
||||
@@ -113,7 +115,7 @@ class RunsPagePanel(QWidget):
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.table.setToolTip(tr("co4e.tt_runs_list"))
|
||||
bind_tip(self.table, "co4e.tt_runs_list")
|
||||
# KHONG noi .itemDoubleClicked o day: cung ly do nhu back_btn o tren.
|
||||
# Right-click a run → Open / Delete (delete a single old run from history).
|
||||
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
|
||||
@@ -11,14 +11,42 @@ from typing import List
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QSplitter, QVBoxLayout, QWidget
|
||||
from ...core import co4e, skills as skills_mod
|
||||
from ...i18n import tr
|
||||
from ...i18n import bind_dynamic, bind_text, bind_tip, tr
|
||||
from ...ui.icons import icon
|
||||
from ...presentation.co4e.agent_list_panel import AgentListPanel
|
||||
from ...presentation.co4e.co4e_chat_view import _skill_names
|
||||
from ...presentation.co4e.palette_list import _PaletteList
|
||||
from ...presentation.co4e.skills_list_panel import SkillsListPanel
|
||||
|
||||
|
||||
def _skill_prefix_lookup(all_skills):
|
||||
"""Answer ``skills.skill_prefix_for`` from an ALREADY-LOADED skill list.
|
||||
|
||||
``skill_prefix_for`` re-reads the whole skill folder on every call, so
|
||||
asking it once per skill made a sidebar reload cost one full disk scan per
|
||||
skill — measured at ~3.8s of frozen GUI thread on a 121-skill library, and
|
||||
that reload runs on every language switch.
|
||||
|
||||
The scan order and the blank-instructions rule are copied from
|
||||
``skill_prefix_for`` deliberately: a namesake with no instructions must NOT
|
||||
end the search, or a skill's text silently becomes empty in an agent prompt.
|
||||
"""
|
||||
cache: dict = {}
|
||||
|
||||
def lookup(name: str) -> str:
|
||||
"""The ``## Skill: <name>\\n<instructions>`` block for one name, or ''."""
|
||||
if not name:
|
||||
return ""
|
||||
low = name.strip().lower()
|
||||
if low not in cache:
|
||||
cache[low] = next(
|
||||
(f"## Skill: {s.name}\n{s.instructions.strip()}" for s in all_skills
|
||||
if (s.slug == low or s.name.lower() == low) and s.instructions.strip()),
|
||||
"")
|
||||
return cache[low]
|
||||
|
||||
return lookup
|
||||
|
||||
|
||||
class Co4ESidebarMixin:
|
||||
"""Cột trái của Co4E Studio: Workflows, Agents, Skills và Flow Status."""
|
||||
def _build_sidebar(self) -> QWidget:
|
||||
@@ -66,9 +94,12 @@ class Co4ESidebarMixin:
|
||||
col = _Col(self.side_split)
|
||||
|
||||
# --- WORKFLOWS ---------------------------------------------------
|
||||
self.wf_new_btn = QPushButton(tr("co4e.new"))
|
||||
# Bound, not set once: Co4ETab._retranslate reloads the sidebar's LIST
|
||||
# CONTENTS, but these headings, buttons and tooltips are built here and
|
||||
# nothing re-applied them — they stayed in the language of app start-up.
|
||||
self.wf_new_btn = bind_text(QPushButton(), "co4e.new")
|
||||
self.wf_new_btn.setIcon(icon("plus"))
|
||||
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
|
||||
bind_tip(self.wf_new_btn, "co4e.tt_new_wf")
|
||||
self.wf_new_btn.setObjectName("co4eSectionAction")
|
||||
self.wf_new_btn.setFlat(True)
|
||||
self.wf_new_btn.setCursor(Qt.PointingHandCursor)
|
||||
@@ -78,7 +109,7 @@ class Co4ESidebarMixin:
|
||||
# Draggable: drag a flow onto the canvas to merge it in (Nova-style);
|
||||
# double-click loads it onto the canvas.
|
||||
self.wf_list = _PaletteList(payload_role=Qt.UserRole + 2)
|
||||
self.wf_list.setToolTip(tr("co4e.drag_hint"))
|
||||
bind_tip(self.wf_list, "co4e.drag_hint")
|
||||
self.wf_list.itemDoubleClicked.connect(self._load_selected_workflow)
|
||||
self.wf_list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.wf_list.customContextMenuRequested.connect(self._wf_context_menu)
|
||||
@@ -93,8 +124,9 @@ class Co4ESidebarMixin:
|
||||
wl.addLayout(wf_btns)
|
||||
# Its own row: sharing one line with the three icon buttons cut "Chạy
|
||||
# nền" down to "Chạ" as soon as the sidebar hit its narrow width.
|
||||
self.wf_runbg_btn = QPushButton(tr("co4e.run_bg")); self.wf_runbg_btn.setIcon(icon("play"))
|
||||
self.wf_runbg_btn.setToolTip(tr("co4e.tt_run_bg"))
|
||||
self.wf_runbg_btn = bind_text(QPushButton(), "co4e.run_bg")
|
||||
self.wf_runbg_btn.setIcon(icon("play"))
|
||||
bind_tip(self.wf_runbg_btn, "co4e.tt_run_bg")
|
||||
self.wf_runbg_btn.clicked.connect(self._run_selected_in_background)
|
||||
wl.addWidget(self.wf_runbg_btn)
|
||||
col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3)
|
||||
@@ -135,12 +167,12 @@ class Co4ESidebarMixin:
|
||||
self.runs_more_btn.setIcon(icon("chevron-right"))
|
||||
self.runs_more_btn.setFixedWidth(30)
|
||||
self.runs_more_btn.setFlat(True)
|
||||
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
bind_tip(self.runs_more_btn, "co4e.tt_runs_tab")
|
||||
self.runs_more_btn.clicked.connect(lambda: self._show_runs(True))
|
||||
runs_body = QWidget(); rl = QVBoxLayout(runs_body)
|
||||
rl.setContentsMargins(0, 0, 0, 0); rl.setSpacing(4)
|
||||
self.runs_side_list = QListWidget()
|
||||
self.runs_side_list.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
bind_tip(self.runs_side_list, "co4e.tt_runs_tab")
|
||||
self.runs_side_list.itemClicked.connect(self._on_side_run_clicked)
|
||||
rl.addWidget(self.runs_side_list, 1)
|
||||
col.addWidget(self._section("co4e.runs_tab", runs_body, self.runs_more_btn), 2)
|
||||
@@ -202,7 +234,10 @@ class Co4ESidebarMixin:
|
||||
v.addWidget(body, 1)
|
||||
|
||||
self._sections[key] = (head, body, stretch)
|
||||
self._sync_section_arrow(key)
|
||||
# bind_dynamic, not bind_text: the heading is the fold arrow plus the
|
||||
# translated name in caps, so re-applying it means re-running the whole
|
||||
# line rather than pushing one key into setText.
|
||||
bind_dynamic(head, lambda k=key: self._sync_section_arrow(k))
|
||||
return box
|
||||
def _fold_section(self, key: str, body: QWidget, box: QWidget, on: bool) -> None:
|
||||
"""Fold/unfold a section AND give its height back to the others.
|
||||
@@ -223,7 +258,7 @@ class Co4ESidebarMixin:
|
||||
head.setText(("▾ " if head.isChecked() else "▸ ") + tr(key).upper())
|
||||
def _icon_btn(self, icon_name: str, tip_key: str, slot) -> QPushButton:
|
||||
"""Dựng một nút icon nhỏ (rộng 34px) kèm tooltip cho hàng công cụ của mục."""
|
||||
b = QPushButton(); b.setIcon(icon(icon_name)); b.setToolTip(tr(tip_key))
|
||||
b = QPushButton(); b.setIcon(icon(icon_name)); bind_tip(b, tip_key)
|
||||
b.setFixedWidth(34)
|
||||
b.clicked.connect(slot)
|
||||
return b
|
||||
@@ -254,13 +289,16 @@ class Co4ESidebarMixin:
|
||||
co4e._step_dict(step))
|
||||
it.setData(Qt.UserRole + 1, ca.id)
|
||||
self.agent_list.addItem(it)
|
||||
# Skills
|
||||
# Skills — the library is read ONCE here and both the names and the
|
||||
# instructions come out of that one read (see _skill_prefix_lookup).
|
||||
self.skill_list.clear()
|
||||
for name in _skill_names():
|
||||
content = skills_mod.skill_prefix_for(name)
|
||||
all_skills = skills_mod.list_skills() + skills_mod.builtin_skills()
|
||||
skill_prefix = _skill_prefix_lookup(all_skills)
|
||||
for skill in all_skills:
|
||||
name = skill.name
|
||||
payload = co4e._step_dict(co4e.Step(
|
||||
label=name, agent_slug=co4e.slugify(name), role="SKILL", icon="sparkle",
|
||||
instructions=content, skills=[name]))
|
||||
instructions=skill_prefix(name), skills=[name]))
|
||||
self.skill_list.addItem(self._palette_item(name, "sparkle", payload))
|
||||
@staticmethod
|
||||
def _palette_item(text: str, icon_name: str, payload: dict) -> QListWidgetItem:
|
||||
|
||||
@@ -39,7 +39,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from ...config import PROVIDER_LABELS
|
||||
from ...core.co4e import PERMISSION_PRESETS, Step
|
||||
from ...i18n import tr
|
||||
from ...i18n import bind_items, bind_placeholder, bind_text, bind_tip, tr
|
||||
from ...ui.icons import icon, icon_picker_combo
|
||||
from .node_property_actions_mixin import _StepConfigActionsMixin
|
||||
from .step_config_section import _add_section
|
||||
@@ -81,26 +81,30 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
|
||||
self.label_edit = QLineEdit()
|
||||
self.label_edit.textChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_label"), self.label_edit)
|
||||
form.addRow(bind_text(QLabel(), "co4e.f_label"), self.label_edit)
|
||||
|
||||
self.role_edit = QLineEdit()
|
||||
self.role_edit.textChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_role"), self.role_edit)
|
||||
form.addRow(bind_text(QLabel(), "co4e.f_role"), self.role_edit)
|
||||
|
||||
# Dropdown of every icon in the registry (Monitoring's Icon Management
|
||||
# set + built-ins), each row previewing its actual glyph — still
|
||||
# editable so a not-yet-added custom name can be typed directly.
|
||||
self.icon_edit = icon_picker_combo()
|
||||
self.icon_edit.lineEdit().setPlaceholderText(tr("co4e.f_icon_placeholder"))
|
||||
# Kept on self because the combo's line edit belongs to C++: a binding
|
||||
# holds its widget weakly, so with no owner on this side the Python
|
||||
# wrapper could be collected and the binding silently dropped.
|
||||
self._icon_line = self.icon_edit.lineEdit()
|
||||
bind_placeholder(self._icon_line, "co4e.f_icon_placeholder")
|
||||
self.icon_edit.currentTextChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_icon"), self.icon_edit)
|
||||
form.addRow(bind_text(QLabel(), "co4e.f_icon"), self.icon_edit)
|
||||
|
||||
self.instructions_edit = QPlainTextEdit()
|
||||
self.instructions_edit.setMaximumHeight(120)
|
||||
self.instructions_edit.textChanged.connect(self._on_edit)
|
||||
self.gen_btn = QPushButton(tr("co4e.ai_draft"))
|
||||
self.gen_btn = bind_text(QPushButton(), "co4e.ai_draft")
|
||||
self.gen_btn.setIcon(icon("sparkle"))
|
||||
self.gen_btn.setToolTip(tr("co4e.ai_draft_tooltip"))
|
||||
bind_tip(self.gen_btn, "co4e.ai_draft_tooltip")
|
||||
self.gen_btn.setEnabled(ctx is not None)
|
||||
self.gen_btn.clicked.connect(self._ai_draft)
|
||||
instr_box = QWidget()
|
||||
@@ -108,15 +112,15 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
ib.setContentsMargins(0, 0, 0, 0)
|
||||
ib.addWidget(self.instructions_edit)
|
||||
ib.addWidget(self.gen_btn, alignment=Qt.AlignRight)
|
||||
form.addRow(tr("co4e.f_instructions"), instr_box)
|
||||
form.addRow(bind_text(QLabel(), "co4e.f_instructions"), instr_box)
|
||||
|
||||
# Extra context — free-text background/info fed to the step at run time
|
||||
# (in addition to instructions, attachments and upstream outputs).
|
||||
self.context_edit = QPlainTextEdit()
|
||||
self.context_edit.setMaximumHeight(90)
|
||||
self.context_edit.setPlaceholderText(tr("co4e.f_context_placeholder"))
|
||||
bind_placeholder(self.context_edit, "co4e.f_context_placeholder")
|
||||
self.context_edit.textChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_context"), self.context_edit)
|
||||
form.addRow(bind_text(QLabel(), "co4e.f_context"), self.context_edit)
|
||||
|
||||
form2, _model_card = _add_section(outer, tr("co4e.tab_model_perm"))
|
||||
|
||||
@@ -126,28 +130,32 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
self.model_combo.editTextChanged.connect(self._on_edit)
|
||||
self.load_models_btn = QPushButton()
|
||||
self.load_models_btn.setIcon(icon("download"))
|
||||
self.load_models_btn.setToolTip(tr("co4e.load_models_tooltip"))
|
||||
bind_tip(self.load_models_btn, "co4e.load_models_tooltip")
|
||||
self.load_models_btn.clicked.connect(self._load_models)
|
||||
self.load_models_btn.setEnabled(ctx is not None)
|
||||
model_row.addWidget(self.model_combo, 1)
|
||||
model_row.addWidget(self.load_models_btn)
|
||||
mrow = QWidget(); mrow.setLayout(model_row)
|
||||
form2.addRow(tr("co4e.f_model"), mrow)
|
||||
form2.addRow(bind_text(QLabel(), "co4e.f_model"), mrow)
|
||||
|
||||
self.perm_combo = QComboBox()
|
||||
for preset in PERMISSION_PRESETS:
|
||||
self.perm_combo.addItem(tr(f"co4e.perm.{preset}"), preset)
|
||||
perm_keys = [f"co4e.perm.{preset}" for preset in PERMISSION_PRESETS]
|
||||
for preset, key in zip(PERMISSION_PRESETS, perm_keys):
|
||||
self.perm_combo.addItem(tr(key), preset)
|
||||
# Only the visible labels follow the language — the data column stays
|
||||
# the preset id that ``_on_edit`` persists onto the Step.
|
||||
bind_items(self.perm_combo, perm_keys)
|
||||
self.perm_combo.currentIndexChanged.connect(self._on_edit)
|
||||
form2.addRow(tr("co4e.f_permission"), self.perm_combo)
|
||||
form2.addRow(bind_text(QLabel(), "co4e.f_permission"), self.perm_combo)
|
||||
|
||||
verify_row = QHBoxLayout()
|
||||
self.verify_chk = QCheckBox(tr("co4e.f_self_verify"))
|
||||
self.verify_chk = bind_text(QCheckBox(), "co4e.f_self_verify")
|
||||
self.verify_chk.toggled.connect(self._on_edit)
|
||||
self.rounds_spin = QSpinBox()
|
||||
self.rounds_spin.setRange(1, 5)
|
||||
self.rounds_spin.valueChanged.connect(self._on_edit)
|
||||
verify_row.addWidget(self.verify_chk)
|
||||
verify_row.addWidget(QLabel(tr("co4e.f_verify_rounds")))
|
||||
verify_row.addWidget(bind_text(QLabel(), "co4e.f_verify_rounds"))
|
||||
verify_row.addWidget(self.rounds_spin)
|
||||
verify_row.addStretch(1)
|
||||
vrow = QWidget(); vrow.setLayout(verify_row)
|
||||
@@ -159,15 +167,15 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
self.skills_list = QListWidget()
|
||||
self.skills_list.setMaximumHeight(110)
|
||||
self.skills_list.itemChanged.connect(self._on_edit)
|
||||
form3.addRow(tr("co4e.f_skills"), self.skills_list)
|
||||
form3.addRow(bind_text(QLabel(), "co4e.f_skills"), self.skills_list)
|
||||
|
||||
# Attachments — files whose extracted text is fed to this step at run time.
|
||||
self.attach_list = QListWidget()
|
||||
self.attach_list.setMaximumHeight(80)
|
||||
self.attach_add_btn = QPushButton(tr("co4e.attach_add"))
|
||||
self.attach_add_btn = bind_text(QPushButton(), "co4e.attach_add")
|
||||
self.attach_add_btn.setIcon(icon("plus"))
|
||||
self.attach_add_btn.clicked.connect(self._add_attachment)
|
||||
self.attach_del_btn = QPushButton(tr("co4e.attach_remove"))
|
||||
self.attach_del_btn = bind_text(QPushButton(), "co4e.attach_remove")
|
||||
self.attach_del_btn.setIcon(icon("trash"))
|
||||
self.attach_del_btn.clicked.connect(self._del_attachment)
|
||||
att_btns = QHBoxLayout()
|
||||
@@ -175,7 +183,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
att_btns.addWidget(self.attach_del_btn)
|
||||
att_btns.addStretch(1)
|
||||
abtn = QWidget(); abtn.setLayout(att_btns)
|
||||
form3.addRow(tr("co4e.f_attachments"), self.attach_list)
|
||||
form3.addRow(bind_text(QLabel(), "co4e.f_attachments"), self.attach_list)
|
||||
form3.addRow("", abtn)
|
||||
|
||||
# Parallel sub-agents get their OWN section — same header style as
|
||||
@@ -187,10 +195,10 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
self.sub_list = QListWidget()
|
||||
self.sub_list.setMaximumHeight(90)
|
||||
self.sub_list.itemDoubleClicked.connect(self._edit_subagent) # re-pick agent
|
||||
self.sub_add_btn = QPushButton(tr("co4e.add_subagent"))
|
||||
self.sub_add_btn = bind_text(QPushButton(), "co4e.add_subagent")
|
||||
self.sub_add_btn.setIcon(icon("plus"))
|
||||
self.sub_add_btn.clicked.connect(self._add_subagent)
|
||||
self.sub_del_btn = QPushButton(tr("co4e.del_subagent"))
|
||||
self.sub_del_btn = bind_text(QPushButton(), "co4e.del_subagent")
|
||||
self.sub_del_btn.setIcon(icon("trash"))
|
||||
self.sub_del_btn.clicked.connect(self._del_subagent)
|
||||
sub_btns = QHBoxLayout()
|
||||
@@ -203,17 +211,17 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
||||
|
||||
# Footer actions — one compact row (Run · Run from here · Delete),
|
||||
# kept below every section, not inside one of the cards.
|
||||
self.run_btn = QPushButton(tr("co4e.run"))
|
||||
self.run_btn = bind_text(QPushButton(), "co4e.run")
|
||||
self.run_btn.setIcon(icon("play"))
|
||||
self.run_btn.setToolTip(tr("co4e.run_this_step"))
|
||||
bind_tip(self.run_btn, "co4e.run_this_step")
|
||||
self.run_btn.clicked.connect(lambda: self.run_node.emit(self._node_id))
|
||||
self.run_from_btn = QPushButton(tr("co4e.run_from_here"))
|
||||
self.run_from_btn.setToolTip(tr("co4e.run_from_here"))
|
||||
self.run_from_btn = bind_text(QPushButton(), "co4e.run_from_here")
|
||||
bind_tip(self.run_from_btn, "co4e.run_from_here")
|
||||
self.run_from_btn.clicked.connect(lambda: self.run_from.emit(self._node_id))
|
||||
self.del_btn = QPushButton()
|
||||
self.del_btn.setIcon(icon("trash"))
|
||||
self.del_btn.setObjectName("danger")
|
||||
self.del_btn.setToolTip(tr("co4e.delete_step"))
|
||||
bind_tip(self.del_btn, "co4e.delete_step")
|
||||
self.del_btn.setFixedWidth(38)
|
||||
self.del_btn.clicked.connect(lambda: self.delete_node.emit(self._node_id))
|
||||
foot = QHBoxLayout()
|
||||
|
||||
@@ -29,7 +29,7 @@ from __future__ import annotations
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from ...i18n import tr
|
||||
from ...i18n import bind_text, bind_tip
|
||||
from .palette_list import _PaletteList
|
||||
|
||||
|
||||
@@ -50,8 +50,10 @@ class SkillsListPanel(QWidget):
|
||||
cái gì.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.manage_btn = QPushButton(tr("co4e.manage_skills"))
|
||||
self.manage_btn.setToolTip(tr("co4e.tt_manage_skills"))
|
||||
# Bound, like AgentListPanel's: the panel owns how its own button reads,
|
||||
# so no embedder has to remember it in a retranslate method.
|
||||
self.manage_btn = bind_text(QPushButton(), "co4e.manage_skills")
|
||||
bind_tip(self.manage_btn, "co4e.tt_manage_skills")
|
||||
self.manage_btn.setObjectName("co4eSectionAction")
|
||||
self.manage_btn.setFlat(True)
|
||||
self.manage_btn.setCursor(Qt.PointingHandCursor)
|
||||
|
||||
@@ -121,6 +121,13 @@ class UsageChartWidget(QWidget):
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho nhãn và tooltip."""
|
||||
# setItemText, chứ không clear()+addItem(): cột data của hai combo này
|
||||
# là thứ quyết định kỳ và chỉ số đang xem, dựng lại danh sách sẽ reset cả
|
||||
# hai. Khoá dịch suy ra từ chính cột data nên không phải chép lại danh
|
||||
# sách giá trị ở hai nơi.
|
||||
for combo, prefix in ((self.gran_combo, "gran"), (self.metric_combo, "metric")):
|
||||
for i in range(combo.count()):
|
||||
combo.setItemText(i, tr(f"dashboard.{prefix}_{combo.itemData(i)}"))
|
||||
self.currency_lbl.setText(tr("monitoring.overview_currency"))
|
||||
self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
|
||||
self._chart_title.setText(tr("dashboard.chart_title"))
|
||||
|
||||
@@ -20,7 +20,7 @@ from PySide6.QtWidgets import (
|
||||
QTableWidget, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ....i18n import tr
|
||||
from ....i18n import bind_tip, tr
|
||||
from ....ui.icons import icon
|
||||
from .event_table import ClickOutsideCloser, EventTable
|
||||
from .event_detail_panel import EventDetailPanel
|
||||
@@ -82,7 +82,10 @@ def build_filter_scaffold(
|
||||
search.textChanged.connect(table.apply_filter)
|
||||
ai_btn = QPushButton(tr("monitoring.ai_filter_btn"))
|
||||
ai_btn.setIcon(icon("sparkle"))
|
||||
ai_btn.setToolTip(tr("monitoring.ai_filter_tooltip"))
|
||||
# Bound rather than set once: this scaffold builds the button for all
|
||||
# three event tabs, and none of their retranslate() methods can reach a
|
||||
# tooltip that was applied here.
|
||||
bind_tip(ai_btn, "monitoring.ai_filter_tooltip")
|
||||
ai_btn.setCursor(Qt.PointingHandCursor)
|
||||
if on_ai_filter is not None:
|
||||
ai_btn.clicked.connect(lambda: on_ai_filter(search, ai_btn))
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Lớp phủ "đang xử lý" ở cấp cửa sổ, dành cho tác vụ chặn GUI thread.
|
||||
|
||||
Vì sao là file riêng chứ không nhét vào ``main_window.py``: file đó chỉ còn 9
|
||||
dòng vật lý dưới trần 400 của Gate S, và một lớp phủ cấp cửa sổ là một trách
|
||||
nhiệm riêng (guardrail G6).
|
||||
|
||||
Cùng lý do ``repaint()`` với panel bận của GraphRAG — xem
|
||||
``presentation/graph/structure_graph_view.py:139-151``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from time import perf_counter
|
||||
from typing import Callable
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
# Dưới mức này người dùng chưa kịp nhận ra mình đang đợi, nên một lớp phủ toàn
|
||||
# cửa sổ chỉ kịp nháy lên rồi tắt — tự nó là một khuyết tật giao diện, không
|
||||
# phải một lời trấn an.
|
||||
_NOTICEABLE_MS = 400.0
|
||||
|
||||
|
||||
class BusyOverlay(QWidget):
|
||||
"""A window-wide "please wait" cover for work that blocks the GUI thread.
|
||||
|
||||
Deliberately NOT registered with :func:`i18n.on_language_changed`: the text
|
||||
is supplied by the caller right before the block and must stay in the
|
||||
language the rest of the screen is still showing.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget) -> None:
|
||||
"""Build the cover hidden; it sizes itself to the parent on every show."""
|
||||
super().__init__(parent)
|
||||
self.setObjectName("busyOverlay")
|
||||
# A QWidget SUBCLASS ignores a stylesheet background without this
|
||||
# attribute; a plain QWidget instance (the panel below) does not need it.
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
self.setFocusPolicy(Qt.NoFocus)
|
||||
# Chưa đo được lượt nào: xem mục ``run_blocking``.
|
||||
self._last_ms: float | None = None
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.addStretch(1)
|
||||
row = QHBoxLayout()
|
||||
row.addStretch(1)
|
||||
self._panel = QWidget()
|
||||
self._panel.setObjectName("busyOverlayPanel")
|
||||
inner = QHBoxLayout(self._panel)
|
||||
inner.setContentsMargins(24, 18, 24, 18)
|
||||
self._label = QLabel()
|
||||
inner.addWidget(self._label)
|
||||
row.addWidget(self._panel)
|
||||
row.addStretch(1)
|
||||
lay.addLayout(row)
|
||||
lay.addStretch(1)
|
||||
self.hide()
|
||||
|
||||
def text(self) -> str:
|
||||
"""Chữ đang hiện trên lớp phủ (dùng cho test)."""
|
||||
return self._label.text()
|
||||
|
||||
def run_blocking(self, message: str, work: Callable[[], None]) -> None:
|
||||
"""Run ``work`` on the GUI thread, covered only when that is worth doing.
|
||||
|
||||
Nothing can time the freeze WHILE it happens: the GUI thread stops, so
|
||||
no timer fires and no watchdog can raise the cover mid-way. The only
|
||||
honest clock is the PREVIOUS run of this same call, so that is what
|
||||
decides. No measurement yet (the first switch of a process) errs
|
||||
towards showing: one flash is a smaller defect than a multi-second
|
||||
freeze with nothing on screen to explain it.
|
||||
|
||||
The result is self-calibrating. A fast machine flashes once per launch
|
||||
and then stays out of the way; a slow one, or a big skill library, gets
|
||||
the cover on every switch from the second one on.
|
||||
|
||||
``work`` is timed and its exceptions propagate — the cover still comes
|
||||
down, so a raising callback cannot leave it stuck on screen forever.
|
||||
"""
|
||||
if self._last_ms is None or self._last_ms >= _NOTICEABLE_MS:
|
||||
self.show_busy(message)
|
||||
started = perf_counter()
|
||||
try:
|
||||
work()
|
||||
finally:
|
||||
self._last_ms = (perf_counter() - started) * 1000.0
|
||||
self.hide_busy()
|
||||
|
||||
def show_busy(self, message: str) -> None:
|
||||
"""Show the cover and FORCE it onto the screen right now.
|
||||
|
||||
``repaint()``, not ``update()``: the caller is about to block the GUI
|
||||
thread, so a queued paint would only run once the freeze is over — the
|
||||
one moment the cover is no longer needed.
|
||||
|
||||
No animated progress bar on purpose: with no event loop running,
|
||||
nothing would move; only static text is guaranteed to be readable.
|
||||
"""
|
||||
self._label.setText(message)
|
||||
self.setGeometry(self.parent().rect())
|
||||
self.show()
|
||||
self.raise_()
|
||||
self.repaint()
|
||||
|
||||
def hide_busy(self) -> None:
|
||||
"""Release the cover. Call from ``finally`` so a raising callback
|
||||
cannot leave it stuck on screen forever."""
|
||||
self.hide()
|
||||
@@ -255,6 +255,12 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
|
||||
tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip"))
|
||||
if hasattr(self, "provider_lbl"):
|
||||
self.provider_lbl.setText(tr("app.provider"))
|
||||
# The label is hidden — the combo names itself through its tooltip
|
||||
# (see top_bar._build_account_row), so that is the one users read.
|
||||
self.provider_combo.setToolTip(tr("app.provider"))
|
||||
if hasattr(self, "nav_project"):
|
||||
self.nav_project.setToolTip(tr("app.nav.project_pick"))
|
||||
self.nav_recents_hdr.setText(tr("app.nav.recents"))
|
||||
if hasattr(self, "settings_btn"):
|
||||
self.settings_btn.setText(tr("app.settings"))
|
||||
if hasattr(self, "theme_btn"):
|
||||
@@ -266,6 +272,13 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
|
||||
if getattr(self, "help_agent", None) is not None:
|
||||
self.help_agent.retranslate()
|
||||
self._tray.retranslate()
|
||||
# Thanh trạng thái (góc dưới bên trái) nhận thông báo từ hàng chục nơi
|
||||
# qua signal ``status_message``, và signal đó mang CHUỖI ĐÃ DỊCH chứ
|
||||
# không mang khoá — nên không thể dịch lại câu đang hiện. Đưa nó về câu
|
||||
# nền của ngôn ngữ mới: câu cũ không đọng lại bằng thứ tiếng vừa rời đi,
|
||||
# mà chỗ đó cũng không trống trơn. Thông báo là ghi chú về một việc vừa
|
||||
# xong, nên bỏ nó đi khi đổi ngôn ngữ không làm mất thông tin nào.
|
||||
self.statusBar().showMessage(tr("app.status.ready"))
|
||||
|
||||
# ---- system tray (run in background when the window is closed) ---
|
||||
|
||||
|
||||
@@ -253,10 +253,24 @@ class NavRailMixin:
|
||||
tree.blockSignals(blocked)
|
||||
# Both destination lists are exactly as tall as their rows; the
|
||||
# stretch in between belongs to RECENTS.
|
||||
#
|
||||
# The frame, and nothing else. A flat ``+ 8`` here used to leave 6px
|
||||
# of dead space under the last row of each list, and because the
|
||||
# Settings button sits DIRECTLY under nav_bottom (nvl has no
|
||||
# spacing), that space landed between Giám sát and Settings only —
|
||||
# so three rows that read as one list were spaced 18/26px. Padding
|
||||
# a row is the item delegate's job; this is the frame's.
|
||||
row_h = 0
|
||||
for tree in (self.nav, self.nav_bottom):
|
||||
n = tree.topLevelItemCount()
|
||||
row_h = tree.sizeHintForRow(0) if n else 0
|
||||
tree.setFixedHeight(n * row_h + 8)
|
||||
row_h = tree.sizeHintForRow(0) if n else row_h
|
||||
tree.setFixedHeight(n * row_h + 2 * tree.frameWidth())
|
||||
# Settings is one more row of the same list, so it gets the rows'
|
||||
# own height rather than a second set of paddings guessed to match
|
||||
# it — the only way the three stay evenly spaced when the font (and
|
||||
# with it ``sizeHintForRow``) is not the one this was tuned on.
|
||||
if row_h and hasattr(self, "_nav_settings_btn"):
|
||||
self._nav_settings_btn.setFixedHeight(row_h)
|
||||
if keep:
|
||||
self._select_nav_row(*keep)
|
||||
finally:
|
||||
|
||||
@@ -54,7 +54,12 @@ class TopBarMixin:
|
||||
self._nav_settings_btn.setCursor(Qt.PointingHandCursor)
|
||||
self._nav_settings_btn.clicked.connect(self._open_settings)
|
||||
srow = QHBoxLayout(self._nav_settings_btn)
|
||||
srow.setContentsMargins(_NAV_ROW_INSET, 6, 8, 6)
|
||||
# No vertical padding of its own: ``_rebuild_nav`` pins this button to the
|
||||
# nav rows' OWN height, so the 6px a row pads with is already inside
|
||||
# that number. Adding it again here made the row taller than the button
|
||||
# (28 wanted, 20 given), which both clipped the icon and pushed the text
|
||||
# 8px below an even pitch with Dashboard / Giám sát.
|
||||
srow.setContentsMargins(_NAV_ROW_INSET, 0, 8, 0)
|
||||
srow.setSpacing(_NAV_ROW_GAP)
|
||||
self._nav_settings_icon = QLabel()
|
||||
self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16))
|
||||
@@ -63,6 +68,9 @@ class TopBarMixin:
|
||||
srow.addWidget(self._nav_settings_icon)
|
||||
srow.addWidget(self._nav_settings_text)
|
||||
srow.addStretch(1)
|
||||
# The first _rebuild_nav() ran before this button existed (it is what
|
||||
# fills the list this row belongs under), so take the height here too.
|
||||
self._nav_settings_btn.setFixedHeight(self.nav_bottom.sizeHintForRow(0))
|
||||
nvl.addWidget(self._nav_settings_btn)
|
||||
self._account_row = self._build_account_row()
|
||||
|
||||
@@ -194,6 +202,39 @@ class TopBarMixin:
|
||||
# Khong bao "dang dung <provider>" 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 _lang_busy_overlay(self):
|
||||
"""The window's busy cover, built on first use.
|
||||
|
||||
Built lazily so a window that never changes language never gets one —
|
||||
and so ``_open_settings`` can be checked for "no switch, no flash".
|
||||
"""
|
||||
overlay = getattr(self, "_lang_busy", None)
|
||||
if overlay is None:
|
||||
from .busy_overlay import BusyOverlay
|
||||
overlay = BusyOverlay(self)
|
||||
self._lang_busy = overlay
|
||||
return overlay
|
||||
|
||||
def _switch_language(self, lang: str) -> None:
|
||||
"""Apply a new UI language behind a busy cover.
|
||||
|
||||
``set_language`` runs every registered widget's re-translation on the
|
||||
GUI thread, which on a large skill library takes long enough to look
|
||||
like a hang. Nothing can raise a cover once that has started (no event
|
||||
loop is left running), so it goes up FIRST — see ``busy_overlay.py``.
|
||||
|
||||
The message is read before the switch on purpose: mid-switch the only
|
||||
language the user can still read is the one being left behind.
|
||||
"""
|
||||
message = tr("app.lang.switching")
|
||||
self.language_combo.setEnabled(False)
|
||||
try:
|
||||
self._lang_busy_overlay().run_blocking(message, lambda: set_language(lang))
|
||||
finally:
|
||||
# In a ``finally`` so a listener that raises cannot leave the
|
||||
# switcher locked for the rest of the session.
|
||||
self.language_combo.setEnabled(True)
|
||||
|
||||
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.
|
||||
@@ -203,7 +244,7 @@ class TopBarMixin:
|
||||
return
|
||||
self.ctx.config.language = lang
|
||||
self.ctx.save()
|
||||
set_language(lang) # notifies every registered persistent widget
|
||||
self._switch_language(lang) # notifies every registered persistent widget
|
||||
def _open_settings(self) -> None:
|
||||
"""Mở hộp thoại Cài đặt; bấm Lưu thì áp lại theme và làm mới thanh trên."""
|
||||
dlg = SettingsDialog(self.ctx, self)
|
||||
@@ -214,7 +255,10 @@ class TopBarMixin:
|
||||
from ...ui.icons import icon as _theme_icon
|
||||
self.theme_btn.setIcon(
|
||||
_theme_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor")))
|
||||
set_language(self.ctx.config.language) # apply if changed in Settings
|
||||
# Guarded, not left to set_language's own no-op check: the cover
|
||||
# around the switch would otherwise flash on every Save.
|
||||
if self.ctx.config.language != get_language():
|
||||
self._switch_language(self.ctx.config.language)
|
||||
# reflect provider/theme/language changes
|
||||
i = self.provider_combo.findData(self.ctx.config.active_provider)
|
||||
if i >= 0:
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Co4E left column: the skill library must be read ONCE per sidebar reload.
|
||||
|
||||
Why this test exists
|
||||
--------------------
|
||||
``Co4ESidebarMixin._reload_sidebar`` used to call
|
||||
``core.skills.skill_prefix_for(name)`` once per skill. Every one of those calls
|
||||
re-reads the *whole* skill folder from disk (``list_skills()`` +
|
||||
``builtin_skills()``), so the reload was O(N**2) in the number of skills.
|
||||
|
||||
That reload runs on every language switch (``Co4ETab._retranslate`` ->
|
||||
``_reload_sidebar``). Measured end-to-end on a real library of 121 skills, the
|
||||
per-name calls cost ~3.8 s of blocked GUI thread — the "app freezes for a few
|
||||
seconds when I switch Vietnamese -> English" the user reported.
|
||||
|
||||
Two different things are guarded here, and they fail for different reasons:
|
||||
|
||||
* ``test_reload_sidebar_reads_the_skill_library_once`` — the performance
|
||||
contract. Red before the fix (the library was read 1 + N times), green after.
|
||||
* the two equivalence tests — the *correctness* contract. A lookup table is
|
||||
easy to build subtly wrong, and a wrong one silently changes which skill text
|
||||
is pushed into an agent prompt. They pin the answer to ``skill_prefix_for``
|
||||
itself in exactly the cases where a naive ``{name: skill}`` dict diverges:
|
||||
a name shared by a user skill and a built-in (first match wins, user first),
|
||||
a skill whose instructions are blank (it does NOT end ``skill_prefix_for``'s
|
||||
scan, so a later namesake with real instructions must still win), and
|
||||
lookups by slug / different case / padding / unknown name / empty name.
|
||||
Those two are characterization tests: green before AND after by design.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QListWidget
|
||||
|
||||
from cowork_local.core import co4e, skills as skills_mod
|
||||
from cowork_local.presentation.co4e.co4e_sidebar import Co4ESidebarMixin
|
||||
|
||||
|
||||
class _SidebarHarness(Co4ESidebarMixin):
|
||||
"""Just enough state to run the real ``_reload_sidebar``.
|
||||
|
||||
Building a whole ``Co4ETab`` would drag in config/HOME-dependent module
|
||||
constants (see ``tests/test_build_co4e_tab.py``); the mixin only touches
|
||||
the three list widgets, so the real production method runs unchanged here.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Create the three palette lists ``_reload_sidebar`` fills in."""
|
||||
self.wf_list = QListWidget()
|
||||
self.agent_list = QListWidget()
|
||||
self.skill_list = QListWidget()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness(qapp, monkeypatch):
|
||||
"""A sidebar harness with the workflow/agent sources stubbed out empty."""
|
||||
monkeypatch.setattr(co4e, "list_workflows", lambda: [])
|
||||
monkeypatch.setattr(co4e, "list_custom_agents", lambda: [])
|
||||
return _SidebarHarness()
|
||||
|
||||
|
||||
def _install_skills(monkeypatch, user, builtin):
|
||||
"""Replace the two disk-reading skill sources and count how often they run.
|
||||
|
||||
Fresh copies are handed out on every call so a caller mutating a returned
|
||||
``Skill`` cannot make a later call look different.
|
||||
"""
|
||||
calls = {"list_skills": 0, "builtin_skills": 0}
|
||||
|
||||
def _list_skills(directory=None):
|
||||
"""Stand-in for ``skills.list_skills`` that records each read."""
|
||||
calls["list_skills"] += 1
|
||||
return [replace(s) for s in user]
|
||||
|
||||
def _builtin_skills():
|
||||
"""Stand-in for ``skills.builtin_skills`` that records each read."""
|
||||
calls["builtin_skills"] += 1
|
||||
return [replace(s) for s in builtin]
|
||||
|
||||
monkeypatch.setattr(skills_mod, "list_skills", _list_skills)
|
||||
monkeypatch.setattr(skills_mod, "builtin_skills", _builtin_skills)
|
||||
return calls
|
||||
|
||||
|
||||
def _skill_rows(sidebar):
|
||||
"""Return ``(item text, payload instructions)`` for every skill palette row."""
|
||||
return [(sidebar.skill_list.item(i).text(),
|
||||
sidebar.skill_list.item(i).data(Qt.UserRole)["instructions"])
|
||||
for i in range(sidebar.skill_list.count())]
|
||||
|
||||
|
||||
def test_reload_sidebar_reads_the_skill_library_once(harness, monkeypatch):
|
||||
"""One sidebar reload must hit the skill library exactly once, not once per skill."""
|
||||
user = [skills_mod.Skill(name=f"Skill {i}", instructions=f"Body {i}")
|
||||
for i in range(8)]
|
||||
calls = _install_skills(monkeypatch, user, [])
|
||||
|
||||
harness._reload_sidebar()
|
||||
|
||||
assert harness.skill_list.count() == 8, "every skill must still reach the palette"
|
||||
assert calls["list_skills"] == 1, (
|
||||
f"the skill library was read {calls['list_skills']}x for 8 skills — "
|
||||
"reading it once per skill is the O(N^2) freeze on language switch"
|
||||
)
|
||||
assert calls["builtin_skills"] == 1, (
|
||||
f"built-in skills were read {calls['builtin_skills']}x for 8 skills"
|
||||
)
|
||||
|
||||
|
||||
def test_skill_payloads_match_skill_prefix_for(harness, monkeypatch):
|
||||
"""The palette payload must be byte-identical to ``skill_prefix_for``'s answer.
|
||||
|
||||
The fixture is deliberately hostile: a name shared by a user skill and a
|
||||
built-in, a blank-instructions skill followed by a namesake with real
|
||||
content, and names with padding/odd casing.
|
||||
"""
|
||||
user = [
|
||||
skills_mod.Skill(name="Shared Name", instructions="USER VERSION"),
|
||||
skills_mod.Skill(name="Blank First", instructions=" "),
|
||||
skills_mod.Skill(name="Blank First", instructions="LATER, WITH CONTENT"),
|
||||
skills_mod.Skill(name=" Padded Name ", instructions="PADDED BODY"),
|
||||
skills_mod.Skill(name="MiXeD CaSe", instructions="MIXED BODY"),
|
||||
]
|
||||
builtin = [
|
||||
skills_mod.Skill(name="Shared Name", instructions="BUILTIN VERSION"),
|
||||
skills_mod.Skill(name="Builtin Only", instructions="BUILTIN BODY"),
|
||||
]
|
||||
_install_skills(monkeypatch, user, builtin)
|
||||
|
||||
harness._reload_sidebar()
|
||||
|
||||
expected = [(s.name, skills_mod.skill_prefix_for(s.name)) for s in user + builtin]
|
||||
assert _skill_rows(harness) == expected
|
||||
|
||||
rows = dict(_skill_rows(harness))
|
||||
# A user skill wins over a built-in of the same name: list_skills() comes
|
||||
# first and skill_prefix_for() returns the FIRST match, not the last.
|
||||
assert rows["Shared Name"] == "## Skill: Shared Name\nUSER VERSION"
|
||||
# A blank-instructions skill does not end the scan, so its later namesake
|
||||
# supplies the text. A last-write-wins dict would answer '' here.
|
||||
assert rows["Blank First"] == "## Skill: Blank First\nLATER, WITH CONTENT"
|
||||
assert rows["Builtin Only"] == "## Skill: Builtin Only\nBUILTIN BODY"
|
||||
|
||||
|
||||
def test_lookup_matches_skill_prefix_for_on_odd_names(monkeypatch):
|
||||
"""Slug / case / padding / unknown / empty lookups must answer like ``skill_prefix_for``."""
|
||||
# Imported inside the test on purpose: this symbol is what the fix
|
||||
# introduces, and a module-level import would turn the pre-fix run into a
|
||||
# collection error instead of a real assertion failure in the test above.
|
||||
from cowork_local.presentation.co4e.co4e_sidebar import _skill_prefix_lookup
|
||||
|
||||
user = [
|
||||
skills_mod.Skill(name="Shared Name", instructions="USER VERSION"),
|
||||
skills_mod.Skill(name="Blank First", instructions=""),
|
||||
skills_mod.Skill(name="Blank First", instructions="LATER, WITH CONTENT"),
|
||||
skills_mod.Skill(name="Tiếng Việt / 日本語", instructions="UNICODE BODY"),
|
||||
skills_mod.Skill(name="", instructions="NAMELESS BODY"),
|
||||
]
|
||||
builtin = [skills_mod.Skill(name="Shared Name", instructions="BUILTIN VERSION")]
|
||||
_install_skills(monkeypatch, user, builtin)
|
||||
|
||||
lookup = _skill_prefix_lookup(skills_mod.list_skills() + skills_mod.builtin_skills())
|
||||
|
||||
probes = [
|
||||
"Shared Name", "shared name", "SHARED NAME", " Shared Name ",
|
||||
"shared-name", # by slug
|
||||
"Blank First", "blank-first",
|
||||
"Tiếng Việt / 日本語", "tiếng-việt-日本語",
|
||||
"", " ", "no-such-skill-anywhere", "skill",
|
||||
]
|
||||
for probe in probes:
|
||||
assert lookup(probe) == skills_mod.skill_prefix_for(probe), probe
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Đổi ngôn ngữ thì MỌI chữ trên màn hình phải đổi theo.
|
||||
|
||||
Lỗi mà bộ test này khoá lại: `w.setToolTip(tr("k"))` chỉ đúng ở đúng thời điểm
|
||||
chạy dòng đó. Không có gì áp lại, nên sau khi người dùng đổi ngôn ngữ thì chữ
|
||||
đứng nguyên ở ngôn ngữ cũ. Vì `DEFAULT_LANGUAGE = "vi"`, triệu chứng người dùng
|
||||
báo là "chọn English mà nhiều chỗ vẫn tiếng Việt".
|
||||
|
||||
Cách kiểm: KHÔNG so chữ với bảng dịch — làm thế thì một nhãn tiếng Việt trùng
|
||||
chữ với nhãn khác sẽ báo oan (và ngược lại, "OneDrive" giống nhau ở cả ba ngôn
|
||||
ngữ sẽ lọt). Thay vào đó thay `tr()` bằng hàm trả về một chuỗi MỐC, rồi gọi
|
||||
`set_language()`. Chỗ nào được áp lại sẽ mang mốc; chỗ nào không mang mốc là chỗ
|
||||
không có ai áp lại — đó chính là lỗi, và biết chắc chứ không phải suy đoán.
|
||||
|
||||
Hai điểm mà một bản kiểm ngây thơ sẽ sai:
|
||||
|
||||
* `from ...i18n import tr` COPY tham chiếu vào namespace của từng module, nên
|
||||
sửa `i18n.tr` một mình là không đủ — phải thay ở mọi module đã import nó.
|
||||
* Lưới vẽ lại bằng `deleteLater()` để lại widget cũ còn sống tới khi vòng lặp sự
|
||||
kiện tiêu hoá xong. Không `sendPostedEvents(DeferredDelete)` thì hàng chục
|
||||
widget bóng ma mang chữ cũ sẽ bị đếm là lỗi trong khi người dùng không hề thấy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
|
||||
|
||||
MOC = "\u2063" # invisible separator: không chuỗi hiển thị thật nào chứa nó
|
||||
|
||||
#: Khoá cố ý giống nhau ở cả ba ngôn ngữ: tên thương hiệu, tên sản phẩm, ký hiệu,
|
||||
#: placeholder thuần định dạng, và mã mức độ hiển thị nguyên dạng. Thêm khoá mới
|
||||
#: vào đây phải kèm lý do — đây là chỗ dễ dùng để lặng lẽ bỏ qua việc dịch.
|
||||
KHOA_KHONG_CAN_DICH = {
|
||||
# thương hiệu / tên sản phẩm
|
||||
"app.logo", "app.credit", "login.header",
|
||||
"app.tab.code", "app.tab.cowork", "app.tab.structure",
|
||||
"code.title", "code.onedrive_badge", "code.onedrive_btn",
|
||||
"cowork.title", "sidebar.filter.code", "sidebar.filter.cowork",
|
||||
"workspace.tab_co4e", "workspace.tab_co4e_tooltip", "workspace.tab_cowork",
|
||||
"workspace.tab_graphrag", "structure.cmem_ui_open",
|
||||
"settings.group.anthropic", "settings.group.cowork", "settings.group.structure",
|
||||
"settings.group.teams", "settings.history_onedrive",
|
||||
"settings.ms365_connector.onedrive", "settings.ms365_connector.outlook",
|
||||
"settings.ms365_connector.sharepoint", "settings.ms365_connector.teams",
|
||||
"schedtask.stepexec.co4e", "schedtask.stepexec.cowork",
|
||||
"schedtask.type.co4e_code", "schedtask.type.cowork",
|
||||
"monitoring.tab_mcp", "ext.mode_rest", "schedtask.f_cron",
|
||||
"help_agent.badge", "help_agent.title", # dùng nguyên dạng trong cả câu tiếng Nhật
|
||||
"help_agent.default_user", # đứng thay cho TÊN người dùng
|
||||
# viết tắt / ký hiệu / thuần định dạng
|
||||
"accounts.ai_search_btn", "monitoring.ai_filter_btn", "monitoring.overview_res_cpu",
|
||||
"monitoring.na", "schedtask.add_link_label", "settings.teams_webhook",
|
||||
"cowork.project_label", "connectors.jira_fail", "tools_admin.jira_fail",
|
||||
# mã mức độ: giữ nguyên dạng ở CẢ BA ngôn ngữ, có chủ ý
|
||||
"monitoring.severity_critical", "monitoring.severity_info", "monitoring.severity_medium",
|
||||
}
|
||||
|
||||
|
||||
# ---- dữ liệu: bảng dịch không được thiếu tiếng Nhật ----------------------
|
||||
|
||||
def test_moi_khoa_co_du_ba_ngon_ngu():
|
||||
from cowork_local.i18n import STRINGS
|
||||
|
||||
thieu = {k: sorted({"en", "ja", "vi"} - {x for x in v if v.get(x)})
|
||||
for k, v in STRINGS.items()
|
||||
if not all(v.get(x) for x in ("en", "ja", "vi"))}
|
||||
|
||||
assert thieu == {}, f"khoá thiếu bản dịch: {thieu}"
|
||||
|
||||
|
||||
def test_ban_dich_nhat_khong_phai_chuoi_tieng_anh():
|
||||
"""`ja` bằng đúng `en` nghĩa là khoá đó chưa được dịch — trừ danh sách miễn.
|
||||
|
||||
Đây là nửa còn lại của lỗi người dùng báo: "chọn tiếng Nhật thì vài chỗ hiển
|
||||
thị tiếng Anh". Nó KHÔNG phải lỗi dây nối (widget vẫn áp lại `tr()` đúng),
|
||||
mà là lỗ trong dữ liệu dịch — nên phải có cổng riêng canh.
|
||||
"""
|
||||
from cowork_local.i18n import STRINGS
|
||||
|
||||
chua_dich = sorted(k for k, v in STRINGS.items()
|
||||
if v.get("en") == v.get("ja") and k not in KHOA_KHONG_CAN_DICH)
|
||||
|
||||
assert chua_dich == [], (
|
||||
"khoá chưa có bản dịch tiếng Nhật (thêm bản dịch, hoặc khai vào "
|
||||
f"KHOA_KHONG_CAN_DICH kèm lý do): {chua_dich}")
|
||||
|
||||
|
||||
def test_danh_sach_mien_khong_phinh_len_am_tham():
|
||||
"""Danh sách miễn chỉ được chứa khoá THẬT SỰ giống nhau ở en/ja.
|
||||
|
||||
Không có test này thì cách dễ nhất để làm cổng trên xanh lại là nhét khoá
|
||||
vào danh sách miễn rồi để nguyên đó sau khi đã dịch.
|
||||
"""
|
||||
from cowork_local.i18n import STRINGS
|
||||
|
||||
thua = sorted(k for k in KHOA_KHONG_CAN_DICH
|
||||
if k in STRINGS and STRINGS[k].get("en") != STRINGS[k].get("ja"))
|
||||
|
||||
assert thua == [], f"khoá đã có bản dịch nhưng vẫn nằm trong danh sách miễn: {thua}"
|
||||
|
||||
|
||||
# ---- lúc chạy: đổi ngôn ngữ thì chữ trên màn hình phải đổi --------------
|
||||
|
||||
def _chu_cua(w):
|
||||
"""Mọi chỗ chữ hiện ra từ một widget, kèm tên thuộc tính."""
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QLineEdit, QListWidget, QPlainTextEdit, QTabWidget,
|
||||
QTableWidget, QTextEdit, QTreeWidget,
|
||||
)
|
||||
ra = []
|
||||
|
||||
def them(ten, s):
|
||||
if isinstance(s, str) and s.strip():
|
||||
ra.append((ten, s))
|
||||
|
||||
# QLineEdit.text() là NỘI DUNG người dùng gõ, không phải nhãn giao diện.
|
||||
if hasattr(w, "text") and not isinstance(w, (QLineEdit, QTextEdit, QPlainTextEdit)):
|
||||
try:
|
||||
them("text", w.text())
|
||||
except RuntimeError:
|
||||
return ra
|
||||
for ten in ("windowTitle", "placeholderText", "toolTip", "title", "accessibleName"):
|
||||
f = getattr(w, ten, None)
|
||||
if callable(f):
|
||||
try:
|
||||
them(ten, f())
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
if isinstance(w, QTabWidget):
|
||||
for i in range(w.count()):
|
||||
them(f"tabText[{i}]", w.tabText(i))
|
||||
them(f"tabToolTip[{i}]", w.tabToolTip(i))
|
||||
if isinstance(w, QComboBox):
|
||||
for i in range(w.count()):
|
||||
them(f"itemText[{i}]", w.itemText(i))
|
||||
if isinstance(w, QListWidget):
|
||||
for i in range(w.count()):
|
||||
it = w.item(i)
|
||||
if it is not None:
|
||||
them(f"item[{i}]", it.text())
|
||||
them(f"itemTip[{i}]", it.toolTip())
|
||||
if isinstance(w, QTableWidget):
|
||||
for c in range(w.columnCount()):
|
||||
h = w.horizontalHeaderItem(c)
|
||||
if h is not None:
|
||||
them(f"hheader[{c}]", h.text())
|
||||
if isinstance(w, QTreeWidget):
|
||||
hi = w.headerItem()
|
||||
if hi is not None:
|
||||
for c in range(w.columnCount()):
|
||||
them(f"hheader[{c}]", hi.text(c))
|
||||
return ra
|
||||
|
||||
|
||||
def _chu_so_huu(w):
|
||||
"""Lớp widget của chính dự án gần nhất trên đường đi lên.
|
||||
|
||||
Một `QPushButton` trần không cho biết nó thuộc màn nào; tổ tiên gần nhất do
|
||||
dự án định nghĩa thì cho biết, và đó là file cần sửa.
|
||||
"""
|
||||
cur = w
|
||||
while cur is not None:
|
||||
mod = type(cur).__module__ or ""
|
||||
if mod.startswith("cowork_local"):
|
||||
return f"{mod}.{type(cur).__name__}"
|
||||
cur = cur.parent()
|
||||
return "?"
|
||||
|
||||
|
||||
def _chup(root, giu):
|
||||
"""Ảnh chụp mọi chỗ chữ trong cây widget.
|
||||
|
||||
``giu`` nhận thêm tham chiếu tới từng widget đã đi qua: wrapper PySide6 bị
|
||||
thu hồi thì `id()` được cấp lại cho wrapper sau, và vòng quét sẽ tưởng đã
|
||||
thăm rồi mà dừng sớm — mất phần lớn cây.
|
||||
"""
|
||||
from PySide6.QtWidgets import QMenu, QWidget
|
||||
|
||||
anh, ngan, da_tham = {}, [root], set()
|
||||
while ngan:
|
||||
w = ngan.pop()
|
||||
if id(w) in da_tham:
|
||||
continue
|
||||
da_tham.add(id(w))
|
||||
giu.append(w)
|
||||
so_huu = _chu_so_huu(w)
|
||||
for ten, s in _chu_cua(w):
|
||||
anh[(id(w), type(w).__name__, so_huu, ten)] = s
|
||||
try:
|
||||
ngan.extend([c for c in w.children() if isinstance(c, (QWidget, QMenu))])
|
||||
except RuntimeError:
|
||||
pass
|
||||
return anh
|
||||
|
||||
|
||||
def _thay_tr(moi, cu):
|
||||
"""Thay `tr` ở MỌI module của dự án đang trỏ tới ``cu``. Trả về số module."""
|
||||
from cowork_local import i18n as i18n_mod
|
||||
|
||||
n = 0
|
||||
for ten, m in list(sys.modules.items()):
|
||||
if ten.startswith("cowork_local") and getattr(m, "tr", None) is cu:
|
||||
setattr(m, "tr", moi)
|
||||
n += 1
|
||||
i18n_mod.tr = moi
|
||||
return n
|
||||
|
||||
|
||||
def _tieu_hoa(qapp):
|
||||
"""Cho Qt xử lý hết `deleteLater()` để không đếm widget bóng ma."""
|
||||
from PySide6.QtCore import QEvent
|
||||
|
||||
for _ in range(3):
|
||||
qapp.processEvents()
|
||||
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cua_so(qapp, tmp_path):
|
||||
"""`MainWindow` thật, đã dựng cả bốn trang dựng-lười.
|
||||
|
||||
Trang chưa dựng thì không có chữ nào để kiểm, mà đó lại đúng là nơi lỗi hay
|
||||
nằm — nên phải gọi `_ensure_page` cho hết.
|
||||
"""
|
||||
from cowork_local.presentation.shell.bootstrap import build_config, build_context
|
||||
from cowork_local.presentation.shell.main_window import MainWindow
|
||||
|
||||
duong_dan = tmp_path / "config.json"
|
||||
build_config(duong_dan)
|
||||
win = MainWindow(build_context(duong_dan))
|
||||
win.resize(1500, 950)
|
||||
for row in range(len(win._page_widgets)):
|
||||
win._ensure_page(row)
|
||||
yield win
|
||||
win.close()
|
||||
|
||||
|
||||
def test_doi_ngon_ngu_thi_khong_con_chu_ngon_ngu_cu(qapp, cua_so):
|
||||
"""Không chỗ nào giữ lại chữ của ngôn ngữ cũ sau khi đổi ngôn ngữ."""
|
||||
from cowork_local import i18n as i18n_mod
|
||||
|
||||
giu = [] # chống cấp lại id() cho wrapper mới
|
||||
goc = i18n_mod.tr
|
||||
ngon_ngu_cu = i18n_mod.get_language()
|
||||
i18n_mod.set_language("vi")
|
||||
_tieu_hoa(qapp)
|
||||
truoc = _chup(cua_so, giu)
|
||||
|
||||
def moc(key, **kw):
|
||||
return MOC + key
|
||||
|
||||
try:
|
||||
assert _thay_tr(moc, goc) > 0, "không thay được tr() ở module nào"
|
||||
i18n_mod.set_language("en")
|
||||
_tieu_hoa(qapp)
|
||||
sau = _chup(cua_so, giu)
|
||||
finally:
|
||||
_thay_tr(goc, moc)
|
||||
i18n_mod.set_language(ngon_ngu_cu)
|
||||
|
||||
# Chỉ tính chỗ mà chữ CÓ phụ thuộc ngôn ngữ: "OneDrive" giống nhau ở cả ba
|
||||
# ngôn ngữ nên không áp lại cũng không ai thấy khác.
|
||||
phu_thuoc_ngon_ngu = set()
|
||||
for v in i18n_mod.STRINGS.values():
|
||||
gia_tri = {v.get("en"), v.get("ja"), v.get("vi")}
|
||||
if len(gia_tri) > 1:
|
||||
phu_thuoc_ngon_ngu.update(x for x in gia_tri if x)
|
||||
|
||||
# Nhãn nhà cung cấp là DANH TÍNH lấy từ ``config.PROVIDER_LABELS``, không đi
|
||||
# qua ``tr()`` ở bất kỳ đâu (Agents Admin, model resolver, preview_ai đều
|
||||
# hiện nguyên dạng như nhau). Một trong số chúng tình cờ trùng chữ với bản
|
||||
# tiếng Anh của ``settings.group.openai`` — là tiêu đề mục trong Cài đặt,
|
||||
# một khoá khác hẳn. Không trừ ra thì phép đo báo oan chỗ này mãi mãi.
|
||||
# Muốn dịch tên nhà cung cấp thì phải sửa ``config.py`` và sửa đồng loạt cả
|
||||
# năm nơi đang đọc nó — đó là một quyết định sản phẩm, không phải lỗi dây nối.
|
||||
from cowork_local.config import PROVIDER_LABELS
|
||||
phu_thuoc_ngon_ngu -= set(PROVIDER_LABELS.values())
|
||||
|
||||
con_chu_cu = sorted(
|
||||
f"{cho[2]} :: {cho[1]}.{cho[3]} = {chu!r}"
|
||||
for cho, chu in sau.items()
|
||||
if cho in truoc and not chu.startswith(MOC) and chu in phu_thuoc_ngon_ngu
|
||||
)
|
||||
|
||||
assert con_chu_cu == [], (
|
||||
"những chỗ này không được áp lại khi đổi ngôn ngữ — dùng bind_text / "
|
||||
"bind_tip / bind_placeholder / bind_items / bind_dynamic của i18n thay "
|
||||
"cho setText(tr(...)) một lần:\n " + "\n ".join(con_chu_cu))
|
||||
|
||||
|
||||
def test_phep_do_thuc_su_quet_duoc_man_hinh(qapp, cua_so):
|
||||
"""Chốt độ phủ của chính phép đo trên.
|
||||
|
||||
Một hôm nào đó `_chup` đi sai (đúng lỗi `id()` bị cấp lại đã gặp) thì nó vẫn
|
||||
trả về một dict rỗng và test trên vẫn xanh — xanh vì không kiểm gì cả.
|
||||
"""
|
||||
giu = []
|
||||
anh = _chup(cua_so, giu)
|
||||
|
||||
assert len(anh) > 500, f"chỉ quét được {len(anh)} chỗ chữ — phép đo đang bị cắt"
|
||||
|
||||
|
||||
def test_binding_tu_don_khi_widget_bi_xoa(qapp):
|
||||
"""Ràng buộc không được giữ widget sống, và không được ném khi widget chết.
|
||||
|
||||
Đây là điều kiện để `bind_*` dùng được cho widget dựng lại liên tục (hàng
|
||||
Kanban, ô lịch) mà không tích thành rác.
|
||||
"""
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
from cowork_local import i18n as i18n_mod
|
||||
|
||||
lbl = QLabel()
|
||||
i18n_mod.bind_text(lbl, "app.settings")
|
||||
truoc = len(i18n_mod._bindings)
|
||||
assert truoc > 0
|
||||
|
||||
lbl.deleteLater()
|
||||
del lbl
|
||||
_tieu_hoa(qapp)
|
||||
i18n_mod._apply_bindings() # không được ném
|
||||
|
||||
assert len(i18n_mod._bindings) < truoc, "ràng buộc của widget đã xoá vẫn còn"
|
||||
@@ -0,0 +1,389 @@
|
||||
"""Đổi ngôn ngữ phải có lớp phủ "đang xử lý" — và chỉ khi nó đáng có.
|
||||
|
||||
``set_language()`` chạy ĐỒNG BỘ trên GUI thread: nó gọi lần lượt mọi callback
|
||||
đã đăng ký qua ``on_language_changed``. Trong lúc đó không có vòng lặp sự kiện,
|
||||
nên không ``QTimer`` nào bắn được và không thanh tiến trình nào quay được — lớp
|
||||
phủ phải được ``repaint()`` NGAY, không phải ``update()``.
|
||||
|
||||
Cùng lý do đó, lớp phủ không thể "đợi xem có chậm không rồi mới hiện": khi việc
|
||||
chặn đã bắt đầu thì không còn ai chạy để bật nó lên. Thứ duy nhất đo được là
|
||||
LẦN ĐỔI TRƯỚC. Vì vậy chính sách là: lần đầu trong phiên thì luôn hiện (thận
|
||||
trọng), các lần sau chỉ hiện khi lần trước đã đủ chậm để người ta kịp nhận ra.
|
||||
Máy nhanh vì thế nháy đúng một lần rồi thôi, thay vì nháy mãi mãi.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để dựng widget thật")
|
||||
|
||||
from cowork_local.presentation.shell import top_bar as top_bar_mod
|
||||
|
||||
|
||||
class _Ctx:
|
||||
"""Ngữ cảnh tối thiểu mà ``_switch_language``/``_open_settings`` cần."""
|
||||
|
||||
def __init__(self, language: str = "vi") -> None:
|
||||
self.config = type("C", (), {})()
|
||||
self.config.language = language
|
||||
self.config.active_provider = "p"
|
||||
self.config.theme = "dark"
|
||||
self.config.data = {}
|
||||
self.so_lan_luu = 0
|
||||
|
||||
def save(self) -> None:
|
||||
self.so_lan_luu += 1
|
||||
|
||||
|
||||
class _Any:
|
||||
"""Nuốt mọi lời gọi.
|
||||
|
||||
``_open_settings`` còn làm mới sidebar/cowork/workspace sau khi đổi ngôn
|
||||
ngữ; không thứ nào trong số đó là thứ đang được kiểm ở đây.
|
||||
"""
|
||||
|
||||
def __getattr__(self, name):
|
||||
return _Any()
|
||||
|
||||
def __call__(self, *a, **k):
|
||||
return _Any()
|
||||
|
||||
|
||||
def _dung(qapp):
|
||||
"""Một ``QMainWindow`` thật có mang ``TopBarMixin`` — đúng ``self`` mà mixin
|
||||
nhận trong sản phẩm, nên lớp phủ làm con của cửa sổ chính y như ``Toast``."""
|
||||
from PySide6.QtWidgets import QComboBox, QMainWindow
|
||||
|
||||
class W(top_bar_mod.TopBarMixin, QMainWindow):
|
||||
_THEME_ICONS = {"system": "monitor", "dark": "moon", "light": "sun"}
|
||||
|
||||
w = W()
|
||||
w.resize(900, 700)
|
||||
w.ctx = _Ctx()
|
||||
combo = QComboBox()
|
||||
for code in ("en", "ja", "vi"):
|
||||
combo.addItem(code.upper(), code)
|
||||
combo.setCurrentIndex(combo.findData("vi"))
|
||||
w.language_combo = combo
|
||||
pcombo = QComboBox()
|
||||
pcombo.addItem("P", "p")
|
||||
w.provider_combo = pcombo
|
||||
return w
|
||||
|
||||
|
||||
def _dong_ho(*moc):
|
||||
"""Đồng hồ giả trả lần lượt các mốc thời gian (giây) cho trước."""
|
||||
it = iter(moc)
|
||||
return lambda: next(it)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dat_ngon_ngu():
|
||||
"""Đặt ngôn ngữ hiện tại mà KHÔNG phát tán cho listener.
|
||||
|
||||
``set_language()`` gọi mọi callback đã đăng ký — trong một phiên pytest đầy
|
||||
đủ, danh sách đó chứa widget của các test khác. Ở đây chỉ cần biết ``tr()``
|
||||
đang trả về ngôn ngữ nào.
|
||||
"""
|
||||
from cowork_local import i18n as i18n_mod
|
||||
|
||||
cu = i18n_mod._current
|
||||
|
||||
def dat(code: str) -> None:
|
||||
i18n_mod._current = code
|
||||
|
||||
yield dat
|
||||
i18n_mod._current = cu
|
||||
|
||||
|
||||
# ---- lớp phủ bật TRƯỚC khi việc chặn bắt đầu -----------------------------
|
||||
|
||||
def test_overlay_hien_truoc_khi_set_language_chan(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""Đi qua đúng đường người dùng bấm: combo ngôn ngữ ở chân nav rail.
|
||||
|
||||
``isHidden()`` chứ không ``isVisible()``: cửa sổ cha chưa ``show()`` thì
|
||||
``isVisible()`` của widget con luôn False, đúng idiom mà
|
||||
``test_graphrag_busy_panel.py:41`` đã dùng.
|
||||
"""
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
ghi = {}
|
||||
|
||||
def probe(lang):
|
||||
ghi["hien"] = not w._lang_busy.isHidden()
|
||||
|
||||
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||
try:
|
||||
w.language_combo.setCurrentIndex(w.language_combo.findData("en"))
|
||||
w._on_language_changed(0)
|
||||
|
||||
assert ghi["hien"] is True, (
|
||||
"lớp phủ bật sau khi GUI thread đã bị chặn thì người dùng không thấy gì")
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_nguoi_dung_that_su_nhin_thay_lop_phu(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""Cửa sổ đã hiện thì ``isVisible()`` mới có nghĩa — chốt luôn mức đó."""
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
ghi = {}
|
||||
|
||||
def probe(lang):
|
||||
ghi["hien"] = w._lang_busy.isVisible()
|
||||
|
||||
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||
w.show()
|
||||
try:
|
||||
w._switch_language("en")
|
||||
|
||||
assert ghi["hien"] is True
|
||||
finally:
|
||||
w.hide()
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_overlay_tat_sau_khi_doi_xong(qapp, monkeypatch, dat_ngon_ngu):
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
monkeypatch.setattr(top_bar_mod, "set_language", lambda lang: None)
|
||||
try:
|
||||
w._switch_language("en")
|
||||
|
||||
assert w._lang_busy.isHidden() is True
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_callback_nem_loi_van_tat_overlay(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""``i18n/__init__.py:68`` chỉ nuốt ``RuntimeError``. Một listener ném loại
|
||||
khác sẽ xuyên qua ``set_language()`` — không có ``try/finally`` thì lớp phủ
|
||||
treo lại trên màn hình vĩnh viễn."""
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
|
||||
def probe(lang):
|
||||
raise ValueError("một listener nào đó hỏng")
|
||||
|
||||
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||
try:
|
||||
with pytest.raises(ValueError):
|
||||
w._switch_language("en")
|
||||
|
||||
assert w._lang_busy.isHidden() is True, "lớp phủ treo lại mãi mãi"
|
||||
assert w.language_combo.isEnabled() is True, "combo khoá lại mãi mãi"
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
# ---- chữ hiện bằng NGÔN NGỮ CŨ -------------------------------------------
|
||||
|
||||
def _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu: str, sang: str) -> str:
|
||||
"""Chữ trên lớp phủ tại thời điểm việc chặn bắt đầu.
|
||||
|
||||
Probe đổi ``_current`` thật, y như ``set_language()`` làm, nên nếu ``tr()``
|
||||
bị gọi SAU lượt đổi thì chữ sẽ ra ngôn ngữ mới và test đỏ.
|
||||
"""
|
||||
from cowork_local import i18n as i18n_mod
|
||||
|
||||
dat_ngon_ngu(tu)
|
||||
w = _dung(qapp)
|
||||
ghi = {}
|
||||
|
||||
def probe(lang):
|
||||
i18n_mod._current = lang
|
||||
ghi["chu"] = w._lang_busy.text()
|
||||
|
||||
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||
try:
|
||||
w._switch_language(sang)
|
||||
return ghi["chu"]
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_chu_hien_bang_ngon_ngu_cu(qapp, monkeypatch, dat_ngon_ngu):
|
||||
from cowork_local.i18n import STRINGS
|
||||
|
||||
chu = _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu="vi", sang="en")
|
||||
|
||||
assert chu == STRINGS["app.lang.switching"]["vi"]
|
||||
|
||||
|
||||
def test_chu_hien_bang_ngon_ngu_cu_chieu_nguoc(qapp, monkeypatch, dat_ngon_ngu):
|
||||
from cowork_local.i18n import STRINGS
|
||||
|
||||
chu = _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu="en", sang="vi")
|
||||
|
||||
assert chu == STRINGS["app.lang.switching"]["en"]
|
||||
|
||||
|
||||
def test_i18n_du_ba_ngon_ngu():
|
||||
from cowork_local.i18n import STRINGS
|
||||
|
||||
assert set(STRINGS["app.lang.switching"]) >= {"en", "ja", "vi"}
|
||||
|
||||
|
||||
# ---- hình dạng: phủ kín cửa sổ, khoá combo -------------------------------
|
||||
|
||||
def test_overlay_phu_kin_cua_so(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""Phủ nửa vời thì người dùng vẫn bấm được vào phần còn lại."""
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
monkeypatch.setattr(top_bar_mod, "set_language", lambda lang: None)
|
||||
try:
|
||||
w._switch_language("en")
|
||||
|
||||
assert w._lang_busy.geometry() == w.rect()
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_combo_bi_khoa_trong_luc_chan(qapp, monkeypatch, dat_ngon_ngu):
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
ghi = {}
|
||||
|
||||
def probe(lang):
|
||||
ghi["khoa"] = w.language_combo.isEnabled()
|
||||
|
||||
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||
try:
|
||||
w._switch_language("en")
|
||||
|
||||
assert ghi["khoa"] is False
|
||||
assert w.language_combo.isEnabled() is True, "phải mở khoá lại sau khi xong"
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
# ---- chính sách: chỉ nháy một lần trên máy nhanh -------------------------
|
||||
|
||||
def test_lan_dau_trong_phien_luon_hien(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""Chưa đo được gì thì nghiêng về phía hiện: một cái nháy còn hơn một lần
|
||||
đơ vài giây không lời giải thích."""
|
||||
from cowork_local.presentation.shell import busy_overlay as busy_mod
|
||||
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
ghi = {}
|
||||
monkeypatch.setattr(top_bar_mod, "set_language",
|
||||
lambda lang: ghi.setdefault("hien", not w._lang_busy.isHidden()))
|
||||
try:
|
||||
assert w._lang_busy_overlay()._last_ms is None
|
||||
w._switch_language("en")
|
||||
|
||||
assert ghi["hien"] is True
|
||||
assert busy_mod._NOTICEABLE_MS > 0
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_lan_truoc_nhanh_thi_khong_hien_nua(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""20 ms thì không ai kịp thấy mình đang đợi — hiện lớp phủ ở đó là tự tạo
|
||||
ra một cái nháy toàn màn hình."""
|
||||
from cowork_local.presentation.shell import busy_overlay as busy_mod
|
||||
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
monkeypatch.setattr(busy_mod, "perf_counter", _dong_ho(0.0, 0.02, 5.0, 5.02))
|
||||
ghi = []
|
||||
monkeypatch.setattr(top_bar_mod, "set_language",
|
||||
lambda lang: ghi.append(not w._lang_busy.isHidden()))
|
||||
try:
|
||||
w._switch_language("en")
|
||||
w._switch_language("ja")
|
||||
|
||||
assert ghi == [True, False]
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_lan_truoc_cham_thi_van_hien(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""Máy chậm / thư viện skill lớn: lần trước mất 1 giây thì lần này phải có
|
||||
lớp phủ, không đợi thêm lần nào nữa."""
|
||||
from cowork_local.presentation.shell import busy_overlay as busy_mod
|
||||
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
monkeypatch.setattr(busy_mod, "perf_counter", _dong_ho(0.0, 1.0, 5.0, 6.0))
|
||||
ghi = []
|
||||
monkeypatch.setattr(top_bar_mod, "set_language",
|
||||
lambda lang: ghi.append(not w._lang_busy.isHidden()))
|
||||
try:
|
||||
w._switch_language("en")
|
||||
w._switch_language("ja")
|
||||
|
||||
assert ghi == [True, True]
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
# ---- đường Cài đặt ▸ Chung -----------------------------------------------
|
||||
|
||||
def _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu: str):
|
||||
"""Chạy ``_open_settings`` với hộp thoại bị thay, trả về (window, ghi)."""
|
||||
w = _dung(qapp)
|
||||
w._apply_theme = lambda: None
|
||||
w.theme_btn = _Any()
|
||||
w.cowork = _Any()
|
||||
w.workspace = _Any()
|
||||
w.sidebar = _Any()
|
||||
ghi = {"goi": 0}
|
||||
|
||||
class _Dlg:
|
||||
def __init__(self, ctx, parent):
|
||||
self._ctx = ctx
|
||||
|
||||
def exec(self):
|
||||
self._ctx.config.language = ngon_ngu_sau_khi_luu
|
||||
return True
|
||||
|
||||
def probe(lang):
|
||||
ghi["goi"] += 1
|
||||
ghi["hien"] = not w._lang_busy.isHidden()
|
||||
|
||||
monkeypatch.setattr(top_bar_mod, "SettingsDialog", _Dlg)
|
||||
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||
return w, ghi
|
||||
|
||||
|
||||
def test_duong_cai_dat_cung_co_overlay(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""Cài đặt ▸ Chung đổi ngôn ngữ thì cũng chặn GUI thread y hệt combo."""
|
||||
dat_ngon_ngu("vi")
|
||||
w, ghi = _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu="en")
|
||||
try:
|
||||
w._open_settings()
|
||||
|
||||
assert ghi["goi"] == 1
|
||||
assert ghi["hien"] is True
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_duong_cai_dat_khong_nhay_khi_khong_doi_ngon_ngu(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""Bấm Lưu mà không đụng tới ngôn ngữ: ``set_language()`` là no-op
|
||||
(``i18n/__init__.py:62``), nên bật lớp phủ ở đây chỉ là một cái nháy."""
|
||||
dat_ngon_ngu("vi")
|
||||
w, ghi = _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu="vi")
|
||||
try:
|
||||
w._open_settings()
|
||||
|
||||
assert ghi["goi"] == 0, "gọi set_language() cho một lượt đổi không tồn tại"
|
||||
assert getattr(w, "_lang_busy", None) is None
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
# ---- màu lấy từ theme, không hardcode ------------------------------------
|
||||
|
||||
def test_mau_lay_tu_theme():
|
||||
"""Guardrail G4: ngoài ``theme/`` không file nào được đặt tên một màu."""
|
||||
from cowork_local.theme.qss import _TEMPLATE
|
||||
|
||||
assert "QWidget#busyOverlayPanel" in _TEMPLATE.template
|
||||
src = (Path(__file__).resolve().parents[2]
|
||||
/ "presentation" / "shell" / "busy_overlay.py").read_text(encoding="utf-8")
|
||||
assert "setStyleSheet" not in src
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Regression: Định tuyến / Tự chạy phải đổi chữ theo ngôn ngữ runtime (UI-20260907-01).
|
||||
|
||||
Người dùng dựng app ở một ngôn ngữ rồi đổi sang ngôn ngữ khác: chữ trên công tắc
|
||||
"Định tuyến" và ô "Tự chạy" ở tab Cowork vẫn đóng băng ở ngôn ngữ lúc dựng widget.
|
||||
Vì vậy MỌI test ở đây phải theo khuôn **dựng ở X → đổi sang Y → kiểm**: kiểm ngay
|
||||
lúc vừa dựng là vô nghĩa (widget luôn đúng lúc dựng, kể cả khi bản vá sai).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local import i18n
|
||||
from cowork_local.ui.routing_toggle import AutoRunToggle, RoutingToggle
|
||||
|
||||
|
||||
class FakeCtx:
|
||||
"""AppContext tối thiểu; ghi lại MỌI lần ghi để test chứng minh dịch lại không ghi đĩa."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Khởi tạo với sổ ghi rỗng."""
|
||||
self.writes: list = []
|
||||
|
||||
def project_routing_mode(self, surface: str) -> str:
|
||||
"""Chế độ định tuyến của bề mặt đang mở — cố định "manual" cho test."""
|
||||
return "manual"
|
||||
|
||||
def set_project_routing_mode(self, surface: str, mode: str) -> None:
|
||||
"""Ghi lại lần ghi chế độ định tuyến thay vì chạm đĩa."""
|
||||
self.writes.append((surface, mode))
|
||||
|
||||
def project_auto_run(self) -> bool:
|
||||
"""Trạng thái tự chạy của project — cố định True cho test."""
|
||||
return True
|
||||
|
||||
def set_project_auto_run(self, value: bool) -> None:
|
||||
"""Ghi lại lần ghi cờ tự chạy thay vì chạm đĩa."""
|
||||
self.writes.append(("auto_run", value))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def i18n_sach():
|
||||
"""Trả ngôn ngữ VÀ danh sách listener về nguyên trạng.
|
||||
|
||||
``i18n._listeners`` chỉ có đường vào (i18n/__init__.py:100); widget do test
|
||||
dựng sẽ nằm lại đó và bị gọi ở mọi test sau. Khôi phục để test hermetic.
|
||||
"""
|
||||
lang, listeners = i18n.get_language(), list(i18n._listeners)
|
||||
yield
|
||||
i18n._listeners[:] = listeners
|
||||
i18n.set_language(lang)
|
||||
|
||||
|
||||
def _chu_dinh_tuyen_phai_la(toggle: RoutingToggle, lang: str) -> None:
|
||||
"""Mọi chữ trên công tắc định tuyến phải khớp bản dịch của ``lang``.
|
||||
|
||||
So với ``i18n.STRINGS`` chứ không hard-code chuỗi: đổi từ ngữ sau này không
|
||||
được làm test đỏ oan.
|
||||
"""
|
||||
assert toggle._label.text() == i18n.STRINGS["routing.toggle_label"][lang]
|
||||
assert toggle._combo.toolTip() == i18n.STRINGS["routing.toggle_tooltip"][lang]
|
||||
for i, (_value, key) in enumerate(toggle._modes):
|
||||
assert toggle._combo.itemText(i) == i18n.STRINGS[key][lang], (
|
||||
f"item {i} sai ngôn ngữ"
|
||||
)
|
||||
|
||||
|
||||
def _on_dinh_layout(app, lap: int = 3) -> None:
|
||||
"""Ép Qt xử lý hết layout request đang xếp hàng để bề rộng widget ổn định."""
|
||||
from PySide6.QtCore import QEvent
|
||||
|
||||
for _ in range(lap):
|
||||
app.sendPostedEvents(None, QEvent.LayoutRequest)
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def _be_rong_o_chu(combo) -> int:
|
||||
"""Bề rộng vùng hiển thị chữ của combo (đã trừ khung và mũi tên)."""
|
||||
from PySide6.QtWidgets import QStyle, QStyleOptionComboBox
|
||||
|
||||
opt = QStyleOptionComboBox()
|
||||
opt.initFrom(combo)
|
||||
opt.currentText = combo.currentText()
|
||||
opt.editable = combo.isEditable()
|
||||
opt.frame = combo.hasFrame()
|
||||
opt.subControls = QStyle.SC_All
|
||||
return combo.style().subControlRect(
|
||||
QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxEditField, combo
|
||||
).width()
|
||||
|
||||
|
||||
def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_ja_roi_doi_sang_vi(qapp, i18n_sach):
|
||||
"""Dựng ở tiếng Nhật, đổi sang tiếng Việt → nhãn/tooltip/4 chế độ phải là tiếng Việt."""
|
||||
i18n.set_language("ja")
|
||||
toggle = RoutingToggle(FakeCtx(), "cowork")
|
||||
|
||||
i18n.set_language("vi")
|
||||
|
||||
_chu_dinh_tuyen_phai_la(toggle, "vi")
|
||||
|
||||
|
||||
def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_ja_roi_doi_sang_en(qapp, i18n_sach):
|
||||
"""Dựng ở tiếng Nhật, đổi sang tiếng Anh → toàn bộ chữ phải là tiếng Anh."""
|
||||
i18n.set_language("ja")
|
||||
toggle = RoutingToggle(FakeCtx(), "cowork")
|
||||
|
||||
i18n.set_language("en")
|
||||
|
||||
_chu_dinh_tuyen_phai_la(toggle, "en")
|
||||
|
||||
|
||||
def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_vi_roi_doi_sang_ja(qapp, i18n_sach):
|
||||
"""Chiều ngược lại: dựng ở tiếng Việt, đổi sang tiếng Nhật."""
|
||||
i18n.set_language("vi")
|
||||
toggle = RoutingToggle(FakeCtx(), "cowork")
|
||||
|
||||
i18n.set_language("ja")
|
||||
|
||||
_chu_dinh_tuyen_phai_la(toggle, "ja")
|
||||
|
||||
|
||||
def test_doi_ngon_ngu_khong_lam_doi_che_do_dinh_tuyen(qapp, i18n_sach):
|
||||
"""Test KHOÁ: dịch lại chỉ được đổi CHỮ, không đổi chế độ và không ghi đĩa.
|
||||
|
||||
Cột ``data`` ("off"/"auto"/"manual"/"fallback") được persist xuống đĩa; nếu
|
||||
ai đó dịch luôn cột đó, hoặc đổi ``retranslate()`` sang ``clear()+addItem()``,
|
||||
thì mode routing của mọi workspace bị âm thầm reset về "off".
|
||||
"""
|
||||
i18n.set_language("ja")
|
||||
ctx = FakeCtx()
|
||||
toggle = RoutingToggle(ctx, "cowork")
|
||||
|
||||
for lang in ("vi", "en", "ja"):
|
||||
i18n.set_language(lang)
|
||||
|
||||
assert toggle.current_mode() == "manual"
|
||||
assert toggle._combo.currentData() == "manual"
|
||||
assert ctx.writes == [], "đổi ngôn ngữ không được ghi lại chế độ định tuyến"
|
||||
|
||||
|
||||
def test_tu_chay_dung_ngon_ngu_sau_khi_doi_ngon_ngu(qapp, i18n_sach):
|
||||
"""Ô "Tự chạy" cũng phải đổi chữ, và không được tự đổi trạng thái tick."""
|
||||
i18n.set_language("ja")
|
||||
ctx = FakeCtx()
|
||||
toggle = AutoRunToggle(ctx)
|
||||
|
||||
i18n.set_language("vi")
|
||||
|
||||
assert toggle._chk.text() == i18n.STRINGS["routing.autorun_label"]["vi"]
|
||||
assert toggle._chk.toolTip() == i18n.STRINGS["routing.autorun_tooltip"]["vi"]
|
||||
|
||||
i18n.set_language("en")
|
||||
|
||||
assert toggle._chk.text() == i18n.STRINGS["routing.autorun_label"]["en"]
|
||||
assert toggle._chk.toolTip() == i18n.STRINGS["routing.autorun_tooltip"]["en"]
|
||||
assert toggle._chk.isChecked() is True
|
||||
assert ctx.writes == [], "đổi ngôn ngữ không được ghi lại cờ tự chạy"
|
||||
|
||||
|
||||
def test_bon_che_do_khong_bi_cat_chu_sau_khi_doi_ngon_ngu(qapp, i18n_sach):
|
||||
"""Dịch lại xong thì combo phải rộng ra theo chữ mới, không nuốt đuôi.
|
||||
|
||||
Combo dựng ở tiếng Nhật rồi đổi sang tiếng Việt: tên chế độ tiếng Việt dài
|
||||
hơn tên tiếng Nhật, nên nếu bề rộng còn bị khoá theo lần hiện đầu tiên thì
|
||||
"Thủ công"/"Dự phòng" (96px) không lọt ô chữ 89px và bị cắt đuôi.
|
||||
|
||||
Hai lớp assert, mỗi lớp bắt một nửa bản vá:
|
||||
|
||||
* chữ phải là tiếng Việt — bắt việc quên đăng ký ``on_language_changed``;
|
||||
* chữ phải lọt ô — bắt việc quên ``setSizeAdjustPolicy``, vì combo chỉ đo
|
||||
lại bề rộng khi chính sách là ``AdjustToContents``.
|
||||
|
||||
``host`` được nới rộng có chủ ý: ``addStretch`` phải còn chỗ trống để nhả ra
|
||||
cho combo, nếu không thì cả combo đúng lẫn combo sai đều kẹt ở bề rộng cũ và
|
||||
test không phân biệt được hai trạng thái.
|
||||
"""
|
||||
from PySide6.QtGui import QFontMetrics
|
||||
from PySide6.QtWidgets import QHBoxLayout, QWidget
|
||||
|
||||
i18n.set_language("ja")
|
||||
host = QWidget()
|
||||
lay = QHBoxLayout(host)
|
||||
toggle = RoutingToggle(FakeCtx(), "cowork")
|
||||
lay.addWidget(toggle)
|
||||
lay.addStretch(1)
|
||||
host.resize(600, 60)
|
||||
host.show()
|
||||
_on_dinh_layout(qapp)
|
||||
|
||||
i18n.set_language("vi")
|
||||
_on_dinh_layout(qapp)
|
||||
|
||||
combo = toggle._combo
|
||||
fm = QFontMetrics(combo.font())
|
||||
o_chu = _be_rong_o_chu(combo)
|
||||
chu_hien = [combo.itemText(i) for i in range(combo.count())]
|
||||
bi_cat = [t for t in chu_hien if fm.horizontalAdvance(t) > o_chu]
|
||||
host.close()
|
||||
|
||||
assert chu_hien == [i18n.STRINGS[key]["vi"] for _value, key in toggle._modes]
|
||||
assert not bi_cat, f"bị cắt chữ: {bi_cat} (ô chữ rộng {o_chu}px)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", [
|
||||
"routing.toggle_label", "routing.toggle_tooltip",
|
||||
"routing.autorun_label", "routing.autorun_tooltip",
|
||||
"routing.mode_off", "routing.mode_auto",
|
||||
"routing.mode_manual", "routing.mode_fallback",
|
||||
])
|
||||
def test_key_routing_co_du_ba_ngon_ngu(key):
|
||||
"""Test KHOÁ: thiếu một bản dịch thì ``tr()`` rơi về tiếng Anh, lỗi lại tái diễn."""
|
||||
for lang in ("en", "ja", "vi"):
|
||||
assert i18n.STRINGS[key].get(lang), f"{key} thiếu {lang}"
|
||||
+8
-4
@@ -183,10 +183,14 @@ QPushButton#navSettingsBtn {
|
||||
/* Padding stays at 0: the row lays its own icon and label out, so that
|
||||
the spacing does not change with the platform's button style. */
|
||||
padding: 0; text-align: left; border-radius: ${radius}px;
|
||||
/* No side margin: Settings reads as one more row under Dashboard/Giám sát,
|
||||
so its icon has to start on their x. A 6px margin put it at 14 — near
|
||||
enough the middle of the collapsed 54px rail to look centred. */
|
||||
margin: 2px 0px 6px 0px;
|
||||
/* No margin at all. A vertical one here was doing the opposite of what it
|
||||
read as: QVBoxLayout gave this button its geometry with the margin
|
||||
ignored (nav_bottom's bottom edge and this button's top edge measured
|
||||
the same y), while the PAINTER honoured it — so the only thing 10px/14px
|
||||
actually did was inset the hover fill, leaving Settings with a visibly
|
||||
shorter hover pill than the rows above. Spacing above/below the row is
|
||||
nav_rail's, which pins this button to the rows' own height. */
|
||||
margin: 0;
|
||||
}
|
||||
QPushButton#navSettingsBtn:hover { background: $nav_hover; color: $text; }
|
||||
QPushButton#navSettingsBtn:pressed { background: $active; }
|
||||
|
||||
@@ -324,6 +324,17 @@ QLabel#welcomeCardTitle { color: $text; font-weight: 600; }
|
||||
nen dac cua rieng minh thay vi chi la mot dong chu. */
|
||||
QWidget#graphBusy { background: $overlay; border: 1px solid $border_strong; border-radius: ${radius}px; }
|
||||
QWidget#graphBusy QLabel { color: $text; font-weight: 600; }
|
||||
|
||||
/* Lop phu ca cua so trong luc doi ngon ngu — cung ly do voi #graphBusy o tren:
|
||||
viec chan chay DONG BO tren GUI thread. Nen mo chu khong dac: nguoi dung con
|
||||
thay man hinh cu mo di, nen hieu la app dang lam viec chu khong phai da nhay
|
||||
sang mot man hinh khac. */
|
||||
QWidget#busyOverlay { background: rgba(0, 0, 0, 0.45); }
|
||||
QWidget#busyOverlayPanel {
|
||||
background: $overlay; border: 1px solid $border_strong;
|
||||
border-radius: ${radius}px;
|
||||
}
|
||||
QWidget#busyOverlayPanel QLabel { color: $text; font-weight: 600; }
|
||||
QLabel#faint { color: $text_faint; }
|
||||
QLabel#warning { color: $warning; font-weight: 600; }
|
||||
QLabel#error { color: $danger; font-weight: 600; }
|
||||
|
||||
+27
-13
@@ -390,21 +390,35 @@ class Co4ETab(
|
||||
|
||||
# ---- i18n -------------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề các mục và tooltip."""
|
||||
for key in self._sections:
|
||||
self._sync_section_arrow(key)
|
||||
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.wf_new_btn.setText(tr("co4e.new"))
|
||||
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề các mục và tooltip.
|
||||
|
||||
Cột trái và trang Flow Status không có mặt ở đây: mỗi widget bên đó tự
|
||||
ràng buộc khoá dịch của mình tại chỗ dựng (``i18n.bind_*``), nên panel
|
||||
dùng ở đâu cũng đúng ngôn ngữ mà không cần ai nhớ hộ.
|
||||
"""
|
||||
self.runs_btn.setText(tr("co4e.runs_tab"))
|
||||
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.runs_back_btn.setText(tr("co4e.back_to_flow"))
|
||||
self.runs_back_btn.setToolTip(tr("co4e.tt_back_to_flow"))
|
||||
self.runs_title.setText(tr("co4e.running_flows"))
|
||||
self.run_stop_btn.setText(tr("co4e.stop"))
|
||||
self.run_rename_btn.setText(tr("co4e.rename_run"))
|
||||
self.run_del_btn.setText(tr("co4e.delete_run"))
|
||||
self.run_clear_btn.setText(tr("co4e.clear_done"))
|
||||
# Flow toolbar.
|
||||
self.name_edit.setToolTip(tr("co4e.tt_flow_name"))
|
||||
self.add_step_btn.setText(tr("co4e.add"))
|
||||
self.add_step_btn.setToolTip(tr("co4e.tt_add_step"))
|
||||
self.save_btn.setText(tr("co4e.save"))
|
||||
self.save_btn.setToolTip(tr("co4e.tt_save"))
|
||||
self.save_tpl_btn.setToolTip(tr("co4e.tt_save_template"))
|
||||
self.mode_combo.setToolTip(tr("co4e.tt_mode"))
|
||||
# By position, from the same source the items were built from: the mode
|
||||
# string in each item's data is persisted, so it must survive a
|
||||
# translation untouched.
|
||||
for i, mode in enumerate(co4e.RUN_MODES):
|
||||
self.mode_combo.setItemText(i, tr(f"co4e.mode.{mode}"))
|
||||
self.run_btn.setToolTip(tr("co4e.tt_run"))
|
||||
# Not a plain tr(): this button reads "Dừng" while THIS flow is running.
|
||||
self._update_run_btn()
|
||||
# Step-config panel header. The toggle's tooltip names the action it
|
||||
# would perform, which depends on which way the panel is folded.
|
||||
self.config_title.setText(tr("co4e.config_title"))
|
||||
self.config_toggle_btn.setToolTip(tr(
|
||||
"co4e.tt_expand_config" if self._config_collapsed else "co4e.tt_collapse_config"))
|
||||
self._refresh_ws_folder_btn()
|
||||
self.runs_table.setHorizontalHeaderLabels([
|
||||
tr("co4e.runs_col_flow"), tr("co4e.runs_col_status"), tr("co4e.runs_col_steps"),
|
||||
|
||||
@@ -19,7 +19,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..i18n import bind_text, on_language_changed, tr
|
||||
from ..state import AppContext
|
||||
from .ext_connector_dialog import ExtConnectorEditDialog
|
||||
from .icons import icon
|
||||
@@ -160,7 +160,7 @@ class ConnectorsPanel(QWidget):
|
||||
self.connect_external_sw.toggled.connect(self._on_connect_external_toggled)
|
||||
lay.addWidget(self.connect_external_sw)
|
||||
|
||||
hint = QLabel(tr("settings.ext_hint"))
|
||||
hint = bind_text(QLabel(), "settings.ext_hint")
|
||||
hint.setObjectName("hint")
|
||||
hint.setWordWrap(True)
|
||||
lay.addWidget(hint)
|
||||
|
||||
@@ -99,6 +99,10 @@ class CoworkTab(ChatPanel):
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và các nút trên thanh công cụ."""
|
||||
# ChatPanel dựng phần chrome dùng chung (nhãn Agent, nút Nén, tiêu đề ba
|
||||
# bảng tệp) nhưng KHÔNG tự đăng ký dịch lại — lớp con là chỗ duy nhất có
|
||||
# đăng ký, nên bỏ dòng này là toàn bộ phần đó đứng ở ngôn ngữ lúc dựng.
|
||||
self._retranslate_base()
|
||||
self.refresh_title()
|
||||
self.skills_btn.setText(tr("cowork.skills_btn"))
|
||||
self.skills_btn.setToolTip(tr("cowork.skills_tooltip"))
|
||||
|
||||
+12
-1
@@ -23,7 +23,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..i18n import tr
|
||||
from ..i18n import on_language_changed, tr
|
||||
|
||||
|
||||
class RoutingToggle(QWidget):
|
||||
@@ -71,6 +71,11 @@ class RoutingToggle(QWidget):
|
||||
self._label.setObjectName("hint")
|
||||
self._combo = QComboBox()
|
||||
self._combo.setToolTip(tr("routing.toggle_tooltip"))
|
||||
# Mode names differ in length per language ("Thủ công" is wider than
|
||||
# "手動"), and a combo only re-measures itself under this policy — without
|
||||
# it the width stays frozen at the language the widget was built in and
|
||||
# the longer translation is cut off.
|
||||
self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
||||
# (data value, i18n key) — data is the persisted mode string. Order is
|
||||
# least-to-most autonomous, with Fallback (R03-T03) last because it is
|
||||
# the "only when something breaks" mode rather than a stronger Auto.
|
||||
@@ -88,6 +93,11 @@ class RoutingToggle(QWidget):
|
||||
self._combo.currentIndexChanged.connect(self._on_changed)
|
||||
lay.addWidget(self._label)
|
||||
lay.addWidget(self._combo)
|
||||
# Registered HERE, not by the four surfaces that embed this widget
|
||||
# (Cowork, Co4E, AI-Edit): every one of them had forgotten to, so the
|
||||
# control stayed frozen in the language it was built in. Owning it here
|
||||
# means no future embedder can forget either.
|
||||
on_language_changed(self.retranslate)
|
||||
|
||||
def current_mode(self) -> str:
|
||||
"""Chế độ định tuyến đang chọn; 'off' nếu chưa đặt."""
|
||||
@@ -149,6 +159,7 @@ class AutoRunToggle(QWidget):
|
||||
self.refresh()
|
||||
self._chk.toggled.connect(self._on_toggled)
|
||||
lay.addWidget(self._chk)
|
||||
on_language_changed(self.retranslate) # same reason as RoutingToggle
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Đọc lại trạng thái tự chạy của project đang mở lên ô đánh dấu."""
|
||||
|
||||
Reference in New Issue
Block a user