Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.
')
+
+ # What the screen is, plus a legend for the regions visible in the shot.
+ de = DESCRIPTIONS.get(slug)
+ intro = f'.*)', re.S)
+SHOT = re.compile(r'
.*?
', re.S)
+CONTROLS = re.compile(r'
.*? ', re.S)
+STYLE = re.compile(r"", re.S)
+SCRIPT = re.compile(r"", re.S)
+
+
+def bodies(html: str) -> dict[str, str]:
+ """slug -> the section's
…
, header excluded."""
+ out = {}
+ for m in SECTION.finditer(html):
+ b = BODY.search(m.group(2))
+ if b:
+ out[m.group(1)] = b.group(1).strip()
+ return out
+
+
+def norm(s: str) -> str:
+ return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", s)).strip()
+
+
+def main(argv: list[str]) -> int:
+ src_path = DOCS / (argv[0] if argv else "ui-audit.html")
+ if not src_path.exists():
+ print(f"khong thay {src_path}")
+ return 1
+
+ src = src_path.read_text(encoding="utf-8")
+ # Screenshots are re-embedded by the builder; keep the base64 out of here.
+ hand = bodies(re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "
![]()
", src))
+
+ sys.path.insert(0, str(REPO / "tools"))
+ import build_audit_page as B
+
+ # Render what the generator ALONE would produce, into a temp file, and treat
+ # every section that differs from it as hand-written.
+ with tempfile.TemporaryDirectory() as tmp:
+ keep_out, keep_hand = B.OUT, B.HAND_SECTIONS
+ B.OUT, B.HAND_SECTIONS = Path(tmp) / "gen-only.html", {}
+ try:
+ with contextlib.redirect_stdout(io.StringIO()):
+ B.main()
+ made = bodies(re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "
![]()
",
+ B.OUT.read_text(encoding="utf-8")))
+ finally:
+ B.OUT, B.HAND_SECTIONS = keep_out, keep_hand
+
+ detected = sorted(s for s, body in hand.items() if norm(body) != norm(made.get(s, "")))
+ slugs = [s for s in hand if s in set(detected) | set(KNOWN)]
+ new = [s for s in detected if s not in KNOWN]
+ gone = [s for s in KNOWN if s in hand and s not in detected]
+ if new:
+ print(f"phat hien them section viet tay: {new}")
+ if gone:
+ # Not an error: a hand section can be edited back to match the generator.
+ print(f"section trong KNOWN nay giong ban sinh: {gone}")
+
+ stored = {}
+ for slug in slugs:
+ body = hand[slug]
+ body = SHOT.sub("{{SHOT}}", body, count=1)
+ body = CONTROLS.sub("{{CONTROLS}}", body, count=1)
+ stored[slug] = body
+
+ # CSS rules and scripts the hand edits added. Compared against the builder's
+ # OWN constants, not its output — the output already carries the merge.
+
+ extra_css = "\n".join(
+ ln for ln in STYLE.search(src).group(1).splitlines()
+ if ln.strip() and ln not in B.CSS)
+ # Scripts already sitting INSIDE a stored section travel with it — collecting
+ # them again would bind every listener twice (the +/- steppers would then
+ # count by two). Only page-level scripts belong in EXTRA_JS.
+ gen_js = {norm(B.JS)}
+ in_section = "".join(stored.values())
+ extra_js = [j for j in SCRIPT.findall(src)
+ if norm(j) not in gen_js and j not in in_section]
+
+ parts = [
+ '"""Hand-written audit sections, extracted from 10-18.ui-audit.html.\n\n'
+ "GENERATED by tools/extract_handwritten.py — do not edit by hand; edit the\n"
+ "source HTML and re-run it. build_audit_page.py substitutes {{SHOT}} and\n"
+ '{{CONTROLS}} so those stay generated.\n"""\n',
+ "SECTIONS = {",
+ ]
+ for slug, body in stored.items():
+ parts.append(f" {slug!r}: {body!r},")
+ parts.append("}\n")
+ parts.append(f"EXTRA_CSS = {extra_css!r}\n")
+ parts.append("EXTRA_JS = [")
+ for j in extra_js:
+ parts.append(f" {j!r},")
+ parts.append("]\n")
+ OUT.write_text("\n".join(parts), encoding="utf-8")
+
+ print(f"section viet tay : {len(stored)}")
+ for slug, body in stored.items():
+ print(f" {slug:32} {len(body):>7,} ky tu"
+ f" shot={'{{SHOT}}' in body} ctl={'{{CONTROLS}}' in body}")
+ print(f"CSS them : {len(extra_css.splitlines())} dong")
+ print(f"script them : {len(extra_js)}")
+ print(f"ghi -> {OUT.relative_to(REPO)} ({OUT.stat().st_size / 1024:.0f} KB)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/tools/seed_demo_data.py b/tools/seed_demo_data.py
new file mode 100644
index 0000000..3c68cbb
--- /dev/null
+++ b/tools/seed_demo_data.py
@@ -0,0 +1,352 @@
+"""Populate a CoworkLocal config dir with realistic demo data, so the audit
+screenshots show a working app instead of empty lists.
+
+MUST be imported only AFTER ``USERPROFILE``/``HOME`` have been repointed at a
+sandbox — every store below resolves its path from ``CONFIG_DIR``, which is
+``Path.home()/".cowork_local"`` evaluated at import time. ``seed()`` asserts this.
+
+Where the app exposes a write API we call it (projects, history, tasks, skills,
+workflows, agents). Two stores are written as raw files on purpose:
+
+* **usage** and **audit** — their ``record()`` helpers always stamp
+ ``datetime.now()``, so they cannot backdate. A one-day spike makes a useless
+ chart, so the day files are written directly.
+* **co4e/run_history.json** — the manager only persists from a Qt signal
+ handler; there is no public save.
+"""
+from __future__ import annotations
+
+import json
+import os
+import random
+from datetime import datetime, timedelta
+from pathlib import Path
+
+rnd = random.Random(20260808) # fixed seed → identical screenshots every run
+
+PROJECTS = [
+ ("Trạm sạc EV — Cổng vận hành",
+ "Cổng nội bộ theo dõi trạm sạc: bản đồ trạng thái, cảnh báo, báo cáo doanh thu.",
+ "Trả lời bằng tiếng Việt. Backend FastAPI + PostgreSQL, frontend React.\n"
+ "Luôn viết test trước khi sửa logic thanh toán."),
+ ("Báo cáo tài chính Q3",
+ "Tự động gom số liệu từ Excel phòng ban, dựng bảng tổng hợp và slide trình bày.",
+ "Đơn vị tiền tệ mặc định VND. Làm tròn tới nghìn đồng.\n"
+ "Mọi con số phải truy được về file nguồn."),
+ ("Cổng tra cứu tài liệu ISO",
+ "Tìm kiếm ngữ nghĩa trên kho tài liệu ISO 9001 của công ty.",
+ "Ưu tiên trích dẫn đúng số hiệu điều khoản.\nKhông suy diễn ngoài tài liệu."),
+]
+
+CONVERSATIONS = [
+ (0, "Dựng API trạng thái trạm sạc", True, [
+ ("user", "Giúp mình dựng endpoint GET /stations/{id}/status trả về trạng thái realtime."),
+ ("assistant", "Mình đã đọc `app/models/station.py` và thấy sẵn bảng `station_heartbeat`.\n\n"
+ "Đề xuất: endpoint đọc heartbeat mới nhất trong 90 giây, quá hạn thì trả `offline`.\n"
+ "Mình tạo `app/api/stations.py` và test đi kèm nhé?"),
+ ("user", "Ừ làm đi."),
+ ("assistant", "Đã tạo 2 file:\n- `app/api/stations.py` — router + schema phản hồi\n"
+ "- `tests/test_stations.py` — 4 ca: online, offline, không tồn tại, heartbeat lỗi\n\n"
+ "Chạy `pytest tests/test_stations.py` → 4 passed."),
+ ]),
+ (0, "Sửa lỗi tính tiền sai khi phiên sạc bị ngắt", False, [
+ ("user", "Khách báo bị tính dư tiền khi rút sạc đột ngột giữa chừng."),
+ ("assistant", "Mình tìm thấy nguyên nhân ở `billing/session.py:142`: khi phiên kết thúc bất thường, "
+ "`ended_at` vẫn để `None` nên hàm tính giờ lấy `now()` — tiền chạy tiếp tới lúc job dọn chạy.\n\n"
+ "Sửa: lấy mốc heartbeat cuối cùng làm `ended_at`. Mình chuẩn bị diff nhé?"),
+ ]),
+ (1, "Gom số liệu doanh thu từ 6 file phòng ban", True, [
+ ("user", "Có 6 file Excel trong thư mục input, gom lại thành 1 bảng tổng hợp giúp mình."),
+ ("assistant", "Đã đọc cả 6 file. Lưu ý: `PB_Marketing.xlsx` để cột “Doanh thu” ở vị trí khác "
+ "(cột F thay vì D) và có 3 dòng trống ở cuối.\n\n"
+ "Mình đã chuẩn hoá và xuất `tonghop_q3.xlsx` — 1.284 dòng, tổng 42.7 tỷ VND."),
+ ]),
+ (1, "Dựng slide trình bày kết quả Q3", False, [
+ ("user", "Từ file tổng hợp, làm giúp bộ slide 8 trang."),
+ ("assistant", "Đã tạo `BaoCao_Q3.pptx`: tổng quan → doanh thu theo phòng ban → so sánh Q2 → "
+ "top 5 tăng trưởng → rủi ro → kế hoạch Q4."),
+ ]),
+ (2, "Đánh chỉ mục kho tài liệu ISO", False, [
+ ("user", "Quét thư mục docs/iso và dựng đồ thị tri thức."),
+ ("assistant", "Đã quét 148 tài liệu, dựng 1.902 node và 3.418 cạnh. "
+ "Có 12 tài liệu bị trùng số hiệu điều khoản, mình liệt kê ở `trung_lap.md`."),
+ ]),
+ (0, "Rà soát bảo mật trước khi lên production", False, [
+ ("user", "Kiểm tra giúp mình các chỗ có rủi ro bảo mật."),
+ ("assistant", "Tìm thấy 3 điểm cần xử lý:\n1. API key Stripe hard-code ở `config/payment.py:31`\n"
+ "2. Endpoint `/admin/export` không kiểm tra quyền\n"
+ "3. CORS đang để `allow_origins=[\"*\"]`"),
+ ]),
+]
+
+TASKS = [
+ # (title, status, priority, ai, enabled, offset_days, last_status)
+ ("Đồng bộ heartbeat trạm sạc mỗi 5 phút", "running", "high", False, True, 0, "success"),
+ ("Gửi báo cáo doanh thu hằng ngày 08:00", "scheduled", "medium", False, True, 1, "success"),
+ ("Quét lại chỉ mục ISO cuối tuần", "scheduled", "low", False, True, 3, "success"),
+ ("Dựng slide tổng kết Q3", "done", "high", True, False, -2, "success"),
+ ("Kiểm tra chứng chỉ TLS sắp hết hạn", "failed", "critical", False, True, -1, "failed"),
+ ("Chờ kế toán duyệt số liệu tháng 7", "waiting_input", "medium", False, False, -3, None),
+ ("Dọn log cũ hơn 90 ngày", "paused", "low", False, False, 7, "success"),
+ ("Xuất danh sách khách hàng B2B", "backlog", "low", True, False, 5, None),
+ ("Rà soát bảo mật trước release", "backlog", "high", False, False, 2, None),
+ ("Sao lưu cơ sở dữ liệu hằng đêm", "done", "critical", False, True, -1, "success"),
+]
+
+SKILLS = [
+ ("Rà soát bảo mật", "Quét mã tìm lộ khoá, thiếu kiểm tra quyền, cấu hình CORS lỏng.",
+ "Khi được gọi, hãy rà soát theo thứ tự:\n1. Bí mật hard-code (API key, mật khẩu, token)\n"
+ "2. Endpoint thiếu kiểm tra xác thực/phân quyền\n3. Cấu hình CORS, CSP, cookie\n"
+ "4. Truy vấn SQL ghép chuỗi\nMỗi phát hiện phải kèm file:dòng và cách sửa cụ thể."),
+ ("Chuẩn hoá bảng Excel", "Gom nhiều file Excel lệch cấu trúc về một bảng thống nhất.",
+ "Đọc từng file, dò vị trí cột theo tiêu đề chứ không theo chỉ số cột.\n"
+ "Bỏ dòng trống ở cuối. Báo rõ file nào lệch cấu trúc và lệch ra sao."),
+ ("Viết test trước", "Sinh test cho hành vi mong muốn trước khi sửa mã.",
+ "Trước khi sửa logic, viết test mô tả hành vi đúng.\n"
+ "Chạy test để xác nhận nó FAIL, rồi mới sửa mã cho nó PASS."),
+ ("Tóm tắt tài liệu ISO", "Tóm tắt điều khoản ISO kèm trích dẫn số hiệu.",
+ "Luôn trích dẫn số hiệu điều khoản. Không suy diễn ngoài văn bản.\n"
+ "Nếu tài liệu mâu thuẫn nhau, nêu rõ cả hai và chỉ ra chỗ mâu thuẫn."),
+ ("Dựng slide từ số liệu", "Chuyển bảng số liệu thành bộ slide trình bày.",
+ "Mỗi slide một thông điệp. Biểu đồ phải có nhãn trục và đơn vị.\n"
+ "Slide cuối luôn là hành động tiếp theo."),
+]
+
+CO4E_AGENTS = [
+ ("Phân tích yêu cầu", "ANALYST", "search",
+ "Đọc mô tả yêu cầu, bóc tách thành danh sách hạng mục rõ ràng, đánh dấu chỗ còn mơ hồ.",
+ ["Rà soát bảo mật"]),
+ ("Thiết kế giải pháp", "ARCHITECT", "flow",
+ "Từ danh sách hạng mục, đề xuất kiến trúc và các bước triển khai, nêu rõ đánh đổi.", []),
+ ("Lập trình viên", "CODER", "code",
+ "Hiện thực theo thiết kế. Viết test trước khi sửa logic nghiệp vụ.", ["Viết test trước"]),
+ ("Kiểm thử", "TESTER", "shield",
+ "Chạy test, đọc log lỗi, báo cáo ca nào hỏng và vì sao.", ["Rà soát bảo mật"]),
+ ("Soạn tài liệu", "WRITER", "book",
+ "Viết tài liệu hướng dẫn sử dụng từ mã nguồn và test.", ["Tóm tắt tài liệu ISO"]),
+]
+
+WORKFLOWS = [
+ ("Quy trình phát triển tính năng",
+ ["Phân tích yêu cầu", "Thiết kế giải pháp", "Lập trình viên", "Kiểm thử", "Soạn tài liệu"]),
+ ("Rà soát bảo mật định kỳ", ["Phân tích yêu cầu", "Kiểm thử"]),
+ ("Dựng báo cáo từ Excel", ["Phân tích yêu cầu", "Lập trình viên", "Soạn tài liệu"]),
+]
+
+AUDIT_EVENTS = [
+ ("tool_call", "read_file", True, "app/models/station.py (2.1 KB)"),
+ ("tool_call", "write_file", True, "app/api/stations.py — tạo mới, 84 dòng"),
+ ("tool_call", "run_command", True, "pytest tests/test_stations.py → 4 passed"),
+ ("tool_call", "fetch_url", True, "https://docs.python.org/3/library/asyncio.html"),
+ ("permission", "run_command", True, "Người dùng duyệt: npm install --save-dev vitest"),
+ ("permission", "write_file", False, "Người dùng từ chối: ghi đè .env"),
+ ("security_block", "path_outside_sandbox", False, "Chặn đọc C:\\Users\\NamPDT\\Documents\\personal.xlsx"),
+ ("security_block", "network_blocked", False, "Chặn kết nối ra 203.0.113.44:8080 (không trong danh sách cho phép)"),
+ ("security_block", "dangerous_command", False, "Chặn lệnh: rm -rf / --no-preserve-root"),
+ ("security_block", "secret_in_output", False, "Phát hiện chuỗi giống API key trong đầu ra, đã che"),
+ ("mcp_call", "filesystem.list_directory", True, "docs/iso → 148 mục"),
+ ("mcp_call", "jira.search_issues", True, "project=EV AND status=Open → 23 issue"),
+ ("mcp_call", "postgres.query", True, "SELECT count(*) FROM station_heartbeat → 1.284.902"),
+ ("mcp_call", "jira.create_issue", False, "401 Unauthorized — API token hết hạn"),
+ ("mcp_call", "filesystem.read_file", True, "docs/iso/9001-2015.pdf (4.2 MB)"),
+]
+
+MODELS = [("ollama", "qwen2.5-coder:7b"), ("ollama", "llama3.1:8b"), ("openai", "gpt-4o-mini")]
+LABELS = ["Dựng API trạng thái trạm sạc", "Sửa lỗi tính tiền sai", "Gom số liệu doanh thu",
+ "Dựng slide trình bày", "Đánh chỉ mục ISO", "Rà soát bảo mật"]
+
+
+def _iso(dt: datetime) -> str:
+ return dt.isoformat(timespec="seconds")
+
+
+def seed(days: int = 45) -> dict:
+ """Fill the (sandboxed) config dir. Returns a per-store count summary."""
+ from cowork_local.config import CONFIG_DIR
+ home = str(Path.home())
+ assert str(CONFIG_DIR).startswith(home), "refusing to seed outside the sandboxed HOME"
+ assert "cowork-capture-" in home or "cowork-seed-" in home, (
+ f"HOME ({home}) does not look like a capture sandbox — refusing to seed")
+
+ from cowork_local.core import admin_agents, co4e, history, projects, skills, tasks
+
+ out: dict[str, int] = {}
+ now = datetime.now().replace(hour=14, minute=32, second=0, microsecond=0)
+
+ # ---- projects ---------------------------------------------------------
+ made = []
+ for name, desc, instr in PROJECTS:
+ p = projects.new_project(name, description=desc, instructions=instr)
+ p.workspace_dir().mkdir(parents=True, exist_ok=True)
+ # a few files so the Folder tab's tree isn't bare
+ for rel in ("README.md", "src/main.py", "src/billing/session.py",
+ "tests/test_stations.py", "docs/ghi-chu.md"):
+ f = p.workspace_dir() / rel
+ f.parent.mkdir(parents=True, exist_ok=True)
+ if not f.exists():
+ f.write_text(f"# {rel}\n\n(nội dung mẫu cho ảnh chụp)\n", encoding="utf-8")
+ made.append(p)
+ out["projects"] = len(made)
+
+ # ---- conversations ----------------------------------------------------
+ hist_root = CONFIG_DIR / "history"
+ hist_root.mkdir(parents=True, exist_ok=True)
+ n_conv = 0
+ for i, (pi, title, pinned, msgs) in enumerate(CONVERSATIONS):
+ proj = made[pi]
+ created = _iso(now - timedelta(days=i * 2 + 1, hours=i * 3))
+ sid = (now - timedelta(days=i * 2 + 1)).strftime("%Y%m%d-%H%M%S-") + f"{i:03d}"
+ payload = [{"role": r, "content": c} for r, c in msgs]
+ for directory in (hist_root, proj.workspace_dir() / ".cowork_history"):
+ directory.mkdir(parents=True, exist_ok=True)
+ path = history.save_conversation(
+ directory, "cowork", sid, payload, title=title,
+ created=created, project_id=proj.project_id)
+ if pinned:
+ history.set_pinned(path, True)
+ # stagger mtime so the sidebar's newest-first order looks real
+ ts = (now - timedelta(days=i * 2 + 1)).timestamp()
+ os.utime(path, (ts, ts))
+ n_conv += 1
+ out["conversations"] = n_conv
+
+ # ---- scheduled tasks --------------------------------------------------
+ for title, status, prio, ai, enabled, off, last in TASKS:
+ t = tasks.new_task(
+ title=title, status=status, priority=prio, is_ai_generated=ai,
+ project_id=made[0].project_id, provider="ollama", model="qwen2.5-coder:7b",
+ description=f"Tác vụ tự động: {title.lower()}.",
+ schedule={"enabled": enabled,
+ "run_at": (now + timedelta(days=off)).strftime("%Y-%m-%d %H:%M"),
+ "repeat_type": "daily" if enabled else "none"},
+ logs={"last_status": last or "", "last_run_id": "run-demo" if last else "",
+ "last_error": "Chứng chỉ hết hạn 2026-08-06" if last == "failed" else ""},
+ )
+ if last:
+ t["runs"] = [{"run_id": f"r{n}", "status": last,
+ "finished_at": (now - timedelta(days=n)).strftime("%Y-%m-%d %H:%M"),
+ "error": "Chứng chỉ hết hạn" if last == "failed" else None}
+ for n in range(1, 4)]
+ tasks.save_task(t)
+ out["tasks"] = len(TASKS)
+
+ # ---- skills -----------------------------------------------------------
+ for name, desc, instr in SKILLS:
+ skills.save_skill(skills.Skill(name=name, description=desc,
+ instructions=instr, enabled=True))
+ out["skills"] = len(SKILLS)
+
+ # ---- Co4E agents ------------------------------------------------------
+ for name, role, icon, instr, sk in CO4E_AGENTS:
+ a = co4e.new_custom_agent(name)
+ a.role, a.icon, a.instructions, a.skills = role, icon, instr, sk
+ a.model = "qwen2.5-coder:7b"
+ co4e.save_custom_agent(a)
+ out["co4e_agents"] = len(CO4E_AGENTS)
+
+ # ---- Co4E workflows ---------------------------------------------------
+ wfs = []
+ for name, steps in WORKFLOWS:
+ wf = co4e.new_workflow(name)
+ prev = None
+ for j, label in enumerate(steps):
+ node = co4e.Node(id=co4e.new_node_id(), x=60.0 + j * 250, y=140.0 + (j % 2) * 120,
+ data=co4e.Step(label=label, role="AGENT",
+ instructions=f"{label}: thực hiện phần việc của mình "
+ f"rồi chuyển kết quả cho bước sau.",
+ model="qwen2.5-coder:7b"))
+ wf.nodes.append(node)
+ if prev:
+ wf.edges.append(co4e.Edge(id=co4e.new_edge_id(prev, node.id),
+ source=prev, target=node.id))
+ prev = node.id
+ co4e.save_workflow(wf)
+ wfs.append(wf)
+ out["workflows"] = len(wfs)
+
+ # ---- Co4E run history (no public save — written directly) -------------
+ runs = []
+ specs = [("done", 5, 5, 0), ("done", 2, 2, 1), ("error", 3, 5, 2),
+ ("done", 3, 3, 3), ("stopped", 1, 5, 4), ("done", 5, 5, 6)]
+ for k, (status, done, total, ago) in enumerate(specs, 1):
+ wf = wfs[k % len(wfs)]
+ runs.append({
+ "id": f"run{k}", "wf_id": wf.id, "name": wf.name,
+ "total": total, "done": done, "status": status,
+ "plan_mode": False, "manual": False, "created_by": "local",
+ "created_at": (now - timedelta(days=ago, hours=k)).strftime("%Y-%m-%d %H:%M"),
+ "error": "Bước “Kiểm thử” trả về mã lỗi 1" if status == "error" else "",
+ "node_status": {n.id: ("done" if i < done else
+ ("error" if status == "error" and i == done else "idle"))
+ for i, n in enumerate(wf.nodes)},
+ "wf": co4e.workflow_to_dict(wf),
+ "out_dir": str(made[0].workspace_dir()),
+ "project_id": made[0].project_id,
+ })
+ hp = CONFIG_DIR / "co4e" / "run_history.json"
+ hp.parent.mkdir(parents=True, exist_ok=True)
+ hp.write_text(json.dumps({"runs": runs}, ensure_ascii=False, indent=2), encoding="utf-8")
+ out["co4e_runs"] = len(runs)
+
+ # ---- usage day files (record() cannot backdate) -----------------------
+ usage_dir = CONFIG_DIR / "usage"
+ usage_dir.mkdir(parents=True, exist_ok=True)
+ n_usage = 0
+ for d in range(days):
+ day = now - timedelta(days=days - 1 - d)
+ # a workday rhythm: quiet weekends, a gentle upward trend
+ weekend = day.weekday() >= 5
+ turns = rnd.randint(1, 3) if weekend else rnd.randint(4, 11) + d // 12
+ lines = []
+ for _ in range(turns):
+ prov, model = rnd.choice(MODELS)
+ lines.append(json.dumps({
+ "ts": _iso(day.replace(hour=rnd.randint(8, 18), minute=rnd.randint(0, 59))),
+ "source": rnd.choice(["cowork", "cowork", "task", "co4e"]),
+ "label": rnd.choice(LABELS), "provider": prov, "model": model,
+ "in": rnd.randint(1200, 9000), "out": rnd.randint(300, 3200),
+ "cache": rnd.randint(0, 4200), "estimated": False,
+ "account": "local", "machine": "DESKTOP-DEMO",
+ }, ensure_ascii=False))
+ n_usage += 1
+ (usage_dir / f"{day:%Y-%m-%d}.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8")
+ out["usage_events"] = n_usage
+
+ # ---- audit day files (drives Security / MCP / Action tables) ----------
+ audit_dir = CONFIG_DIR / "audit"
+ audit_dir.mkdir(parents=True, exist_ok=True)
+ n_audit = 0
+ roles = ["cowork", "code", "schedule", "graphrag", "security"]
+ for d in range(14):
+ day = now - timedelta(days=13 - d)
+ lines = []
+ for _ in range(rnd.randint(4, 9)):
+ kind, name, ok, detail = rnd.choice(AUDIT_EVENTS)
+ lines.append(json.dumps({
+ "ts": _iso(day.replace(hour=rnd.randint(8, 19), minute=rnd.randint(0, 59))),
+ "kind": kind, "agent_role": rnd.choice(roles), "name": name,
+ "ok": ok, "detail": detail,
+ "account": "local", "role": "admin", "machine": "DESKTOP-DEMO",
+ }, ensure_ascii=False))
+ n_audit += 1
+ (audit_dir / f"{day:%Y-%m-%d}.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8")
+ out["audit_events"] = n_audit
+
+ # ---- admin agents -----------------------------------------------------
+ admin_dir = admin_agents.agents_admin_dir("")
+ admin_dir.mkdir(parents=True, exist_ok=True)
+ for name, kind in [("Trợ giúp trong app", "help"), ("Tìm kiếm tài khoản", "search"),
+ ("Phân tích giám sát", "monitor"), ("Cowork mặc định", "cowork"),
+ ("Hỏi đáp GraphRAG", "graphrag"), ("Lập lịch thông minh", "schedule"),
+ ("Kiểm tra lệnh nguy hiểm", "security")]:
+ a = admin_agents.new_agent(name, task_kind=kind, provider="ollama",
+ model="qwen2.5-coder:7b", updated_by="local",
+ prompt=f"Bạn phụ trách chức năng “{kind}” của ứng dụng.")
+ admin_agents.save_agent(a, admin_dir)
+ out["admin_agents"] = 7
+
+ return out
+
+
+if __name__ == "__main__":
+ raise SystemExit("Import and call seed() from capture_screens.py — it needs the sandboxed HOME.")
diff --git a/ui/accounts_tab.py b/ui/accounts_tab.py
index e5d5122..da4c0c8 100644
--- a/ui/accounts_tab.py
+++ b/ui/accounts_tab.py
@@ -28,7 +28,7 @@ from ..core import usage_tracker as ut
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
-from .icons import icon
+from .icons import DOT_AMBER, icon
from .widgets import fmt_tokens
_PERIODS = ("day", "week", "month", "year")
@@ -367,7 +367,7 @@ class AccountsTab(QWidget):
label = f"{acc.display_name or acc.username} ({acc.username}) — {tr(f'accounts.role.{acc.role}')}"
item = QTreeWidgetItem([label])
if is_subadmin: # subadmin badge → star icon instead of a ★ glyph
- item.setIcon(0, icon("star", color="#f59e0b"))
+ item.setIcon(0, icon("star", color=DOT_AMBER))
item.setData(0, Qt.UserRole, ("account", acc.username))
if acc.email:
item.setToolTip(0, acc.email)
diff --git a/ui/agents_admin_tab.py b/ui/agents_admin_tab.py
index ef6ab2d..db02b70 100644
--- a/ui/agents_admin_tab.py
+++ b/ui/agents_admin_tab.py
@@ -8,18 +8,22 @@ accounts folder, so every machine pointed at the same share picks changes up
automatically (OneDrive/network sync) — non-admin machines only ever READ the
catalog (their pickers in Cowork / Schedule Task list the enabled agents).
-The "Check" button probes each agent's effective provider (``check_agent``)
-and shows an operational-status column (🟢 reachable / 🔴 error) separate from
-the Enabled config flag.
+The header's "Kiểm tra tất cả" icon probes each agent's effective provider
+(``check_agent``) and shows the result as the Trạng thái pill (OK / error /
+checking…) — separate from the per-row Kích hoạt switch, which only toggles
+the config flag.
"""
from __future__ import annotations
+from datetime import datetime
from typing import Dict, List, Optional
+from PySide6.QtCore import QSize, Qt
+from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap
from PySide6.QtWidgets import (
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
- QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, QTableWidget,
- QTableWidgetItem, QVBoxLayout, QWidget,
+ QHeaderView, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton,
+ QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
)
from ..config import PROVIDER_LABELS
@@ -27,10 +31,62 @@ from ..core import admin_agents, preview_ai
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
-from .icons import icon, dot_icon, DOT_GREEN, DOT_RED, DOT_AMBER, DOT_GREY
+from .icons import icon
+from .widgets import ToggleSwitch, badge_pill_widget
_PROVIDER_DEFAULT = "" # "" = each machine's own active provider (unchanged default)
+# Identity colour (avatar circle) + badge tone per task_kind — same "fixed
+# colour regardless of theme" convention as monitoring_tab.py's per-agent
+# avatars, plus a badge object name (theme.py) for the Vai trò pill. Seven
+# kinds, seven distinct tones — no two kinds share a badge colour.
+_KIND_COLOUR = {
+ "search": "#8A8886", "monitor": "#FFB900", "cowork": "#0078D4",
+ "graphrag": "#8764B8", "schedule": "#107C10", "security": "#D13438",
+ "help": "#E3008C",
+}
+_KIND_BADGE = {
+ "search": "badgeNeutral", "monitor": "badgeWarn", "cowork": "badge",
+ "graphrag": "badgePurple", "schedule": "badgeSuccess", "security": "badgeDanger",
+ "help": "badgePink",
+}
+_STATUS_BADGE = {
+ "unchecked": "badgeNeutral", "checking": "badgeWarn",
+ "ok": "badgeSuccess", "bad": "badgeDanger",
+}
+
+
+def _initials(name: str) -> str:
+ return "".join(w[0] for w in name.split() if w)[:2].upper()
+
+
+def _fmt_updated(ts: str) -> str:
+ """"dd/MM hh:mm" — same Cập nhật/Thời gian format as Monitoring's
+ Bảo mật/MCP/Hành động tables (``_fmt_event_time`` in monitoring_tab.py)."""
+ try:
+ dt = datetime.fromisoformat(ts)
+ except (TypeError, ValueError):
+ return ts
+ return dt.strftime("%d/%m %H:%M")
+
+
+def _kind_avatar_icon(kind: str, name: str, size: int = 20) -> QIcon:
+ pm = QPixmap(size, size)
+ pm.fill(Qt.transparent)
+ p = QPainter(pm)
+ p.setRenderHint(QPainter.Antialiasing)
+ p.setPen(Qt.NoPen)
+ p.setBrush(QColor(_KIND_COLOUR.get(kind, "#0078D4")))
+ p.drawEllipse(0, 0, size, size)
+ font = QFont()
+ font.setPixelSize(max(7, size // 2))
+ font.setBold(True)
+ p.setFont(font)
+ p.setPen(QColor("#FFFFFF"))
+ p.drawText(pm.rect(), Qt.AlignCenter, _initials(name))
+ p.end()
+ return QIcon(pm)
+
class AgentEditDialog(QDialog):
"""Add/Edit one admin agent. The provider/model pickers are drop-lists,
@@ -161,41 +217,79 @@ class AgentsAdminTab(QWidget):
self._check_workers: List[AgentWorker] = []
root = QVBoxLayout(self)
+
+ hdr = QHBoxLayout()
+ self._title_lbl = QLabel()
+ self._title_lbl.setStyleSheet("font-weight:700; font-size:14px;")
+ hdr.addWidget(self._title_lbl)
+ hdr.addStretch(1)
+ # "Kiểm tra tất cả" keeps the real _check_all action reachable without
+ # competing with the 2 primary header buttons (Làm mới / + Thêm) — a
+ # flat, secondary-styled button rather than a 3rd primary one, but
+ # still labelled: an icon-only button here was a mystery button.
+ self.check_btn = QPushButton()
+ self.check_btn.setIcon(icon("check"))
+ self.check_btn.setFlat(True)
+ self.check_btn.setCursor(Qt.PointingHandCursor)
+ self.check_btn.clicked.connect(self._check_all)
+ hdr.addWidget(self.check_btn)
+ self.refresh_btn = QPushButton()
+ self.refresh_btn.setIcon(icon("refresh"))
+ self.refresh_btn.setCursor(Qt.PointingHandCursor)
+ self.refresh_btn.clicked.connect(self.refresh)
+ hdr.addWidget(self.refresh_btn)
+ self.add_btn = QPushButton()
+ self.add_btn.setIcon(icon("plus"))
+ self.add_btn.setObjectName("primary")
+ self.add_btn.setCursor(Qt.PointingHandCursor)
+ self.add_btn.clicked.connect(self._add)
+ hdr.addWidget(self.add_btn)
+ root.addLayout(hdr)
+
self._hint = QLabel("")
self._hint.setObjectName("hint")
self._hint.setWordWrap(True)
root.addWidget(self._hint)
- self.table = QTableWidget(0, 6)
+ self.table = QTableWidget(0, 7)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
- self.table.setSelectionBehavior(QTableWidget.SelectRows)
+ # Sửa/Xoá/Kích hoạt are now per-row widgets (button/switch), and Vai
+ # trò/Trạng thái are pill cell widgets — none of those track a row
+ # across a re-sort (a cell widget stays pinned to its screen position,
+ # not to the item that moves — see monitoring_tab.py's _EventTable for
+ # the same lesson learned the hard way), so this table doesn't sort.
+ self.table.setSelectionMode(QTableWidget.NoSelection)
self.table.verticalHeader().setVisible(False)
- self.table.horizontalHeader().setStretchLastSection(True)
- self.table.setSortingEnabled(True)
+ # Fixed row height — letting Qt auto-size rows from content fights
+ # with the toggle switch / badge cell widgets: their layout settles on
+ # a stale, oversized geometry from an intermediate sizing pass, which
+ # then overlaps neighbouring rows (same bug _EventTable hit for its
+ # Hành động pill, fixed there the same way).
+ self.table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
+ self.table.verticalHeader().setDefaultSectionSize(32)
+ self.table.setIconSize(QSize(20, 20))
+ header = self.table.horizontalHeader()
+ header.setStretchLastSection(False)
+ for col in (0, 6):
+ header.setSectionResizeMode(col, QHeaderView.ResizeToContents)
+ # Vai trò/Trạng thái (1, 4) are pill cell widgets — ResizeToContents
+ # only measures QTableWidgetItem content, so it kept fighting refresh()'s
+ # manual sizeHint()-based setColumnWidth() and clipping the pill text.
+ # Interactive leaves whatever width refresh() sets alone.
+ for col in (1, 4):
+ header.setSectionResizeMode(col, QHeaderView.Interactive)
+ header.setSectionResizeMode(2, QHeaderView.Stretch) # Model
+ header.setSectionResizeMode(5, QHeaderView.ResizeToContents)
root.addWidget(self.table, 1)
- btns = QHBoxLayout()
- self.add_btn = QPushButton()
- self.add_btn.setIcon(icon("plus"))
- self.add_btn.setObjectName("primary")
- self.add_btn.clicked.connect(self._add)
- self.edit_btn = QPushButton()
- self.edit_btn.setIcon(icon("edit"))
- self.edit_btn.clicked.connect(self._edit)
- self.del_btn = QPushButton()
- self.del_btn.setIcon(icon("trash"))
- self.del_btn.clicked.connect(self._delete)
- self.check_btn = QPushButton()
- self.check_btn.setIcon(icon("check"))
- self.check_btn.clicked.connect(self._check_all)
- for b in (self.add_btn, self.edit_btn, self.del_btn):
- btns.addWidget(b)
- btns.addStretch(1)
- btns.addWidget(self.check_btn)
- root.addLayout(btns)
-
+ # on_language_changed() already invokes _retranslate() once immediately
+ # (see i18n.py) — calling it again here was a harmless no-op back when
+ # every column was a plain QTableWidgetItem, but now refresh() also
+ # populates cell WIDGETS (toggle switch, pills, row actions): running
+ # it twice back-to-back with no event-loop turn in between left the
+ # first pass's widgets replaced but not yet deleted, so they briefly
+ # painted overlapping the second pass's row 0.
on_language_changed(self._retranslate)
- self._retranslate()
# ---- storage ---------------------------------------------------------
def _dir(self):
@@ -205,17 +299,6 @@ class AgentsAdminTab(QWidget):
conf = self.ctx.config.provider_conf(self.ctx.config.active_provider)
return conf.get("model", "")
- def _selected_agent(self) -> Optional[admin_agents.AdminAgent]:
- row = self.table.currentRow()
- if row < 0:
- return None
- item = self.table.item(row, 0)
- if item is None:
- return None
- from PySide6.QtCore import Qt
-
- return admin_agents.load_agent(item.data(Qt.UserRole), self._dir())
-
# ---- CRUD -------------------------------------------------------------
def _add(self) -> None:
dlg = AgentEditDialog(self, ctx=self.ctx, default_model_hint=self._default_model_hint())
@@ -232,8 +315,8 @@ class AgentsAdminTab(QWidget):
admin_agents.save_agent(agent, self._dir())
self.refresh()
- def _edit(self) -> None:
- agent = self._selected_agent()
+ def _edit_agent(self, agent_id: str) -> None:
+ agent = admin_agents.load_agent(agent_id, self._dir())
if agent is None:
return
dlg = AgentEditDialog(self, ctx=self.ctx, agent=agent,
@@ -243,7 +326,6 @@ class AgentsAdminTab(QWidget):
fields = dlg.result_fields()
if not fields["name"]:
return
- from datetime import datetime
agent.name = fields["name"]
agent.task_kind = fields["task_kind"]
@@ -256,8 +338,8 @@ class AgentsAdminTab(QWidget):
admin_agents.save_agent(agent, self._dir())
self.refresh()
- def _delete(self) -> None:
- agent = self._selected_agent()
+ def _delete_agent(self, agent_id: str) -> None:
+ agent = admin_agents.load_agent(agent_id, self._dir())
if agent is None:
return
if QMessageBox.question(
@@ -267,27 +349,69 @@ class AgentsAdminTab(QWidget):
admin_agents.delete_agent(agent.agent_id, self._dir())
self.refresh()
+ def _set_enabled(self, agent_id: str, enabled: bool) -> None:
+ agent = admin_agents.load_agent(agent_id, self._dir())
+ if agent is None or agent.enabled == enabled:
+ return
+
+ agent.enabled = enabled
+ agent.updated = datetime.now().isoformat(timespec="seconds")
+ agent.updated_by = (getattr(self.ctx, "account", None).username if getattr(self.ctx, "account", None) else "")
+ admin_agents.save_agent(agent, self._dir())
+ self.refresh()
+
# ---- view --------------------------------------------------------------
def _status_cell(self, agent_id: str) -> tuple:
- """(status_icon | None, display_text, tooltip) for the operational-status
- column — a colored LED dot instead of the old 🟢/🔴 emoji."""
+ """(state_key, display_text, tooltip) for the Trạng thái pill —
+ state_key indexes _STATUS_BADGE for the badge's colour tone."""
res = self._status.get(agent_id)
if res is None:
- return None, tr("agents_admin.status_unchecked"), tr("agents_admin.status_unchecked_tip")
+ return ("unchecked", tr("agents_admin.status_unchecked").lstrip("— ").strip(),
+ tr("agents_admin.status_unchecked_tip"))
ok, msg = res
if msg == "checking":
- return dot_icon(DOT_AMBER), tr("agents_admin.status_checking"), ""
- ic = dot_icon(DOT_GREEN if ok else DOT_RED)
- return ic, (tr("agents_admin.status_ok") if ok else tr("agents_admin.status_bad")), msg
+ return "checking", tr("agents_admin.status_checking"), ""
+ return ("ok" if ok else "bad"), (tr("agents_admin.status_ok") if ok else tr("agents_admin.status_bad")), msg
+
+ def _toggle_widget(self, agent_id: str, enabled: bool) -> QWidget:
+ container = QWidget()
+ container.setStyleSheet("background: transparent;")
+ lay = QHBoxLayout(container)
+ lay.setContentsMargins(6, 0, 0, 0)
+ sw = ToggleSwitch()
+ sw.setChecked(enabled)
+ sw.toggled.connect(lambda checked, aid=agent_id: self._set_enabled(aid, checked))
+ lay.addWidget(sw, 0, Qt.AlignVCenter)
+ lay.addStretch(1)
+ return container
+
+ def _row_actions_widget(self, agent_id: str) -> QWidget:
+ container = QWidget()
+ container.setStyleSheet("background: transparent;")
+ lay = QHBoxLayout(container)
+ lay.setContentsMargins(2, 0, 2, 0)
+ lay.setSpacing(2)
+ edit_btn = QPushButton()
+ edit_btn.setIcon(icon("edit"))
+ edit_btn.setFlat(True)
+ edit_btn.setCursor(Qt.PointingHandCursor)
+ edit_btn.setToolTip(tr("agents_admin.edit_row_tooltip"))
+ edit_btn.clicked.connect(lambda: self._edit_agent(agent_id))
+ del_btn = QPushButton()
+ del_btn.setIcon(icon("trash"))
+ del_btn.setFlat(True)
+ del_btn.setCursor(Qt.PointingHandCursor)
+ del_btn.setToolTip(tr("agents_admin.delete_row_tooltip"))
+ del_btn.clicked.connect(lambda: self._delete_agent(agent_id))
+ lay.addWidget(edit_btn)
+ lay.addWidget(del_btn)
+ return container
def refresh(self) -> None:
- from PySide6.QtCore import Qt
-
# Make sure the built-in in-app Help assistant exists, so the Admin can
# manage its provider/model here (the floating Help widget uses it).
admin_agents.ensure_help_agent(self._dir())
agents = admin_agents.list_agents(self._dir())
- self.table.setSortingEnabled(False)
self.table.setRowCount(len(agents))
default_model = self._default_model_hint()
for row, agent in enumerate(agents):
@@ -296,23 +420,36 @@ class AgentsAdminTab(QWidget):
model = f"{provider_lbl} — {agent.model}" if provider_lbl else agent.model
else:
model = tr("agents_admin.default_model", model=default_model or "—")
- status_icon, status_text, status_tip = self._status_cell(agent.agent_id)
- cells = [agent.name, tr(f"agents_admin.kind.{agent.task_kind}"),
- model, "", status_text, agent.updated]
- for col, text in enumerate(cells):
- item = QTableWidgetItem(str(text))
- if col == 0:
- item.setData(Qt.UserRole, agent.agent_id)
- if col == 3: # Enabled — green check / grey minus icon (no emoji)
- item.setIcon(icon("check", color=DOT_GREEN) if agent.enabled
- else icon("minus", color=DOT_GREY))
- if col == 4:
- if status_icon:
- item.setIcon(status_icon)
- if status_tip:
- item.setToolTip(status_tip)
- self.table.setItem(row, col, item)
- self.table.setSortingEnabled(True)
+
+ name_item = QTableWidgetItem(agent.name)
+ name_item.setIcon(_kind_avatar_icon(agent.task_kind, agent.name))
+ self.table.setItem(row, 0, name_item)
+
+ kind_tone = _KIND_BADGE.get(agent.task_kind, "badge")
+ self.table.setCellWidget(
+ row, 1, badge_pill_widget(tr(f"agents_admin.kind.{agent.task_kind}"), kind_tone))
+
+ self.table.setItem(row, 2, QTableWidgetItem(model))
+ self.table.setCellWidget(row, 3, self._toggle_widget(agent.agent_id, agent.enabled))
+
+ state_key, status_text, status_tip = self._status_cell(agent.agent_id)
+ status_widget = badge_pill_widget(status_text, _STATUS_BADGE[state_key])
+ if status_tip:
+ status_widget.setToolTip(status_tip)
+ self.table.setCellWidget(row, 4, status_widget)
+
+ self.table.setItem(row, 5, QTableWidgetItem(_fmt_updated(agent.updated)))
+ self.table.setCellWidget(row, 6, self._row_actions_widget(agent.agent_id))
+
+ # ResizeToContents doesn't measure a cell WIDGET's real width (only
+ # QTableWidgetItem content) — size the Vai trò/Trạng thái pill columns
+ # by hand, or their text clips against whatever width it guessed.
+ if self.table.rowCount():
+ for col in (1, 4):
+ needed = max(self.table.cellWidget(r, col).sizeHint().width()
+ for r in range(self.table.rowCount()))
+ if needed + 24 > self.table.columnWidth(col):
+ self.table.setColumnWidth(col, needed + 24)
def _check_all(self) -> None:
"""Health-check every agent's effective provider off the UI thread and
@@ -347,15 +484,15 @@ class AgentsAdminTab(QWidget):
w.start()
def _retranslate(self) -> None:
+ self._title_lbl.setText(tr("agents_admin.page_title"))
self._hint.setText(tr("agents_admin.hint"))
self.table.setHorizontalHeaderLabels([
tr("agents_admin.col_name"), tr("agents_admin.col_kind"),
tr("agents_admin.col_model"), tr("agents_admin.col_enabled"),
- tr("agents_admin.col_status"), tr("agents_admin.col_updated"),
+ tr("agents_admin.col_status"), tr("agents_admin.col_updated"), "",
])
self.add_btn.setText(tr("agents_admin.add_btn"))
- self.edit_btn.setText(tr("agents_admin.edit_btn"))
- self.del_btn.setText(tr("agents_admin.delete_btn"))
+ self.refresh_btn.setText(tr("monitoring.refresh"))
self.check_btn.setText(tr("agents_admin.check_btn"))
self.check_btn.setToolTip(tr("agents_admin.check_tooltip"))
self.refresh()
diff --git a/ui/calendar_view.py b/ui/calendar_view.py
index dc09cca..861e20a 100644
--- a/ui/calendar_view.py
+++ b/ui/calendar_view.py
@@ -20,6 +20,7 @@ from ..core.calendar_grid import (
GRANULARITIES, group_tasks_by_date, month_grid, month_task_counts, shift_period, week_days,
)
from ..i18n import on_language_changed, tr
+from ..theme import current_palette
from .icons import icon
_WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
@@ -56,20 +57,21 @@ class _DayCell(QFrame):
today: bool = False, weekend: bool = False) -> None:
self._date_str = d.isoformat()
self.date_lbl.setText(str(d.day))
- num_color = "#0096C7" if today else ("#888" if dim else "")
- self.date_lbl.setStyleSheet(f"font-weight:700; color:{num_color};")
- # Today = accent border + stronger tint; weekend (Sat/Sun) = a subtle
- # darker-blue tint than the base cell. rgba overlays read correctly on
- # both light and dark themes.
- base_border = "1px solid rgba(128,128,128,0.35)"
+ p = current_palette()
+ num_color = p.accent if today else (p.text_faint if dim else p.text)
+ self.date_lbl.setStyleSheet(f"font-weight:600; color:{num_color};")
+ # Today is the only cell that gets a filled surface + accent border;
+ # weekends are set apart by a recessed surface alone, so the eye lands
+ # on "today" first and on the weekend block only when scanning.
+ r = p.radius
if today:
- css = ("#dayCell { background: rgba(0,150,199,0.22); "
- "border: 2px solid #0096C7; border-radius: 6px; }")
+ css = (f"#dayCell {{ background: {p.accent_soft}; "
+ f"border: 1px solid {p.accent}; border-radius: {r}px; }}")
elif weekend:
- css = ("#dayCell { background: rgba(0,120,182,0.13); "
- f"border: {base_border}; border-radius: 6px; }}")
+ css = (f"#dayCell {{ background: {p.surface}; "
+ f"border: 1px solid {p.border}; border-radius: {r}px; }}")
else:
- css = f"#dayCell {{ border: {base_border}; border-radius: 6px; }}"
+ css = f"#dayCell {{ border: 1px solid {p.border}; border-radius: {r}px; }}"
self.setStyleSheet(css)
self.list.clear()
for t in tasks:
diff --git a/ui/chat_panel.py b/ui/chat_panel.py
index 936e227..9457d13 100644
--- a/ui/chat_panel.py
+++ b/ui/chat_panel.py
@@ -24,6 +24,7 @@ from PySide6.QtWidgets import (
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
+from ..theme import current_palette
from .chat_view import ChatView, ThinkingIndicator
from .composer import Composer
from .icons import collapse_right_icon, icon as app_icon
@@ -83,6 +84,7 @@ class ChatPanel(QWidget):
self.session_name = session_name
self.session_id = new_session_id()
self.title = ""
+ self._notify_title()
# Which project (workspace) this conversation belongs to — every new
# thread inherits the currently selected project (Claude-Projects style).
self.project_id = "default"
@@ -140,8 +142,8 @@ class ChatPanel(QWidget):
# updated after each turn; cost uses the Monitoring model-price table.
self._usage_total_lbl = QLabel("")
self._usage_total_lbl.setObjectName("hint")
- self._usage_total_lbl.setStyleSheet("color: rgba(140,146,152,0.9);")
- self.composer.add_bottom_left(self._usage_total_lbl)
+ self._usage_total_lbl.setStyleSheet(f"color: {current_palette().text_faint};")
+
# Per-tab "Agent" = which MODEL this tab uses (e.g. deepseek / gemma /
# qwen for the local provider). Cowork and Code pick independently and
@@ -165,13 +167,19 @@ class ChatPanel(QWidget):
self.agent_combo.setMinimumWidth(150)
self.agent_combo.setToolTip(tr("chatpanel.agent_tooltip"))
self.agent_combo.currentIndexChanged.connect(self._on_agent_changed)
- self.composer.add_bottom_right(self._agent_lbl)
- self.composer.add_bottom_right(self.agent_combo)
+ self.composer.add_bottom_left(self._agent_lbl)
+ self.composer.add_bottom_left(self.agent_combo)
# Off/Auto/Manual routing toggle — lets the router pick the best-fit
# model per message (see core/routing + _apply_routing).
from .routing_toggle import RoutingToggle
self.routing_toggle = RoutingToggle(ctx, self.kind)
- self.composer.add_bottom_right(self.routing_toggle)
+ # The drawing reads the strip left to right as
+ # Agent: … · Định tuyến: … · ↓in ↑out · $cost · 📁 folder
+ # so these sit together on the left, with the folder box the Cowork tab
+ # appends landing after them. Nén and Tự chạy stay on the right, where
+ # the control inventory marks them "giữ nguyên tại chỗ".
+ self.composer.add_bottom_left(self.routing_toggle)
+ self.composer.add_bottom_left(self._usage_total_lbl)
# Manual "compress conversation" — trim old history to cut tokens.
self.compress_btn = QPushButton(tr("chatpanel.compress_btn"))
self.compress_btn.setIcon(app_icon("compress"))
@@ -188,20 +196,29 @@ class ChatPanel(QWidget):
cc.addWidget(self.chat_view, 1)
self.thinking = ThinkingIndicator() # animated "working…" line while we wait
cc.addWidget(self.thinking)
- composer_wrap = QWidget()
- cwl = QVBoxLayout(composer_wrap)
- cwl.setContentsMargins(8, 4, 8, 8)
- cwl.addWidget(self.composer)
- cc.addWidget(composer_wrap)
-
self.center_split = QSplitter(Qt.Horizontal)
self.center_split.addWidget(chat_col)
root.addWidget(self.center_split, 1)
+ # The composer spans the whole screen, under BOTH columns — that is how
+ # the drawing lays it out, and it is the reason the files panel can sit
+ # beside the transcript without narrowing what you type into. Inside the
+ # chat column it stopped at the panel's edge and the input shrank
+ # whenever files appeared.
+ composer_wrap = QWidget()
+ cwl = QVBoxLayout(composer_wrap)
+ cwl.setContentsMargins(8, 4, 8, 8)
+ cwl.addWidget(self.composer)
+ root.addWidget(composer_wrap)
+
# Right sidebar: Output files only (see below — Input is tracked but
# not shown).
self.input_section = CollapsibleSection(tr("widgets.input_files"))
- self.output_section = CollapsibleSection(tr("widgets.output_files"))
+ # No cap: this section owns the whole right panel (its header is
+ # hoisted into io_hdr below), so the list should fill the space down
+ # to the composer instead of stopping at a fixed height with empty
+ # panel below it.
+ self.output_section = CollapsibleSection(tr("widgets.output_files").upper(), max_height=None)
# Input files are NOT shown in Cowork's UI anymore — but they're still
# fully tracked (add/remove/paths()) exactly as before, since that list
# is what gets written into the conversation's own "inputs" field on
@@ -241,8 +258,15 @@ class ChatPanel(QWidget):
self._io_collapse_btn.clicked.connect(lambda: self._set_io_collapsed(True))
self._files_header = QLabel()
self._files_header.setStyleSheet("font-weight:600;")
+ # The drawing gives this panel ONE heading — "TỆP ĐẦU RA (3) ›" — and
+ # the section already draws exactly that, count included. A separate
+ # "Files" label above it was the same thing said twice, so the section's
+ # own header moves onto this row and the collapse chevron sits at its
+ # right, where the drawing puts it. _files_header stays for the tabs
+ # that still label their panel, just not in this layout.
+ self._files_header.setVisible(False)
+ io_hdr.addWidget(self.output_section.header, 1)
io_hdr.addWidget(self._io_collapse_btn)
- io_hdr.addWidget(self._files_header, 1)
# The plan now shows INLINE in the conversation (an expandable block whose
# steps tick off as they complete), not in this right panel — so it's kept
# out of the layout here. The object stays (its set_steps/clear calls are
@@ -253,8 +277,7 @@ class ChatPanel(QWidget):
bl = QVBoxLayout(bl_host)
bl.setContentsMargins(0, 0, 0, 0)
bl.setSpacing(4)
- bl.addWidget(self.output_section) # Output only — Input is tracked but hidden
- bl.addStretch(1)
+ bl.addWidget(self.output_section, 1) # Output only — Input is tracked but hidden; fills down to the composer
iol.addWidget(bl_host, 1)
# Collapsing shrinks the panel to a thin clickable line (not hidden).
@@ -284,7 +307,7 @@ class ChatPanel(QWidget):
self.compress_btn.setText(tr("chatpanel.compress_btn"))
self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip"))
self.input_section.set_title(tr("widgets.input_files"))
- self.output_section.set_title(tr("widgets.output_files"))
+ self.output_section.set_title(tr("widgets.output_files").upper())
self.plan_section.set_title(tr("widgets.plan_title"))
self._io_collapse_btn.setToolTip(tr("chatpanel.collapse_files_tooltip"))
self._files_header.setText(tr("chatpanel.files_header"))
@@ -1023,6 +1046,7 @@ class ChatPanel(QWidget):
if not self.title:
base = text or (Path(attachments[0]).name if attachments else "(attachment)")
self.title = (base[:60] + "…") if len(base) > 60 else base
+ self._notify_title()
# Reset the Plan panel so each message starts from a clean checklist (the
# previous message's plan never lingers/flickers into this one).
@@ -1398,6 +1422,26 @@ class ChatPanel(QWidget):
return [e for e in ut.load_events()
if e.get("source") == self.kind and e.get("label") == label]
+ def refresh_usage(self) -> None:
+ """Show what this conversation has already cost.
+
+ The label was written only at the end of a turn, so opening a thread
+ from History left the strip blank however much it had spent.
+ """
+ from ..core import model_pricing as mp
+ from ..core import usage_tracker as ut
+
+ cur = self._usage_snapshot()
+ if not (cur["in"] or cur["out"] or cur["cache"]):
+ self._usage_total_lbl.setText("")
+ return
+ # same source _show_usage reads, so the two never disagree
+ pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
+ self._usage_total_lbl.setText(
+ f"↓{mp.format_tokens(cur['in'])} ↑{mp.format_tokens(cur['out'])} "
+ f"▤{mp.format_tokens(cur['in'] + cur['out'] + cur['cache'])} "
+ f"{ut.format_cost(self._session_cost_usd(), pricing)}")
+
def _usage_snapshot(self) -> Dict[str, int]:
"""Cumulative in/out/cache tokens for THIS conversation so far."""
snap = {"in": 0, "out": 0, "cache": 0}
@@ -1668,6 +1712,18 @@ class ChatPanel(QWidget):
self._sync_indicators()
self.history_changed.emit() # current view changed → refresh History highlight
+ def _notify_title(self) -> None:
+ """Let a screen that heads itself with the thread title follow along.
+
+ The thread also decides what the usage strip should read, so refresh
+ that here rather than at each of the three places the title changes.
+ """
+ hook = getattr(self, "refresh_title", None)
+ if callable(hook):
+ hook()
+ if getattr(self, "_usage_total_lbl", None) is not None:
+ self.refresh_usage()
+
def load_conversation(self, conv: Dict[str, Any]) -> None:
"""Switch the view to a stored conversation. Allowed while work is running —
the current turns keep going in the background."""
@@ -1679,6 +1735,7 @@ class ChatPanel(QWidget):
self._detach_live_turns()
self.session_id = sid
self.title = conv.get("title", "")
+ self._notify_title()
self.project_id = conv.get("project_id", "") or "default"
# If this conversation still has a turn running in the background, attach to
# its LIVE message list (not a stale disk copy) so the two never race on save.
diff --git a/ui/chat_view.py b/ui/chat_view.py
index dc2c6d4..5e4bc96 100644
--- a/ui/chat_view.py
+++ b/ui/chat_view.py
@@ -12,7 +12,7 @@ from PySide6.QtWidgets import (
)
from ..i18n import on_language_changed, tr
-from ..theme import ACCENT, resolve_theme
+from ..theme import palette, resolve_theme
from ..config import CONFIG_DIR
from .osutil import is_image, open_folder, open_path
@@ -28,11 +28,18 @@ def _app_theme() -> str:
return "dark"
-# Timeline dot color per role (reads on both themes — small, saturated).
-_DOT = {
- "user": "#48CAE4", "assistant": "#48D9A0", "tool": "#9B8FF7",
- "error": "#E5484D", "success": "#48D9A0",
-}
+def _p():
+ """Design tokens for the theme in effect right now."""
+ return palette(_app_theme())
+
+
+def _dot_color(role: str) -> str:
+ """Timeline dot colour for a message role."""
+ p = _p()
+ return {
+ "user": p.role_user, "assistant": p.role_assistant, "tool": p.role_tool,
+ "error": p.role_error, "success": p.role_result,
+ }.get(role, p.text_faint)
class _TimelineGutter(QWidget):
@@ -52,17 +59,17 @@ class _TimelineGutter(QWidget):
def paintEvent(self, _e): # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
- dark = _app_theme() == "dark"
+ tok = _p()
x = 11.0
cy = 15.0
# connector line (faint) running the full height → continuous rail
- p.setPen(QPen(QColor("#243a56" if dark else "#CBDDEC"), 2))
+ p.setPen(QPen(QColor(tok.border), 2))
p.drawLine(int(x), 0, int(x), self.height())
# a background ring lifts the dot off the line
p.setPen(Qt.NoPen)
- p.setBrush(QColor("#0A1628" if dark else "#E8F4FD"))
+ p.setBrush(QColor(tok.bg))
p.drawEllipse(QPointF(x, cy), 7.5, 7.5)
- p.setBrush(QColor(_DOT.get(self._role, "#8FB2D4")))
+ p.setBrush(QColor(_dot_color(self._role)))
p.drawEllipse(QPointF(x, cy), 4.5, 4.5)
@@ -72,18 +79,20 @@ def _diff_legend(diff_text: str) -> str:
so the before/after distinction is explicit, not just implied by color."""
has_add = any(ln.startswith("+") and not ln.startswith("+++") for ln in diff_text.splitlines())
has_del = any(ln.startswith("-") and not ln.startswith("---") for ln in diff_text.splitlines())
- before = (f'
{html.escape(tr("chat.diff_before"))}')
- after = (f'
{html.escape(tr("chat.diff_after"))}')
+ p = _p()
+
+ def pill(bg: str, fg: str, key: str) -> str:
+ return (f'
{html.escape(tr(key))}')
+
if has_add and has_del:
- badge = f'{before}
→ {after}'
+ badge = (pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_before")
+ + f'
→ '
+ + pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_after"))
elif has_add:
- badge = (f'
{html.escape(tr("chat.diff_added"))}')
+ badge = pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_added")
elif has_del:
- badge = (f'
{html.escape(tr("chat.diff_removed"))}')
+ badge = pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_removed")
else:
return ""
return f'
{badge}
'
@@ -97,21 +106,22 @@ def diff_to_html(diff_text: str) -> str:
empty 'before') naturally renders as all-green, which is exactly what
``difflib.unified_diff`` already produces for it."""
legend = _diff_legend(diff_text)
+ p = _p()
rows = []
for ln in diff_text.splitlines():
esc = html.escape(ln) if ln else " "
if ln.startswith(("+++", "---")):
- rows.append(f'
{esc}
')
+ rows.append(f'
{esc}
')
elif ln.startswith("@@"):
- rows.append(f'
{esc}
')
+ rows.append(f'
{esc}
')
elif ln.startswith("+"):
- rows.append(f'
{esc}
')
+ rows.append(f'
{esc}
')
elif ln.startswith("-"):
- rows.append(f'
{esc}
')
+ rows.append(f'
{esc}
')
else:
rows.append(f"
{esc}
")
body = "".join(rows) or "(no textual change)"
- return (f'{legend}
{body}
')
@@ -219,12 +229,12 @@ class MessageBubble(QFrame):
self._head.setCursor(Qt.PointingHandCursor)
self._head.setStyleSheet(
"QPushButton { text-align:left; border:none; background:transparent;"
- " font-weight:600; color:#8b8d98; padding:0; }")
+ f" font-weight:600; color:{_p().text_muted}; padding:0; }}")
self._head.clicked.connect(self._toggle_body)
lay.addWidget(self._head)
else:
head = QLabel(title)
- head.setStyleSheet("font-weight:600; color:#8b8d98;")
+ head.setStyleSheet(f"font-weight:600; color:{_p().text_muted};")
lay.addWidget(head)
self.body = QTextBrowser()
@@ -265,36 +275,23 @@ class MessageBubble(QFrame):
def _apply_theme_styles(self, role: str) -> None:
"""Apply text color to the body QTextBrowser based on current theme + role."""
- theme = self._current_theme()
- if theme == "light":
- if role == "success":
- text_color = "#1B7A3D"
- elif role == "error":
- text_color = "#C0392B"
- elif role in ("tool",):
- text_color = "#5C6B7A" # muted (secondary) like Claude's steps
- else:
- text_color = "#1A2332"
- else:
- if role == "success":
- text_color = "#7ee2a8"
- elif role == "error":
- text_color = "#ff9aa8"
- elif role in ("tool",):
- text_color = "#9aa6b8"
- else:
- text_color = "#eceef2"
+ p = _p()
+ text_color = {
+ "success": p.success,
+ "error": p.danger,
+ "tool": p.text_muted, # secondary, like Claude's steps
+ }.get(role, p.text)
self.body.setStyleSheet(f"background: transparent; border: none; color: {text_color};")
def _apply_style(self, role: str) -> None:
"""Flat timeline row — no bubble box; the left dot/rail conveys role and
structure (Claude-Code style). The user's own message gets a faint tint
so questions are easy to pick out when scanning."""
- theme = self._current_theme()
+ p = _p()
if role == "user":
- tint = "rgba(72,202,228,0.10)" if theme == "dark" else "rgba(72,202,228,0.14)"
self.setStyleSheet(
- f"QFrame {{ background: {tint}; border: none; border-radius: 10px; }}")
+ f"QFrame {{ background: {p.surface}; border: none; "
+ f"border-radius: {p.radius}px; }}")
else:
self.setStyleSheet("QFrame { background: transparent; border: none; }")
@@ -353,20 +350,20 @@ class MessageBubble(QFrame):
existing.setText(text)
return
lbl = QLabel(text)
- lbl.setObjectName("hint")
- lbl.setStyleSheet("color: rgba(140,146,152,0.9); font-size: 11px;")
+ lbl.setObjectName("faint")
+ lbl.setStyleSheet(f"color: {_p().text_faint}; font-size: 11px;")
self._usage_lbl = lbl
self._content_layout.addWidget(lbl)
def add_delete_link(self, callback) -> None:
- link = QLabel(f'
{tr("chat.delete_link")}')
+ link = QLabel(f'
{tr("chat.delete_link")}')
link.setToolTip(tr("chat.delete_tooltip"))
link.linkActivated.connect(lambda *_: callback())
self._content_layout.addWidget(link)
def add_folder_link(self, folder: str, label: str | None = None) -> None:
label = label or tr("chat.open_workspace")
- link = QLabel(f'
{label}')
+ link = QLabel(f'
{label}')
link.setToolTip(str(folder))
link.linkActivated.connect(lambda *_: open_folder(folder))
self._content_layout.addWidget(link)
@@ -385,7 +382,7 @@ class MessageBubble(QFrame):
thumb.setCursor(Qt.PointingHandCursor)
self._content_layout.addWidget(thumb)
continue
- file_link = QLabel(f'
{name}')
+ file_link = QLabel(f'
{name}')
file_link.setToolTip(path)
file_link.linkActivated.connect(lambda *_a, fp=path: open_path(fp))
self._content_layout.addWidget(file_link)
diff --git a/ui/co4e_canvas.py b/ui/co4e_canvas.py
index eabd988..e47e1a7 100644
--- a/ui/co4e_canvas.py
+++ b/ui/co4e_canvas.py
@@ -27,13 +27,20 @@ from ..core.co4e import (
STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node, Step,
compute_waves, new_edge_id, new_node_id,
)
+from ..theme import current_palette
+
+
+def _status_color(status: str) -> str:
+ """Accent colour for a step's run status. Resolved per paint so the canvas
+ follows a live theme switch."""
+ p = current_palette()
+ return {
+ "idle": p.text_muted, STEP_RUNNING: p.accent, STEP_DONE: p.success,
+ STEP_ERROR: p.danger, STEP_PLANNED: p.purple, "pending": p.text_faint,
+ }.get(status, p.text_muted)
CO4E_MIME = "application/x-co4e-step"
-_STATUS_COLOR = {
- "idle": "#5C8DB8", STEP_RUNNING: "#48CAE4", STEP_DONE: "#48D9A0",
- STEP_ERROR: "#E5484D", STEP_PLANNED: "#9B8FF7", "pending": "#7A8DA8",
-}
_NODE_W, _NODE_H = 210, 96
_PORT_R = 6 # output port radius (the drag-to-connect handle)
_PORT_HIT = 15 # click tolerance around a port
@@ -63,24 +70,29 @@ class _NodeItem(QGraphicsObject):
return QRectF(1, 1, _NODE_W - 2, _NODE_H - 2)
def paint(self, p, _opt, _widget=None):
+ tok = current_palette()
step = self.node.data
- accent = QColor(_STATUS_COLOR.get(self.status, "#5C8DB8"))
- body = QColor("#0D1F35")
- border = QColor("#48CAE4") if self.isSelected() else QColor("#1A2D4A")
+ accent = QColor(_status_color(self.status))
+ body = QColor(tok.surface_raised)
+ border = QColor(tok.accent) if self.isSelected() else QColor(tok.border)
p.setRenderHint(p.RenderHint.Antialiasing)
rect = self._card_rect()
path = QPainterPath()
- path.addRoundedRect(rect, 10, 10)
+ radius = float(tok.radius_lg)
+ path.addRoundedRect(rect, radius, radius)
p.fillPath(path, QBrush(body))
p.setPen(QPen(border, 2 if self.isSelected() else 1))
p.drawPath(path)
- # header stripe
+ # header stripe — a tint of the status colour, not the status colour
+ # itself, so the card's own text stays the brightest thing on it.
hdr = QRectF(rect.left(), rect.top(), rect.width(), 26)
hpath = QPainterPath()
- hpath.addRoundedRect(hdr, 10, 10)
- p.fillPath(hpath, QBrush(accent.darker(160)))
+ hpath.addRoundedRect(hdr, radius, radius)
+ stripe = QColor(accent)
+ stripe.setAlpha(48)
+ p.fillPath(hpath, QBrush(stripe))
# label
- p.setPen(QColor("#E0F0FF"))
+ p.setPen(QColor(tok.text))
f = p.font(); f.setBold(True); f.setPointSize(9); p.setFont(f)
p.drawText(QRectF(10, 4, _NODE_W - 20, 20), Qt.AlignVCenter | Qt.AlignLeft,
_elide(step.label, 26))
@@ -89,7 +101,7 @@ class _NodeItem(QGraphicsObject):
p.setPen(accent)
p.drawText(QRectF(10, 30, _NODE_W - 20, 16), Qt.AlignLeft, step.role)
# body: instructions preview OR sub-agent chips
- p.setPen(QColor("#8FB2D4"))
+ p.setPen(QColor(tok.text_muted))
if step.is_parallel:
preview = "⇉ " + ", ".join(s.agent for s in step.sub_agents) if step.sub_agents else "⇉ (no sub-agents)"
else:
@@ -97,7 +109,7 @@ class _NodeItem(QGraphicsObject):
p.drawText(QRectF(10, 46, _NODE_W - 20, 30), Qt.TextWordWrap | Qt.AlignTop,
_elide(preview, 66))
# footer: model + skills + status dot
- p.setPen(QColor("#5C8DB8"))
+ p.setPen(QColor(tok.text_faint))
foot = []
if step.model:
foot.append(step.model)
@@ -109,7 +121,7 @@ class _NodeItem(QGraphicsObject):
# ---- ports ---------------------------------------------------------
# input port (top-center): hollow. output port (bottom-center): filled —
# the drag handle you pull to wire an edge to another step.
- port_col = QColor("#48CAE4")
+ port_col = QColor(tok.accent)
# input port (left-center): hollow. output port (right-center): filled —
# the drag handle you pull to wire an edge to the next step (left→right).
p.setBrush(QBrush(body)); p.setPen(QPen(port_col, 1.4))
@@ -298,12 +310,13 @@ class _EdgeItem(QGraphicsPathItem):
self._apply_pen()
def _apply_pen(self):
+ tok = current_palette()
if self.isSelected():
- color, w = QColor("#48CAE4"), 3
+ color, w = QColor(tok.accent), 3
elif self._hover:
- color, w = QColor("#6FA8C8"), 3
+ color, w = QColor(tok.text_muted), 3
else:
- color, w = QColor("#3A5A78"), 2
+ color, w = QColor(tok.border_strong), 2
self.setPen(QPen(color, w, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
def update_path(self, points):
@@ -491,7 +504,8 @@ class Co4ECanvas(QGraphicsView):
self._port_src_pt = scene_pt
self._temp_edge = QGraphicsPathItem()
self._temp_edge.setZValue(3.5) # above nodes + edges while connecting
- self._temp_edge.setPen(QPen(QColor("#48CAE4"), 2, Qt.DashLine, Qt.RoundCap))
+ self._temp_edge.setPen(
+ QPen(QColor(current_palette().accent), 2, Qt.DashLine, Qt.RoundCap))
self._scene.addItem(self._temp_edge)
def update_port_drag(self, scene_pt: QPointF) -> None:
diff --git a/ui/co4e_config_panel.py b/ui/co4e_config_panel.py
index 327220d..fd2e928 100644
--- a/ui/co4e_config_panel.py
+++ b/ui/co4e_config_panel.py
@@ -11,18 +11,124 @@ from __future__ import annotations
from typing import List, Optional
-from PySide6.QtCore import Qt, Signal
+from PySide6.QtCore import Qt, QEasingCurve, QPropertyAnimation, Signal
from PySide6.QtWidgets import (
- QCheckBox, QComboBox, QFormLayout, QHBoxLayout, QInputDialog, QLabel,
- QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit, QPushButton,
- QScrollArea, QSpinBox, QVBoxLayout, QWidget,
+ QCheckBox, QComboBox, QFormLayout, QHBoxLayout, QInputDialog,
+ QLabel, QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit,
+ QPushButton, QScrollArea, QSpinBox, QVBoxLayout, QWidget,
)
from ..config import PROVIDER_LABELS
from ..core.co4e import PERMISSION_PRESETS, Step, SubAgent
from ..i18n import tr
+from ..theme import current_palette
from .icons import icon, icon_picker_combo
+_SECTION_ANIM_MS = 180
+
+
+class _SectionHeader(QLabel):
+ """A clickable label — a QPushButton's own style chrome (border, native
+ button margin, focus rect) always leaves a taller minimum height than a
+ plain label, even once its QSS padding is zeroed out, so the header that
+ needs to sit tight against its neighbours is a label, not a button."""
+
+ clicked = Signal()
+
+ def mousePressEvent(self, event) -> None: # noqa: N802
+ if event.button() == Qt.LeftButton:
+ self.clicked.emit()
+ super().mousePressEvent(event)
+
+ def showEvent(self, event) -> None: # noqa: N802
+ # fontMetrics() at construction time (before this label is ever part
+ # of a shown top-level window) reflects the QSS font-size only if the
+ # style has fully polished by then — on the very FIRST paint of the
+ # Co4E screen it sometimes hasn't, so the fixed height computed in
+ # _add_section is briefly wrong (too tall) until something else
+ # triggers a relayout. Recomputing here, every time the label
+ # actually becomes visible, means the first paint is never stale.
+ self.setFixedHeight(self.fontMetrics().height())
+ super().showEvent(event)
+
+
+def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
+ """One group of fields, collapsed to just its heading by default and
+ independently expandable, so a long step config reads as a short list of
+ group names until you open the one you need. Deliberately bare — no card
+ border/background/box — the ▶/▼ marker and the heading text are the only
+ things separating one group from the next; opening one never closes
+ another (not an accordion, not a tab bar). Returns ``(form, card)``: add
+ the group's rows to ``form``; ``card`` is the whole section (header +
+ body) — hide it to remove the group entirely (e.g. for a section that
+ only applies to some steps), rather than hiding individual rows inside
+ an always-visible header."""
+ p = current_palette()
+ card = QWidget()
+ card_lay = QVBoxLayout(card)
+ card_lay.setContentsMargins(0, 0, 0, 0)
+ card_lay.setSpacing(0)
+
+ header = _SectionHeader()
+ header.setCursor(Qt.PointingHandCursor)
+ header.setStyleSheet(f"font-weight:400; font-size:13px; color:{p.text}; padding:0; margin:0;")
+ header.setContentsMargins(0, 0, 0, 0)
+ # QSS font-size only lands on the widget's actual QFont (and therefore
+ # its fontMetrics()) once the style sheet is polished — ensurePolished()
+ # forces that now, so the fixed height below is computed from the 12px
+ # font just set above, not the default one this label was constructed
+ # with. A label's natural sizeHint still reserves font leading above/
+ # below the glyphs on top of the (now zeroed) QSS padding — pinning the
+ # height to the text's actual cap-to-baseline span is what closes that
+ # last gap without clipping the ▶ glyph, the title, or Vietnamese
+ # diacritics.
+ header.ensurePolished()
+ header.setFixedHeight(header.fontMetrics().height())
+ header.setText(f"▶ {title}")
+ card_lay.addWidget(header)
+
+ body = QWidget()
+ body.setVisible(False)
+ body.setMaximumHeight(0)
+ form = QFormLayout(body)
+ form.setContentsMargins(0, 6, 0, 0)
+ card_lay.addWidget(body)
+
+ anim = QPropertyAnimation(body, b"maximumHeight", body)
+ anim.setDuration(_SECTION_ANIM_MS)
+ anim.setEasingCurve(QEasingCurve.InOutCubic)
+
+ is_open = False
+
+ def _on_finished() -> None:
+ if is_open:
+ # Uncapped once open, so switching to a step whose fields make
+ # this section taller/shorter (e.g. a parallel node's sub-agent
+ # list appearing) is never clipped by the height this animation
+ # last landed on.
+ body.setMaximumHeight(16_777_215)
+ else:
+ body.setVisible(False)
+ anim.finished.connect(_on_finished)
+
+ def _toggle() -> None:
+ nonlocal is_open
+ is_open = not is_open
+ header.setText(f"{'▼' if is_open else '▶'} {title}")
+ anim.stop()
+ if is_open:
+ body.setVisible(True)
+ anim.setStartValue(body.height())
+ anim.setEndValue(body.sizeHint().height())
+ else:
+ anim.setStartValue(body.height())
+ anim.setEndValue(0)
+ anim.start()
+ header.clicked.connect(_toggle)
+
+ outer.addWidget(card)
+ return form, card
+
class StepConfigPanel(QScrollArea):
changed = Signal() # any field edited → repaint node + autosave
@@ -39,7 +145,16 @@ class StepConfigPanel(QScrollArea):
self.setWidgetResizable(True)
host = QWidget()
self.setWidget(host)
- form = QFormLayout(host)
+ outer = QVBoxLayout(host)
+ outer.setSpacing(1)
+
+ # Grouped sections stacked on one scrolling page — same fields as
+ # before, grouped by what they're for: identity, execution
+ # (model/permission), and the extra resources fed to the step
+ # (skills/files/sub-agents). No tabs/accordion: every group's border
+ # and heading are what separate it from its neighbours, and all three
+ # are on screen (or one scroll away) at once.
+ form, _basic_card = _add_section(outer, tr("co4e.tab_basic"))
self.label_edit = QLineEdit()
self.label_edit.textChanged.connect(self._on_edit)
@@ -80,6 +195,8 @@ class StepConfigPanel(QScrollArea):
self.context_edit.textChanged.connect(self._on_edit)
form.addRow(tr("co4e.f_context"), self.context_edit)
+ form2, _model_card = _add_section(outer, tr("co4e.tab_model_perm"))
+
model_row = QHBoxLayout()
self.model_combo = QComboBox()
self.model_combo.setEditable(True)
@@ -92,13 +209,13 @@ class StepConfigPanel(QScrollArea):
model_row.addWidget(self.model_combo, 1)
model_row.addWidget(self.load_models_btn)
mrow = QWidget(); mrow.setLayout(model_row)
- form.addRow(tr("co4e.f_model"), mrow)
+ form2.addRow(tr("co4e.f_model"), mrow)
self.perm_combo = QComboBox()
for preset in PERMISSION_PRESETS:
self.perm_combo.addItem(tr(f"co4e.perm.{preset}"), preset)
self.perm_combo.currentIndexChanged.connect(self._on_edit)
- form.addRow(tr("co4e.f_permission"), self.perm_combo)
+ form2.addRow(tr("co4e.f_permission"), self.perm_combo)
verify_row = QHBoxLayout()
self.verify_chk = QCheckBox(tr("co4e.f_self_verify"))
@@ -111,13 +228,15 @@ class StepConfigPanel(QScrollArea):
verify_row.addWidget(self.rounds_spin)
verify_row.addStretch(1)
vrow = QWidget(); vrow.setLayout(verify_row)
- form.addRow("", vrow)
+ form2.addRow("", vrow)
+
+ form3, _skills_card = _add_section(outer, tr("co4e.tab_skills_files"))
# Skills checklist (registry skills)
self.skills_list = QListWidget()
self.skills_list.setMaximumHeight(110)
self.skills_list.itemChanged.connect(self._on_edit)
- form.addRow(tr("co4e.f_skills"), self.skills_list)
+ form3.addRow(tr("co4e.f_skills"), self.skills_list)
# Attachments — files whose extracted text is fed to this step at run time.
self.attach_list = QListWidget()
@@ -133,11 +252,15 @@ class StepConfigPanel(QScrollArea):
att_btns.addWidget(self.attach_del_btn)
att_btns.addStretch(1)
abtn = QWidget(); abtn.setLayout(att_btns)
- form.addRow(tr("co4e.f_attachments"), self.attach_list)
- form.addRow("", abtn)
+ form3.addRow(tr("co4e.f_attachments"), self.attach_list)
+ form3.addRow("", abtn)
- # Parallel sub-agents (only shown for parallel nodes)
- self.parallel_label = QLabel(tr("co4e.f_subagents"))
+ # Parallel sub-agents get their OWN section — same header style as
+ # Cơ bản/Model & Quyền/Skills & Tệp — rather than a row buried inside
+ # Skills & Tệp, since it's really a distinct group, just one that
+ # only applies to parallel-variant steps. load_step() hides the whole
+ # card for a non-parallel step (see is_par below).
+ form4, self._parallel_card = _add_section(outer, tr("co4e.f_subagents"))
self.sub_list = QListWidget()
self.sub_list.setMaximumHeight(90)
self.sub_list.itemDoubleClicked.connect(self._edit_subagent) # re-pick agent
@@ -152,10 +275,11 @@ class StepConfigPanel(QScrollArea):
sub_btns.addWidget(self.sub_del_btn)
sub_btns.addStretch(1)
sbtn = QWidget(); sbtn.setLayout(sub_btns)
- form.addRow(self.parallel_label, self.sub_list)
- form.addRow("", sbtn)
+ form4.addRow(self.sub_list)
+ form4.addRow("", sbtn)
- # Footer actions — one compact row (Run · Run from here · Delete).
+ # 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.setIcon(icon("play"))
self.run_btn.setToolTip(tr("co4e.run_this_step"))
@@ -170,12 +294,21 @@ class StepConfigPanel(QScrollArea):
self.del_btn.setFixedWidth(38)
self.del_btn.clicked.connect(lambda: self.delete_node.emit(self._node_id))
foot = QHBoxLayout()
- foot.setContentsMargins(0, 0, 0, 0)
foot.addWidget(self.run_btn, 1)
foot.addWidget(self.run_from_btn, 1)
foot.addWidget(self.del_btn)
foot_w = QWidget(); foot_w.setLayout(foot)
- form.addRow("", foot_w)
+ outer.addWidget(foot_w)
+ # Without this, QVBoxLayout hands every child widget an EQUAL share of
+ # whatever extra height the scroll area's viewport has beyond the
+ # content's own sizeHint (setWidgetResizable(True) stretches `host` to
+ # fill it) — each collapsed header's card was measuring a true
+ # sizeHint of ~17px but rendering over 100px taller, and no amount of
+ # margin/padding/spacing on the header itself could touch that: the
+ # surplus was being spent on the cards, not around them. One trailing
+ # stretch absorbs all of it instead, so every section (and the
+ # footer) renders at exactly its own natural height.
+ outer.addStretch(1)
self.setEnabled(False)
@@ -209,12 +342,11 @@ class StepConfigPanel(QScrollArea):
item = QListWidgetItem(_P(path).name)
item.setToolTip(path)
self.attach_list.addItem(item)
- # parallel sub-agents
+ # parallel sub-agents — the whole "Agent song song" section only
+ # applies to parallel-variant steps, so the entire card (header
+ # included) is hidden for any other step, not just its rows.
is_par = step.is_parallel
- self.parallel_label.setVisible(is_par)
- self.sub_list.setVisible(is_par)
- self.sub_add_btn.setVisible(is_par)
- self.sub_del_btn.setVisible(is_par)
+ self._parallel_card.setVisible(is_par)
self.sub_list.clear()
if is_par:
for sub in step.sub_agents:
diff --git a/ui/co4e_tab.py b/ui/co4e_tab.py
index 27ad7ee..b829b89 100644
--- a/ui/co4e_tab.py
+++ b/ui/co4e_tab.py
@@ -34,6 +34,7 @@ from ..core.co4e_builtins import BUILTIN_AGENTS
from ..core.co4e_run_manager import Co4ERunManager
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
+from ..theme import current_palette
from .chat_view import ChatView
from .co4e_canvas import CO4E_MIME, Co4ECanvas
from .co4e_config_panel import StepConfigPanel
@@ -260,7 +261,7 @@ class Co4ETab(QWidget):
root.addWidget(self._split)
sidebar = self._build_sidebar()
- sidebar.setMinimumWidth(210)
+ sidebar.setMinimumWidth(180) # 210 pushed the whole tab past 1214px min
self._split.addWidget(sidebar)
self._split.addWidget(self._build_center())
self.config = StepConfigPanel(ctx)
@@ -272,6 +273,19 @@ class Co4ETab(QWidget):
self._config_collapsed = False
self._config_expanded_w = 360
self._split.addWidget(self._wrap_config())
+ from PySide6.QtCore import QTimer
+
+ from .widgets import narrow_guard
+ self._narrow_guard = narrow_guard(self, self._NARROW, self._apply_narrow_layout)
+ # Deferred one tick: the parent chain (and therefore window()) only
+ # exists after whoever is building this has finished adding it.
+ QTimer.singleShot(0, self._narrow_guard.attach)
+ # Start the sidebar wider than its 180px floor — at the floor the
+ # "Chạy nền" button and the flow names are cut off.
+ self._split.setSizes([230, 720, 360])
+ self._split.setStretchFactor(0, 0)
+ self._split.setStretchFactor(1, 1)
+ self._split.setStretchFactor(2, 0)
self._split.setStretchFactor(0, 0)
self._split.setStretchFactor(1, 1)
self._split.setStretchFactor(2, 0)
@@ -306,6 +320,11 @@ class Co4ETab(QWidget):
self.flow_bar.setCurrentIndex(bar_idx)
self._reflect_active_run(wf.id)
return
+ # Without the strip there is nowhere to switch between open flows, so
+ # opening one REPLACES the one on the canvas (saved first, as the tab
+ # switch used to do). Runs already in progress are unaffected — they are
+ # tracked per flow id and keep going in the background.
+ self._close_other_flows()
self._flows.append(wf)
self.flow_bar.blockSignals(True)
bar_idx = self.flow_bar.addTab(icon("flow"), wf.name or tr("co4e.untitled"))
@@ -317,13 +336,43 @@ class Co4ETab(QWidget):
self.flow_bar.setCurrentIndex(bar_idx)
self._reflect_active_run(wf.id)
+ def _close_other_flows(self) -> None:
+ """Leave the canvas empty of flows, saving whatever was on it.
+
+ Called before opening a flow, because the tab strip that used to hold
+ several at once is gone. Tab 0 (Runs) is never touched.
+ """
+ if not self._flows:
+ return
+ if 0 <= self._active_flow_idx < len(self._flows):
+ self._sync_wf_from_canvas()
+ self.flow_bar.blockSignals(True)
+ for idx in range(self.flow_bar.count() - 1, 0, -1):
+ self.flow_bar.removeTab(idx)
+ self.flow_bar.blockSignals(False)
+ self._flows.clear()
+ self._active_flow_idx = -1
+
+ def _show_runs(self, on: bool) -> None:
+ """Swap the centre between the flow editor and the Runs table.
+
+ This is where the pinned "Runs" tab went when the strip was removed —
+ same page, same table, reached from a toggle in the flow toolbar.
+ """
+ target = 0 if on else min(1, self.flow_bar.count() - 1)
+ if self.flow_bar.currentIndex() == target:
+ self._on_flow_tab_changed(target) # already there → re-apply
+ else:
+ self.flow_bar.setCurrentIndex(target)
+
def _on_flow_tab_changed(self, idx: int) -> None:
# save the outgoing flow (active_flow_idx is a FLOWS-list index) first
if 0 <= self._active_flow_idx < len(self._flows) and (self._active_flow_idx + 1) != idx:
self._sync_wf_from_canvas()
- if idx <= 0: # the pinned Runs tab
+ if idx <= 0: # the Runs page
self._active_flow_idx = -1
self.center_stack.setCurrentIndex(0)
+ self._sync_runs_toggle(True)
self._refresh_runs()
return
flow_idx = idx - 1
@@ -331,8 +380,18 @@ class Co4ETab(QWidget):
return
self._active_flow_idx = flow_idx
self.center_stack.setCurrentIndex(1)
+ self._sync_runs_toggle(False)
self._apply_workflow(self._flows[flow_idx])
+ def _sync_runs_toggle(self, on: bool) -> None:
+ """Keep the Runs toggle showing which page is up, however it got there
+ (a double-click in the runs table also switches pages)."""
+ btn = getattr(self, "runs_btn", None)
+ if btn is not None and btn.isChecked() != on:
+ blocked = btn.blockSignals(True)
+ btn.setChecked(on)
+ btn.blockSignals(blocked)
+
def _add_tab_close_button(self, idx: int) -> None:
"""Give a flow tab its own close button — a small ✕ placed by QTabBar on
the tab's right side, vertically centered and INSIDE the tab (reliable
@@ -423,22 +482,47 @@ class Co4ETab(QWidget):
# ---- sidebar ----------------------------------------------------------
def _build_sidebar(self) -> QWidget:
- self.sidebar = QTabWidget()
- # Icon-only tabs share the full sidebar width equally (line up with the
- # list below) via an equal-width tab bar — no left/right scroll. Colours
- # come from theme.py (QTabBar#co4eSideTabs — transparent tabs + a subtle
- # translucent selection with the theme text colour, like the app's lists).
- self.sidebar.setTabBar(_EqualTabBar())
- _tb = self.sidebar.tabBar()
- _tb.setObjectName("co4eSideTabs")
- _tb.setUsesScrollButtons(False)
- _tb.setElideMode(Qt.ElideNone)
- # Workflows
- wf_page = QWidget(); wl = QVBoxLayout(wf_page)
- wl.setContentsMargins(6, 6, 6, 6)
+ # ONE COLUMN, four named sections — no icon tabs. Every list is on screen
+ # at once, so "what can I drag onto the canvas" is answered by looking
+ # rather than by clicking through three unlabeled tabs.
+ # A vertical splitter, not a fixed stack: on a short window four stacked
+ # lists otherwise squeeze down to one visible row each. The splitter
+ # hands out the available height by weight and lets the user re-balance
+ # it by dragging; each list keeps a small minimum so none disappears.
+ self._sections: dict = {}
+ self.sidebar = QWidget()
+ outer_col = QVBoxLayout(self.sidebar)
+ outer_col.setContentsMargins(6, 6, 6, 6)
+ outer_col.setSpacing(0)
+ self.side_split = QSplitter(Qt.Vertical)
+ self.side_split.setChildrenCollapsible(False)
+ self.side_split.setHandleWidth(8)
+ outer_col.addWidget(self.side_split, 1)
+
+ class _Col:
+ """Adapter so the section builders below read the same as before."""
+
+ def __init__(self, split):
+ self._split = split
+
+ def addWidget(self, w, stretch=1):
+ self._split.addWidget(w)
+ self._split.setStretchFactor(self._split.count() - 1, stretch)
+
+ col = _Col(self.side_split)
+
+ # --- WORKFLOWS ---------------------------------------------------
+ self.wf_new_btn = QPushButton(tr("co4e.new"))
+ self.wf_new_btn.setIcon(icon("plus"))
+ self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
+ self.wf_new_btn.setObjectName("co4eSectionAction")
+ self.wf_new_btn.setFlat(True)
+ self.wf_new_btn.setCursor(Qt.PointingHandCursor)
+ self.wf_new_btn.clicked.connect(self._new_workflow)
+ wf_body = QWidget(); wl = QVBoxLayout(wf_body)
+ wl.setContentsMargins(0, 0, 0, 0); wl.setSpacing(4)
# Draggable: drag a flow onto the canvas to merge it in (Nova-style);
- # double-click loads it into an empty canvas. (The old always-on hint
- # label was removed to give the flow list more room; it's a tooltip now.)
+ # double-click loads it onto the canvas.
self.wf_list = _PaletteList(payload_role=Qt.UserRole + 2)
self.wf_list.setToolTip(tr("co4e.drag_hint"))
self.wf_list.itemDoubleClicked.connect(self._load_selected_workflow)
@@ -446,57 +530,157 @@ class Co4ETab(QWidget):
self.wf_list.customContextMenuRequested.connect(self._wf_context_menu)
wl.addWidget(self.wf_list, 1)
wf_btns = QHBoxLayout(); wf_btns.setSpacing(4)
- # "New flow" moved to the "+" button on the flow tab strip (browser-style).
- # "Load selected flow to canvas" button removed — double-click a flow in
- # the list (or drag it onto the canvas) to open it; the explicit button
- # was redundant.
self.wf_edit_btn = self._icon_btn("edit", "co4e.tt_edit_wf", self._edit_selected_workflow)
self.wf_dup_btn = self._icon_btn("branch", "co4e.tt_dup_wf", self._duplicate_selected_workflow)
self.wf_del_btn = self._icon_btn("trash", "co4e.tt_del_wf", self._delete_selected_workflow)
for b in (self.wf_edit_btn, self.wf_dup_btn, self.wf_del_btn):
wf_btns.addWidget(b)
+ wf_btns.addStretch(1)
+ 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.clicked.connect(self._run_selected_in_background)
- wf_btns.addWidget(self.wf_runbg_btn, 1)
- wl.addLayout(wf_btns)
- # (The "Running flows" list moved out of the sidebar into the pinned
- # "Runs" tab at the front of the flow tabs — see _build_runs_page.)
- # Icon-only tabs keep the sidebar narrow; the name is a tooltip.
- self.sidebar.addTab(wf_page, icon("flow"), "")
- self.sidebar.setTabToolTip(0, tr("co4e.tab_workflows"))
+ wl.addWidget(self.wf_runbg_btn)
+ col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3)
- # Agents (drag onto canvas; CRUD custom)
- ag_page = QWidget(); al = QVBoxLayout(ag_page)
- al.setContentsMargins(6, 6, 6, 6)
+ # --- AGENTS ------------------------------------------------------
+ self.ag_new_btn = QPushButton(tr("co4e.new")); self.ag_new_btn.setIcon(icon("plus"))
+ self.ag_new_btn.setToolTip(tr("co4e.tt_new_agent"))
+ self.ag_new_btn.setObjectName("co4eSectionAction")
+ self.ag_new_btn.setFlat(True)
+ self.ag_new_btn.setCursor(Qt.PointingHandCursor)
+ self.ag_new_btn.clicked.connect(self._new_agent)
+ ag_body = QWidget(); al = QVBoxLayout(ag_body)
+ al.setContentsMargins(0, 0, 0, 0); al.setSpacing(4)
self.agent_list = _PaletteList()
al.addWidget(self.agent_list, 1)
ag_btns = QHBoxLayout(); ag_btns.setSpacing(4)
- self.ag_new_btn = QPushButton(tr("co4e.new")); self.ag_new_btn.setIcon(icon("plus"))
- self.ag_new_btn.setToolTip(tr("co4e.tt_new_agent"))
- self.ag_new_btn.clicked.connect(self._new_agent)
+ # Edit/delete act on the selected row, so they stay with the list.
self.ag_edit_btn = self._icon_btn("edit", "co4e.tt_edit_agent", self._edit_agent)
self.ag_del_btn = self._icon_btn("trash", "co4e.tt_del_agent", self._delete_agent)
- ag_btns.addWidget(self.ag_new_btn, 1)
ag_btns.addWidget(self.ag_edit_btn)
ag_btns.addWidget(self.ag_del_btn)
+ ag_btns.addStretch(1)
al.addLayout(ag_btns)
- self.sidebar.addTab(ag_page, icon("robot"), "")
- self.sidebar.setTabToolTip(1, tr("co4e.tab_agents"))
+ col.addWidget(self._section("co4e.tab_agents", ag_body, self.ag_new_btn), 3)
- # Skills (drag onto canvas; manage via existing Skills manager button)
- sk_page = QWidget(); sl = QVBoxLayout(sk_page)
- sl.setContentsMargins(6, 6, 6, 6)
- self.skill_list = _PaletteList()
- sl.addWidget(self.skill_list, 1)
+ # --- SKILLS ------------------------------------------------------
self.sk_manage_btn = QPushButton(tr("co4e.manage_skills"))
self.sk_manage_btn.setToolTip(tr("co4e.tt_manage_skills"))
+ self.sk_manage_btn.setObjectName("co4eSectionAction")
+ self.sk_manage_btn.setFlat(True)
+ self.sk_manage_btn.setCursor(Qt.PointingHandCursor)
self.sk_manage_btn.clicked.connect(self._manage_skills)
- sl.addWidget(self.sk_manage_btn)
- self.sidebar.addTab(sk_page, icon("sparkle"), "")
- self.sidebar.setTabToolTip(2, tr("co4e.tab_skills"))
+ sk_body = QWidget(); sl = QVBoxLayout(sk_body)
+ sl.setContentsMargins(0, 0, 0, 0); sl.setSpacing(4)
+ self.skill_list = _PaletteList()
+ sl.addWidget(self.skill_list, 1)
+ col.addWidget(self._section("co4e.tab_skills", sk_body, self.sk_manage_btn), 2)
+
+ # --- RUNS --------------------------------------------------------
+ # A short, always-visible view of the same runs the Flow Status page
+ # tables in full. Clicking one opens that page with the run selected.
+ # Icon only: the heading beside it already reads FLOW STATUS, and the
+ # label was long enough to be cut in half in a narrow sidebar.
+ self.runs_more_btn = QPushButton()
+ 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"))
+ 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"))
+ 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)
+ # Small enough that all four still fit on a laptop screen, large enough
+ # that each shows more than a single row.
+ for lst in (self.wf_list, self.agent_list, self.skill_list, self.runs_side_list):
+ lst.setMinimumHeight(56)
return self.sidebar
+ _SIDE_RUNS = 6
+
+ def _refresh_side_runs(self) -> None:
+ """Mirror the newest runs into the sidebar's short list."""
+ lst = getattr(self, "runs_side_list", None)
+ if lst is None:
+ return
+ dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
+ lst.clear()
+ for h in list(reversed(self.manager.runs()))[:self._SIDE_RUNS]:
+ it = QListWidgetItem(f"{dots.get(h.status, '•')} {h.name}"
+ f" {h.progress_text()}")
+ it.setData(Qt.UserRole, h.id)
+ it.setToolTip(f"{h.name} · {tr('co4e.status.' + h.status)} · {h.created_at or '-'}")
+ lst.addItem(it)
+
+ def _on_side_run_clicked(self, item) -> None:
+ """Open the full Flow Status page with this run selected."""
+ run_id = item.data(Qt.UserRole)
+ self._show_runs(True)
+ for r in range(self.runs_table.rowCount()):
+ cell = self.runs_table.item(r, 0)
+ if cell is not None and cell.data(Qt.UserRole) == run_id:
+ self.runs_table.setCurrentCell(r, 0)
+ break
+
+ def _section(self, key: str, body: QWidget, action: QPushButton | None = None,
+ stretch: int = 1) -> QWidget:
+ """One named, foldable section of the sidebar column.
+
+ Replaces the three icon-only tabs: all the lists are visible at once
+ (WORKFLOWS · AGENTS · SKILLS · runs), each under its own heading with the
+ action that belongs to it. Clicking the heading folds the section, so a
+ narrow window can still get to everything.
+ """
+ box = QWidget()
+ v = QVBoxLayout(box)
+ v.setContentsMargins(0, 0, 0, 0)
+ v.setSpacing(2)
+
+ row = QHBoxLayout()
+ row.setContentsMargins(0, 0, 0, 0)
+ row.setSpacing(4)
+ head = QPushButton()
+ head.setObjectName("co4eSectionHdr")
+ head.setCheckable(True)
+ head.setChecked(True)
+ head.setCursor(Qt.PointingHandCursor)
+ head.setFlat(True)
+ head.toggled.connect(lambda on, w=body, b=box: self._fold_section(key, w, b, on))
+ row.addWidget(head, 1)
+ if action is not None:
+ row.addWidget(action, 0)
+ v.addLayout(row)
+ v.addWidget(body, 1)
+
+ self._sections[key] = (head, body, stretch)
+ self._sync_section_arrow(key)
+ 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.
+
+ Inside a splitter, hiding the body is not enough — the pane keeps its
+ share of the height, so folding would free nothing. Clamping the whole
+ section to its header height makes the splitter re-deal the space.
+ """
+ body.setVisible(on)
+ if on:
+ box.setMaximumHeight(16777215)
+ else:
+ box.setMaximumHeight(box.layout().itemAt(0).sizeHint().height() + 4)
+ self._sync_section_arrow(key)
+
+ def _sync_section_arrow(self, key: str) -> None:
+ head, _body, _s = self._sections[key]
+ head.setText(("▾ " if head.isChecked() else "▸ ") + tr(key).upper())
+
def _icon_btn(self, icon_name: str, tip_key: str, slot) -> QPushButton:
b = QPushButton(); b.setIcon(icon(icon_name)); b.setToolTip(tr(tip_key))
b.setFixedWidth(34)
@@ -564,13 +748,14 @@ class Co4ETab(QWidget):
# Tab colours + layout live in theme.py (QTabBar#flowTabs — theme-aware,
# flush, centred). Here we only style the per-tab close (✕) button, which
# QTabBar places centred on the tab's right (see _add_tab_close_button).
+ _fp = current_palette()
self.flow_bar.setStyleSheet(
"QPushButton#flowTabClose {"
- " border: none; background: transparent; color: #8FB2D4;"
+ f" border: none; background: transparent; color: {_fp.text_muted};"
" font-size: 13px; font-weight: bold; padding: 0; margin: 0;"
- " border-radius: 8px; }"
+ f" border-radius: {_fp.radius_sm}px; }}"
"QPushButton#flowTabClose:hover {"
- " background: rgba(229,72,77,0.18); color: #E5484D; }")
+ f" background: {_fp.danger_soft}; color: {_fp.danger}; }}")
runs_idx = self.flow_bar.addTab(icon("monitoring"), tr("co4e.runs_tab")) # 0 = Runs
self.flow_bar.setTabButton(runs_idx, QTabBar.RightSide, None) # pinned
self.flow_bar.currentChanged.connect(self._on_flow_tab_changed)
@@ -606,15 +791,16 @@ class Co4ETab(QWidget):
"QScrollArea#flowTabScroll { background: transparent; border: none; }"
"QScrollArea#flowTabScroll QScrollBar:horizontal { height: 8px; background: transparent; margin: 0; }"
"QScrollArea#flowTabScroll QScrollBar::handle:horizontal {"
- " background: rgba(143,178,212,0.45); border-radius: 4px; min-width: 30px; }"
+ f" background: {current_palette().scroll_handle}; border-radius: 4px; min-width: 30px; }}"
"QScrollArea#flowTabScroll QScrollBar::add-line:horizontal,"
"QScrollArea#flowTabScroll QScrollBar::sub-line:horizontal { width: 0; height: 0; }")
- flow_row = QHBoxLayout()
- flow_row.setContentsMargins(0, 0, 0, 0)
- flow_row.setSpacing(3) # same gap as between tabs → looks like one strip
- flow_row.addWidget(self.flow_scroll, 1)
- flow_row.addWidget(self.flow_add_btn, 0, Qt.AlignVCenter)
- lay.addLayout(flow_row)
+ # The strip itself is NOT shown any more (see class docstring): flows are
+ # picked from the WORKFLOWS list on the left, one open at a time. The
+ # QTabBar stays alive off-screen as the index that maps flow ↔ canvas —
+ # every open/close/rename path already goes through it — but the user
+ # never sees or drives it.
+ self.flow_scroll.setVisible(False)
+ self.flow_add_btn.setVisible(False)
# Content switches between the Runs table (tab 0) and the flow editor.
self.center_stack = QStackedWidget()
@@ -650,6 +836,14 @@ class Co4ETab(QWidget):
self.run_btn.setToolTip(tr("co4e.tt_run"))
self.run_btn.clicked.connect(self._on_run_clicked)
+ # The pinned "Runs" tab lost its strip, so it becomes a toggle here —
+ # one click to the run table and one click back, from either page.
+ self.runs_btn = QPushButton(tr("co4e.runs_tab"))
+ self.runs_btn.setIcon(icon("monitoring"))
+ self.runs_btn.setCheckable(True)
+ self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
+ self.runs_btn.toggled.connect(self._show_runs)
+
bar.addWidget(QLabel(tr("co4e.flow_name")))
bar.addWidget(self.name_edit, 1)
bar.addWidget(self.add_step_btn)
@@ -657,6 +851,7 @@ class Co4ETab(QWidget):
bar.addWidget(self.save_tpl_btn)
bar.addWidget(self.mode_combo)
bar.addWidget(self.run_btn)
+ bar.addWidget(self.runs_btn)
lay.addLayout(bar)
self.canvas = Co4ECanvas()
@@ -683,6 +878,13 @@ class Co4ETab(QWidget):
w = QWidget()
v = QVBoxLayout(w)
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.runs_back_btn = QPushButton(tr("co4e.back_to_flow"))
+ self.runs_back_btn.setIcon(icon("chevron-left"))
+ self.runs_back_btn.setToolTip(tr("co4e.tt_back_to_flow"))
+ self.runs_back_btn.clicked.connect(lambda: self._show_runs(False))
+ hdr.addWidget(self.runs_back_btn)
self.runs_title = QLabel(tr("co4e.running_flows"))
self.runs_title.setObjectName("hint")
hdr.addWidget(self.runs_title)
@@ -762,6 +964,29 @@ class Co4ETab(QWidget):
self.config_container = container
return container
+ # Below this window width the three panes (rail + 180 sidebar + canvas +
+ # 300 config) leave the canvas too little to draw a flow in, and the config
+ # fields start clipping instead of shrinking. Measured with
+ # tools/check_responsive.py — Co4E gets the full content area (no project or
+ # history pane beside it), so the threshold is about its own screen only.
+ _NARROW = 1300
+
+ def showEvent(self, e): # noqa: N802 - Qt override
+ super().showEvent(e)
+ self._narrow_guard.attach()
+
+ def _apply_narrow_layout(self, narrow: bool) -> None: # noqa: D401
+ """Fold the step-config panel on a narrow window, restore it when there
+ is room again.
+
+ Attached from __init__ rather than only on show: this page sits inside a
+ QTabWidget, whose minimum width is the MAXIMUM over all its pages —
+ including hidden ones. While Co4E sat unfolded in the background it was
+ forcing Project and Cowork to be ~1180px wide too.
+ """
+ if narrow != self._config_collapsed:
+ self._toggle_config()
+
def _toggle_config(self) -> None:
self._config_collapsed = not self._config_collapsed
v = self._cfg_vlayout
@@ -785,6 +1010,11 @@ class Co4ETab(QWidget):
sizes[2] = 34
sizes[1] = max(200, sizes[1] + freed)
self._split.setSizes(sizes)
+ # Without this the splitter keeps reporting the OLD minimum width,
+ # and since a QTabWidget's minimum is the maximum over all its pages
+ # — hidden ones included — Co4E would go on forcing Project and
+ # Cowork to be 1180px wide even while folded here.
+ self._refresh_min_width()
else:
v.removeItem(self._cfg_top_spacer)
v.removeItem(self._cfg_bot_spacer)
@@ -800,6 +1030,14 @@ class Co4ETab(QWidget):
sizes[2] = want
sizes[1] = max(200, sizes[1] - delta)
self._split.setSizes(sizes)
+ self._refresh_min_width()
+
+ def _refresh_min_width(self) -> None:
+ """Make the splitter (and everything above it) re-read its minimum."""
+ self.config_container.updateGeometry()
+ self._split.refresh()
+ self._split.updateGeometry()
+ self.updateGeometry()
def _build_canvas_overlay(self) -> None:
"""Zoom +/− and Fit as a small floating control at the canvas's
@@ -861,7 +1099,8 @@ class Co4ETab(QWidget):
# $cost) at the bottom, exactly like Cowork's conversation total.
self._usage_total_lbl = QLabel("")
self._usage_total_lbl.setObjectName("hint")
- self._usage_total_lbl.setStyleSheet("color: rgba(140,146,152,0.9); font-size: 11px;")
+ self._usage_total_lbl.setStyleSheet(
+ f"color: {current_palette().text_faint}; font-size: 11px;")
crow.addWidget(self._usage_total_lbl)
_inp = QWidget(); row = QHBoxLayout(_inp); row.setContentsMargins(0, 0, 0, 0)
self.chat_input = _ChatInput()
@@ -969,7 +1208,14 @@ class Co4ETab(QWidget):
self._refresh_usage_total() # show THIS flow's token/cost total
def _new_workflow(self) -> None:
- self._open_flow(co4e.new_workflow(tr("co4e.untitled"))) # opens a new tab
+ self._open_flow(co4e.new_workflow(tr("co4e.untitled")))
+ # Pressing this while the canvas already holds an empty untitled flow
+ # produced an identical empty untitled flow — correct, and completely
+ # invisible, so the button read as broken. Say what happened and put the
+ # cursor where the next thing to do is: naming it.
+ self.name_edit.setFocus()
+ self.name_edit.selectAll()
+ self.status_message.emit(tr("co4e.new_flow_ready"))
def _selected_wf(self) -> Optional[co4e.Workflow]:
"""Materialise the selected saved-flow row into a Workflow."""
@@ -1331,8 +1577,9 @@ class Co4ETab(QWidget):
# Rebuild the always-fresh Runs table from the manager (single source of truth).
if not hasattr(self, "runs_table"):
return
- color = {"running": "#48CAE4", "done": "#48D9A0", "error": "#E5484D",
- "stopped": "#8FB2D4"}
+ p = current_palette()
+ color = {"running": p.accent, "done": p.success, "error": p.danger,
+ "stopped": p.text_muted}
dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
# Most-recent run at the TOP, oldest at the bottom (manager keeps runs in
# chronological insertion order, so reverse it for display).
@@ -1352,16 +1599,22 @@ class Co4ETab(QWidget):
if c == 0:
it.setData(Qt.UserRole, h.id)
if c == 1:
- it.setForeground(_qcolor(color.get(h.status, "#E0F0FF")))
+ it.setForeground(_qcolor(color.get(h.status, p.text)))
t.setItem(r, c, it)
if h.id == sel_id:
sel_row = r
if sel_row >= 0:
t.setCurrentCell(sel_row, 0)
- # reflect the active run count in the pinned Runs tab title
+ # The sidebar's short run list is the same data — refresh it together.
+ self._refresh_side_runs()
+ # Active-run count, on the sidebar heading now that the tab strip is gone.
+ n = self.manager.active_count()
+ label = tr("co4e.runs_tab_n", n=n) if n else tr("co4e.runs_tab")
if hasattr(self, "flow_bar"):
- n = self.manager.active_count()
- self.flow_bar.setTabText(0, tr("co4e.runs_tab_n", n=n) if n else tr("co4e.runs_tab"))
+ self.flow_bar.setTabText(0, label)
+ head = (self._sections.get("co4e.runs_tab") or (None,))[0]
+ if head is not None:
+ head.setText(("▾ " if head.isChecked() else "▸ ") + label.upper())
def _stop_selected_run(self) -> None:
row = self.runs_table.currentRow()
@@ -1502,7 +1755,9 @@ class Co4ETab(QWidget):
return
self.manager.start(wf, skill_map=self._skill_map(),
plan_mode=(self._current_mode() == "plan"))
- self.sidebar.setCurrentIndex(0)
+ # Used to jump the sidebar back to the Workflows tab; with one column
+ # there is nothing to jump to — show the run that just started instead.
+ self._refresh_side_runs()
self.status_message.emit(tr("co4e.bg_started", name=wf.name))
def _wf_by_id(self, wf_id: str) -> Optional[co4e.Workflow]:
@@ -1803,9 +2058,15 @@ class Co4ETab(QWidget):
# ---- i18n -------------------------------------------------------------
def _retranslate(self) -> None:
- self.sidebar.setTabToolTip(0, tr("co4e.tab_workflows"))
- self.sidebar.setTabToolTip(1, tr("co4e.tab_agents"))
- self.sidebar.setTabToolTip(2, tr("co4e.tab_skills"))
+ 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"))
+ 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"))
diff --git a/ui/composer.py b/ui/composer.py
index 205d7f0..0f41822 100644
--- a/ui/composer.py
+++ b/ui/composer.py
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
from ..config import CONFIG_DIR
from ..i18n import on_language_changed, tr
+from ..theme import current_palette
from .icons import icon, IconLabel
@@ -451,7 +452,12 @@ class Composer(QWidget):
self.stop_btn.setObjectName("danger")
self.stop_btn.setVisible(False)
self.stop_btn.clicked.connect(self.stop_requested.emit)
+ # Attach pinned to the input's top edge, Send (and Stop, once a turn
+ # is running) pinned to its bottom edge — the gap between them is
+ # absorbed by this stretch instead of splitting evenly above/below
+ # the whole button column, which is what centering it did before.
btns.addWidget(self.attach_btn)
+ btns.addStretch(1)
btns.addWidget(self.send_btn)
btns.addWidget(self.stop_btn)
row.addLayout(btns)
@@ -459,11 +465,18 @@ class Composer(QWidget):
# bottom row: left slot (e.g. Cowork's output-folder picker) — stretch —
# right slot (e.g. Plan/Act toggle, the per-tab Agent combo on Code/Cowork)
+ # Its own strip UNDER the typing box, styled as a status line rather
+ # than a second toolbar: the design asks for the typing area to be just
+ # input · attach · send, with agent / routing / usage / folder reading
+ # as status underneath. They stay interactive — only quieter.
self._bottom_left_count = 0
- self.extra_row = QHBoxLayout()
- self.extra_row.setContentsMargins(0, 0, 0, 0)
+ self.extra_bar = QWidget()
+ self.extra_bar.setObjectName("composerStatus")
+ self.extra_row = QHBoxLayout(self.extra_bar)
+ self.extra_row.setContentsMargins(2, 2, 2, 0)
+ self.extra_row.setSpacing(6)
self.extra_row.addStretch(1)
- root.addLayout(self.extra_row)
+ root.addWidget(self.extra_bar)
on_language_changed(self._retranslate)
@@ -583,7 +596,10 @@ class Composer(QWidget):
for p in self._attachments:
item = QListWidgetItem()
row = QWidget()
- row.setStyleSheet("background: rgba(140,146,152,0.18); border-radius: 6px;")
+ _cp = current_palette()
+ row.setStyleSheet(
+ f"background: {_cp.surface_raised}; border: 1px solid {_cp.border};"
+ f" border-radius: {_cp.radius_sm}px;")
h = QHBoxLayout(row)
h.setContentsMargins(8, 2, 4, 2)
h.setSpacing(4)
diff --git a/ui/connectors_panel.py b/ui/connectors_panel.py
index 1c9fd4d..77f9bd7 100644
--- a/ui/connectors_panel.py
+++ b/ui/connectors_panel.py
@@ -1,18 +1,20 @@
"""Connectors (MCP / REST API) management — the setup UI.
-Lives in Monitoring → Tools → "Connector" sub-tab (moved out of Settings). A
-tree of the four categories (CAD / CAE / MS365 / Other); each connector has an
-Enabled checkbox and can be added / edited / deleted (MCP-stdio or REST-API,
-via ExtConnectorEditDialog). Built-in connectors (MS365 OneDrive / SharePoint,
-and Jira under "Other") appear as rows in the tree with their checkbox bound to
-config; double-clicking one opens its setup — nothing spills outside the tree.
+Lives in Monitoring → Tools → "Connector" sub-tab (moved out of Settings).
+Grouped by the four real categories (CAD / CAE / MS365 / Other): each is a
+header (icon + name + the software it covers) above a left-aligned row of
+cards, one per real connector, each with its own on/off switch — user-added
+connectors (MCP-stdio or REST-API, via ExtConnectorEditDialog) also get
+✎ Sửa/🗑 Xóa; the built-in ones (MS365 OneDrive/SharePoint, Jira under
+"Other") only get what they actually support (Jira: ✎ Sửa only, opens its own
+setup dialog; OneDrive/SharePoint: neither, they only toggle).
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
- QCheckBox, QDialog, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox,
- QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,
+ QDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, QMessageBox,
+ QPushButton, QScrollArea, QVBoxLayout, QWidget,
)
from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
@@ -21,6 +23,7 @@ from ..i18n import on_language_changed, tr
from ..state import AppContext
from .ext_connector_dialog import ExtConnectorEditDialog
from .icons import icon
+from .widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_card
class JiraConnectDialog(QDialog):
@@ -130,174 +133,219 @@ class ConnectorsPanel(QWidget):
# Master switch: connect to external connectors at all (default ON).
# Off = the agent connects to NO external connector/MCP (see
# AppContext.build_mcp_tools), regardless of the per-connector checks below.
- self.connect_external_chk = QCheckBox(tr("connectors.connect_external"))
- self.connect_external_chk.setChecked(self.ctx.config.connect_external)
- self.connect_external_chk.setToolTip(tr("connectors.connect_external_tooltip"))
- self.connect_external_chk.toggled.connect(self._on_connect_external_toggled)
- lay.addWidget(self.connect_external_chk)
+ self.connect_external_sw = ToggleSwitch(tr("connectors.connect_external"))
+ self.connect_external_sw.setChecked(self.ctx.config.connect_external)
+ self.connect_external_sw.setToolTip(tr("connectors.connect_external_tooltip"))
+ self.connect_external_sw.toggled.connect(self._on_connect_external_toggled)
+ lay.addWidget(self.connect_external_sw)
hint = QLabel(tr("settings.ext_hint"))
hint.setObjectName("hint")
hint.setWordWrap(True)
lay.addWidget(hint)
- self.ext_tree = QTreeWidget()
- self.ext_tree.setHeaderHidden(True)
- self.ext_tree.itemChanged.connect(self._on_ext_check)
- self.ext_tree.itemDoubleClicked.connect(lambda *_: self._ext_edit())
- lay.addWidget(self.ext_tree, 1)
+ # Each category (CAD/CAE/MS365/Other) is a header + a left-aligned,
+ # wrapping row of cards — one per real connector — instead of a tree
+ # the admin had to expand to see what was inside.
+ self._cat_scroll = QScrollArea()
+ self._cat_scroll.setWidgetResizable(True)
+ self._cat_scroll.setFrameShape(QScrollArea.NoFrame)
+ cat_host = QWidget()
+ enable_height_for_width(cat_host) # holds several height-for-width sections — see FlowLayout
+ self._cat_lay = QVBoxLayout(cat_host)
+ self._cat_lay.setContentsMargins(0, 4, 0, 4)
+ self._cat_lay.setSpacing(10)
+ self._cat_scroll.setWidget(cat_host)
+ lay.addWidget(self._cat_scroll, 1)
- self.dbl_hint = QLabel(tr("connectors.dbl_configure"))
- self.dbl_hint.setObjectName("hint")
- lay.addWidget(self.dbl_hint)
-
- row = QHBoxLayout()
self.add_btn = QPushButton(tr("settings.ext_add_btn"))
self.add_btn.setIcon(icon("plus"))
self.add_btn.setObjectName("primary")
+ self.add_btn.setCursor(Qt.PointingHandCursor)
self.add_btn.clicked.connect(self._ext_add)
- self.edit_btn = QPushButton(tr("settings.ext_edit_btn"))
- self.edit_btn.setIcon(icon("edit"))
- self.edit_btn.clicked.connect(self._ext_edit)
- self.del_btn = QPushButton(tr("settings.ext_delete_btn"))
- self.del_btn.setIcon(icon("trash"))
- self.del_btn.clicked.connect(self._ext_delete)
- row.addWidget(self.add_btn)
- row.addWidget(self.edit_btn)
- row.addWidget(self.del_btn)
- row.addStretch(1)
- lay.addLayout(row)
+ add_row = QHBoxLayout()
+ add_row.addWidget(self.add_btn)
+ add_row.addStretch(1)
+ lay.addLayout(add_row)
self.ms365_local_status = QLabel()
self.ms365_local_status.setObjectName("hint")
self.ms365_local_status.setWordWrap(True)
lay.addWidget(self.ms365_local_status)
+ # on_language_changed() below already invokes _retranslate() once
+ # immediately (see i18n.py), which itself calls _reload_connectors() —
+ # calling it again here would rebuild the category cards twice
+ # back-to-back with no event-loop turn in between, so the first pass's
+ # widgets are only QUEUED for deleteLater() (not yet gone) when the
+ # second pass adds new ones on top: the two rows visually overlap
+ # (the exact bug agents_admin_tab.py hit the same way).
self._refresh_ms365_local_status()
- self._reload_ext_tree()
on_language_changed(self._retranslate)
# ---- rendering ------------------------------------------------------------
- def _reload_ext_tree(self) -> None:
- self.ext_tree.blockSignals(True)
- self.ext_tree.clear()
- ext = self.ctx.config.ext_connectors
+ def _clear_categories(self) -> None:
+ while self._cat_lay.count():
+ item = self._cat_lay.takeAt(0)
+ w = item.widget()
+ if w is not None:
+ w.deleteLater()
+
+ def _reload_connectors(self) -> None:
+ self._clear_categories()
for cat in EXT_CATEGORIES:
- cat_item = QTreeWidgetItem([self._EXT_CATEGORY_LABELS.get(cat, cat)])
- cat_item.setIcon(0, icon(self._EXT_CATEGORY_ICONS.get(cat, "plug")))
- cat_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
- cat_item.setData(0, Qt.UserRole, ("category", cat))
- self.ext_tree.addTopLevelItem(cat_item)
- if cat == "ms365":
- conns = self.ctx.config.ms365.get("connectors", {})
- for key, label in self._MS365_BUILTIN_LABELS.items():
- b = QTreeWidgetItem([f"{label} — {tr('ext.mode_builtin')}"])
- b.setFlags(b.flags() | Qt.ItemIsUserCheckable)
- b.setCheckState(0, Qt.Checked if conns.get(key) else Qt.Unchecked)
- b.setData(0, Qt.UserRole, ("ms365_builtin", "ms365", key))
- cat_item.addChild(b)
- for idx, entry in enumerate(ext.get(cat, [])):
- mode_label = tr("ext.mode_mcp") if entry.get("mode") == "mcp_stdio" else tr("ext.mode_rest")
- child = QTreeWidgetItem([f"{entry.get('name', '')} — {mode_label}"])
- child.setFlags(child.flags() | Qt.ItemIsUserCheckable)
- child.setCheckState(0, Qt.Checked if entry.get("enabled") else Qt.Unchecked)
- child.setData(0, Qt.UserRole, ("connector", cat, idx))
- cat_item.addChild(child)
- if cat == "other":
- # Jira is a built-in "Other" connector (like OneDrive under MS365):
- # checkbox = enabled; double-click opens its minimal setup dialog.
- jira = self.ctx.config.data.get("jira", {})
- configured = bool(jira.get("base_url") and jira.get("email")
- and jira.get("api_token"))
- jstate = tr("connectors.jira_connected") if configured else tr("connectors.jira_not_set")
- jrow = QTreeWidgetItem([f"Jira — {tr('ext.mode_builtin')} · {jstate}"])
- jrow.setIcon(0, icon("link"))
- jrow.setFlags(jrow.flags() | Qt.ItemIsUserCheckable)
- on = configured and jira.get("enabled", True)
- jrow.setCheckState(0, Qt.Checked if on else Qt.Unchecked)
- jrow.setToolTip(0, tr("connectors.jira_setup_hint"))
- jrow.setData(0, Qt.UserRole, ("jira_builtin", "other"))
- cat_item.addChild(jrow)
- cat_item.setExpanded(True)
- self.ext_tree.blockSignals(False)
+ self._cat_lay.addWidget(self._category_section(cat))
- def _on_ext_check(self, item: QTreeWidgetItem, _col: int) -> None:
- data = item.data(0, Qt.UserRole)
- if data and data[0] == "ms365_builtin":
- self.ctx.config.ms365.setdefault("connectors", {})[data[2]] = (
- item.checkState(0) == Qt.Checked)
- self.ctx.save()
- return
- if data and data[0] == "jira_builtin":
- self.ctx.config.data.setdefault("jira", {})["enabled"] = (
- item.checkState(0) == Qt.Checked)
- self.ctx.save()
- return
- if not data or data[0] != "connector":
- return
- _, cat, idx = data
- entries = self.ctx.config.ext_connectors.get(cat, [])
- if 0 <= idx < len(entries):
- entries[idx]["enabled"] = item.checkState(0) == Qt.Checked
- self.ctx.save()
+ def _category_section(self, cat: str) -> QWidget:
+ section = QWidget()
+ enable_height_for_width(section) # this section wraps a FlowLayout row — see FlowLayout
+ sl = QVBoxLayout(section)
+ sl.setContentsMargins(0, 0, 0, 0)
+ sl.setSpacing(6)
- def _current_ext_category(self) -> str:
- item = self.ext_tree.currentItem()
- data = item.data(0, Qt.UserRole) if item else None
- return data[1] if data else EXT_CATEGORIES[0]
+ hdr = QHBoxLayout()
+ hdr.setSpacing(6)
+ icon_lbl = QLabel()
+ icon_lbl.setPixmap(icon(self._EXT_CATEGORY_ICONS.get(cat, "plug"), size=18).pixmap(18, 18))
+ hdr.addWidget(icon_lbl)
+ name, _, subtitle = self._EXT_CATEGORY_LABELS.get(cat, cat).partition(" (")
+ name_lbl = QLabel(name)
+ name_lbl.setStyleSheet("font-weight:700;")
+ hdr.addWidget(name_lbl)
+ if subtitle:
+ sub_lbl = QLabel("(" + subtitle)
+ sub_lbl.setObjectName("hint")
+ hdr.addWidget(sub_lbl)
+ hdr.addStretch(1)
+ sl.addLayout(hdr)
- def _current_ext_connector(self):
- item = self.ext_tree.currentItem()
- data = item.data(0, Qt.UserRole) if item else None
- if not data or data[0] != "connector":
- return None
- _, cat, idx = data
- entries = self.ctx.config.ext_connectors.get(cat, [])
- return (cat, entries[idx]) if 0 <= idx < len(entries) else None
+ flow_host = QWidget()
+ flow = FlowLayout(flow_host, margin=0, h_spacing=10, v_spacing=10)
+
+ if cat == "ms365":
+ conns = self.ctx.config.ms365.get("connectors", {})
+ for key, label in self._MS365_BUILTIN_LABELS.items():
+ flow.addWidget(self._connector_card(
+ label, tr("connectors.builtin_auto"), bool(conns.get(key)),
+ lambda on, k=key: self._toggle_ms365_builtin(k, on)))
+
+ for entry in self.ctx.config.ext_connectors.get(cat, []):
+ mode_label = tr("ext.mode_mcp") if entry.get("mode") == "mcp_stdio" else tr("ext.mode_rest")
+ flow.addWidget(self._connector_card(
+ entry.get("name", ""), mode_label, bool(entry.get("enabled")),
+ lambda on, e=entry: self._toggle_ext_entry(e, on),
+ # QPushButton.clicked emits a `checked` bool — a lambda whose
+ # ONLY parameter is a defaulted capture (`e=entry`) looks like
+ # it accepts that bool, so Qt hands it the click state instead
+ # of using the default, silently replacing the captured dict
+ # with False. An explicit leading `checked=False` soaks up the
+ # signal's argument so `e` keeps the entry it was defined with.
+ edit_cb=lambda checked=False, e=entry: self._edit_ext_entry(cat, e),
+ delete_cb=lambda checked=False, e=entry: self._delete_ext_entry(cat, e)))
+
+ if cat == "other":
+ # Jira is a built-in "Other" connector (like OneDrive under MS365):
+ # switch = enabled; ✎ Sửa opens its own minimal setup dialog — no
+ # 🗑 Xóa, same as OneDrive/SharePoint have neither (nothing to delete).
+ jira = self.ctx.config.data.get("jira", {})
+ configured = bool(jira.get("base_url") and jira.get("email") and jira.get("api_token"))
+ jstate = tr("connectors.jira_connected") if configured else tr("connectors.jira_not_set")
+ flow.addWidget(self._connector_card(
+ "Jira", f"{tr('ext.mode_builtin').capitalize()} · {jstate}",
+ configured and jira.get("enabled", True), self._toggle_jira,
+ edit_cb=self._open_jira_dialog))
+
+ sl.addWidget(flow_host)
+ return section
+
+ def _connector_card(self, title: str, subtitle: str, checked: bool, on_toggle,
+ edit_cb=None, delete_cb=None) -> QWidget:
+ card = QFrame()
+ card.setFrameShape(QFrame.NoFrame)
+ style_card(card)
+ lay = QVBoxLayout(card)
+ lay.setContentsMargins(10, 8, 10, 8)
+ lay.setSpacing(4)
+
+ hdr = QHBoxLayout()
+ name_lbl = QLabel(title)
+ name_lbl.setStyleSheet("font-weight:700; border: none;")
+ hdr.addWidget(name_lbl)
+ hdr.addStretch(1)
+ sw = ToggleSwitch()
+ sw.setChecked(checked)
+ sw.toggled.connect(on_toggle)
+ hdr.addWidget(sw)
+ lay.addLayout(hdr)
+
+ sub_lbl = QLabel(subtitle)
+ sub_lbl.setObjectName("hint")
+ sub_lbl.setStyleSheet("border: none;")
+ lay.addWidget(sub_lbl)
+
+ if edit_cb is not None or delete_cb is not None:
+ actions = QHBoxLayout()
+ actions.setContentsMargins(0, 2, 0, 0)
+ actions.setSpacing(2)
+ if edit_cb is not None:
+ b = QPushButton(tr("settings.ext_edit_btn"))
+ b.setIcon(icon("edit"))
+ b.setFlat(True)
+ b.setCursor(Qt.PointingHandCursor)
+ b.clicked.connect(edit_cb)
+ actions.addWidget(b)
+ if delete_cb is not None:
+ b = QPushButton(tr("settings.ext_delete_btn"))
+ b.setIcon(icon("trash"))
+ b.setFlat(True)
+ b.setCursor(Qt.PointingHandCursor)
+ b.clicked.connect(delete_cb)
+ actions.addWidget(b)
+ actions.addStretch(1)
+ lay.addLayout(actions)
+
+ return card
+
+ def _toggle_ms365_builtin(self, key: str, checked: bool) -> None:
+ self.ctx.config.ms365.setdefault("connectors", {})[key] = checked
+ self.ctx.save()
+
+ def _toggle_jira(self, checked: bool) -> None:
+ self.ctx.config.data.setdefault("jira", {})["enabled"] = checked
+ self.ctx.save()
+
+ def _toggle_ext_entry(self, entry: dict, checked: bool) -> None:
+ entry["enabled"] = checked
+ self.ctx.save()
# ---- CRUD -----------------------------------------------------------------
def _ext_add(self) -> None:
- dlg = ExtConnectorEditDialog(self, category=self._current_ext_category())
+ dlg = ExtConnectorEditDialog(self, category=EXT_CATEGORIES[0])
if dlg.exec():
entry = dlg.result_connector()
self.ctx.config.ext_connectors.setdefault(entry["category"], []).append(entry)
self.ctx.save()
- self._reload_ext_tree()
+ self._reload_connectors()
- def _ext_edit(self) -> None:
- """Configure the selected row. Built-in Jira → its minimal dialog;
- a normal connector → the MCP/REST editor."""
- item = self.ext_tree.currentItem()
- data = item.data(0, Qt.UserRole) if item else None
- if data and data[0] == "jira_builtin":
- self._open_jira_dialog()
- return
- current = self._current_ext_connector()
- if current is None:
- return
- cat, entry = current
+ def _edit_ext_entry(self, cat: str, entry: dict) -> None:
dlg = ExtConnectorEditDialog(self, category=cat, connector=entry)
if dlg.exec():
entry.update(dlg.result_connector())
self.ctx.save()
- self._reload_ext_tree()
+ self._reload_connectors()
def _open_jira_dialog(self) -> None:
JiraConnectDialog(self.ctx, self).exec()
- self._reload_ext_tree()
+ self._reload_connectors()
- def _ext_delete(self) -> None:
- current = self._current_ext_connector()
- if current is None:
- return
- cat, entry = current
+ def _delete_ext_entry(self, cat: str, entry: dict) -> None:
if QMessageBox.question(
self, tr("settings.ext_delete_btn"),
tr("settings.ext_delete_confirm", name=entry.get("name", ""))) != QMessageBox.Yes:
return
self.ctx.config.ext_connectors[cat].remove(entry)
self.ctx.save()
- self._reload_ext_tree()
+ self._reload_connectors()
def _refresh_ms365_local_status(self) -> None:
from .. import paths
@@ -314,16 +362,13 @@ class ConnectorsPanel(QWidget):
def _apply_connect_external_enabled(self, on: bool) -> None:
"""Grey out the per-connector setup when the master switch is off — the
agent won't connect to any of them anyway."""
- for w in (self.ext_tree, self.add_btn, self.edit_btn, self.del_btn):
+ for w in (self._cat_scroll, self.add_btn):
w.setEnabled(on)
def _retranslate(self) -> None:
- self.connect_external_chk.setText(tr("connectors.connect_external"))
- self.connect_external_chk.setToolTip(tr("connectors.connect_external_tooltip"))
+ self.connect_external_sw.setText(tr("connectors.connect_external"))
+ self.connect_external_sw.setToolTip(tr("connectors.connect_external_tooltip"))
self.add_btn.setText(tr("settings.ext_add_btn"))
- self.edit_btn.setText(tr("settings.ext_edit_btn"))
- self.del_btn.setText(tr("settings.ext_delete_btn"))
- self.dbl_hint.setText(tr("connectors.dbl_configure"))
self._refresh_ms365_local_status()
- self._reload_ext_tree()
+ self._reload_connectors()
self._apply_connect_external_enabled(self.ctx.config.connect_external)
diff --git a/ui/cowork_tab.py b/ui/cowork_tab.py
index 7b516d7..44c620f 100644
--- a/ui/cowork_tab.py
+++ b/ui/cowork_tab.py
@@ -81,8 +81,21 @@ class CoworkTab(ChatPanel):
# succeeds (see _cleanup_turn) — never intermediate files or a folder name.
on_language_changed(self._retranslate)
+ def refresh_title(self) -> None:
+ """Head the screen with the thread you are in, as the drawing does.
+
+ It said "Cowork" on every conversation — the screen's own name, which
+ the rail already shows. The thread's title is the thing that changes and
+ the thing that tells you where you are; a thread with no title yet (a
+ new chat, before its first turn) falls back to the screen name.
+ """
+ lbl = getattr(self, "_title_lbl", None)
+ if lbl is None:
+ return # ChatPanel.__init__ sets self.title before we exist
+ lbl.setText(getattr(self, "title", "") or tr("cowork.title"))
+
def _retranslate(self) -> None:
- self._title_lbl.setText(tr("cowork.title"))
+ self.refresh_title()
self.skills_btn.setText(tr("cowork.skills_btn"))
self.skills_btn.setToolTip(tr("cowork.skills_tooltip"))
self._new_btn.setText(tr("cowork.new_chat"))
diff --git a/ui/dashboard_tab.py b/ui/dashboard_tab.py
index 3c4c17a..8978b8e 100644
--- a/ui/dashboard_tab.py
+++ b/ui/dashboard_tab.py
@@ -24,6 +24,7 @@ from ..core import usage_tracker as ut
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
+from ..theme import current_palette
from .icons import icon
from .spline_chart import SplineChart
from .widgets import BudgetCard as _BudgetCard
@@ -91,39 +92,52 @@ class DashboardTab(QWidget):
self.refresh_btn.setIcon(icon("refresh"))
self.refresh_btn.setFixedWidth(34)
self.refresh_btn.clicked.connect(self.refresh)
+ # Two rows, grouped by what the controls do, instead of nine widgets
+ # strung across one line where the title, a date pager, two chart
+ # selectors, a currency picker and Refresh all read as one undifferentiated
+ # strip. Row 1 is "where am I"; row 2 is "what am I looking at".
head.addWidget(self._title, 1)
- head.addWidget(self.chart_prev_btn)
- head.addWidget(self._chart_period_lbl)
- head.addWidget(self.chart_next_btn)
- head.addWidget(self.gran_combo)
- head.addWidget(self.metric_combo)
- head.addWidget(self.currency_lbl)
- head.addWidget(self.currency_combo)
head.addWidget(self.refresh_btn)
root.addLayout(head)
+ controls = QHBoxLayout()
+ controls.setSpacing(6)
+ controls.addWidget(self.chart_prev_btn) # period pager
+ controls.addWidget(self._chart_period_lbl)
+ controls.addWidget(self.chart_next_btn)
+ controls.addSpacing(12)
+ controls.addWidget(self.gran_combo) # what the chart plots
+ controls.addWidget(self.metric_combo)
+ controls.addStretch(1)
+ controls.addWidget(self.currency_lbl) # how money is displayed
+ controls.addWidget(self.currency_combo)
+ root.addLayout(controls)
+
# ---- stat cards ---------------------------------------------------
- # Single row, 5 equal-width cards (same layout as Monitoring Overview)
+ # Cost is the headline this screen exists for, so it gets a card twice
+ # the height of the rest instead of being the fifth of five identical
+ # tiles — with six equal cards nothing said which number mattered.
cards_grid = QGridLayout()
cards_grid.setSpacing(8)
self.card_total = _StatCard()
self.card_in = _StatCard()
self.card_out = _StatCard()
self.card_cache = _StatCard()
- self.card_cost = _StatCard()
- for i, card in enumerate((self.card_total, self.card_in, self.card_out,
- self.card_cache, self.card_cost)):
- cards_grid.addWidget(card, 0, i)
+ self.card_cost = _StatCard().as_hero()
+ # Hero on the left, spanning both rows; the four supporting figures fill
+ # a 2×2 block beside it.
+ cards_grid.addWidget(self.card_cost, 0, 0, 2, 1)
+ for i, card in enumerate((self.card_total, self.card_in,
+ self.card_out, self.card_cache)):
+ cards_grid.addWidget(card, i // 2, 1 + i % 2)
# Budget: remaining/budget, direct entry, auto-warns red past 85% used.
self.budget_card = _BudgetCard()
self.budget_card.apply_btn.setIcon(icon("check"))
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
- cards_grid.addWidget(self.budget_card, 0, 5)
- # Equal stretch on every column — otherwise the grid sizes each column
- # to its widest cell's natural content (Budget's longer "$X / $Y" value
- # + entry row made its column ~25% wider than the plain stat cards).
- for col in range(6):
- cards_grid.setColumnStretch(col, 1)
+ cards_grid.addWidget(self.budget_card, 0, 3, 2, 1)
+ # The hero and Budget columns get more room than the small tiles.
+ for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)):
+ cards_grid.setColumnStretch(col, stretch)
root.addLayout(cards_grid)
# ---- token/cost within the selected period (spline): WEEK → 7 days
@@ -298,8 +312,11 @@ class DashboardTab(QWidget):
n_points = max(1, len(parts))
refs = []
if prev[mi] > 0:
+ # Muted on purpose: the comparison line is a reference, not the
+ # series — it must not compete with the accent-coloured spline.
refs.append((prev[mi] / n_points,
- f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}", "#B08968"))
+ f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}",
+ current_palette().text_muted))
self.chart.set_reference_lines(refs)
self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}"))
self._chart_period_lbl.setText(ut.period_range_label(gran, self._chart_offset))
diff --git a/ui/ext_connector_dialog.py b/ui/ext_connector_dialog.py
index 0f45eb2..2244d4a 100644
--- a/ui/ext_connector_dialog.py
+++ b/ui/ext_connector_dialog.py
@@ -16,9 +16,14 @@ from PySide6.QtWidgets import (
QLineEdit, QMessageBox, QPushButton, QStackedWidget, QVBoxLayout, QWidget,
)
-from ..core.ext_connectors import PRESETS
+from ..core.ext_connectors import CATEGORIES, PRESETS
from ..i18n import tr
+# ms365 has no user-created entries here (see ConnectorsPanel) — it auto-connects
+# via its own built-in OneDrive/SharePoint toggles, so it's left off this picker.
+_PICKABLE_CATEGORIES = tuple(c for c in CATEGORIES if c != "ms365")
+_CATEGORY_LABEL = {"cad": "CAD", "cae": "CAE", "other": "Other"}
+
class ExtConnectorEditDialog(QDialog):
def __init__(self, parent=None, category: str = "cad", connector: Optional[dict] = None):
@@ -32,10 +37,20 @@ class ExtConnectorEditDialog(QDialog):
lay = QVBoxLayout(self)
form = QFormLayout()
+ # Category picker — the single "+ Thêm connector…" button (Monitoring ▸
+ # Công cụ ▸ Connector) has no tree selection to infer this from anymore,
+ # so the dialog itself asks. Fixed once created, same as the preset.
+ self.category_combo = QComboBox()
+ for cat in _PICKABLE_CATEGORIES:
+ self.category_combo.addItem(_CATEGORY_LABEL.get(cat, cat), cat)
+ idx = self.category_combo.findData(self.category)
+ self.category_combo.setCurrentIndex(max(0, idx))
+ self.category_combo.setEnabled(not editing)
+ self.category_combo.currentIndexChanged.connect(self._on_category_changed)
+ form.addRow(tr("ext.category_label"), self.category_combo)
+
self.preset_combo = QComboBox()
- self.preset_combo.addItem(tr("ext.preset_custom"), "")
- for p in PRESETS.get(self.category, []):
- self.preset_combo.addItem(p["name"], p["id"])
+ self._reload_presets()
if editing:
self.preset_combo.setEnabled(False) # identity fixed once created
form.addRow(tr("ext.preset_label"), self.preset_combo)
@@ -105,6 +120,18 @@ class ExtConnectorEditDialog(QDialog):
buttons.rejected.connect(self.reject)
lay.addWidget(buttons)
+ def _reload_presets(self) -> None:
+ self.preset_combo.blockSignals(True)
+ self.preset_combo.clear()
+ self.preset_combo.addItem(tr("ext.preset_custom"), "")
+ for p in PRESETS.get(self.category, []):
+ self.preset_combo.addItem(p["name"], p["id"])
+ self.preset_combo.blockSignals(False)
+
+ def _on_category_changed(self) -> None:
+ self.category = self.category_combo.currentData() or self.category
+ self._reload_presets()
+
def _apply_preset(self) -> None:
preset_id = self.preset_combo.currentData()
if preset_id and not self.name_edit.text().strip():
diff --git a/ui/folder_tab.py b/ui/folder_tab.py
index c984d3a..c5aeebe 100644
--- a/ui/folder_tab.py
+++ b/ui/folder_tab.py
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
+from ..theme import current_palette
from .chat_view import ChatView
from .icons import icon
from .libreoffice_view import DOC_SUFFIXES
@@ -89,23 +90,26 @@ class PygmentsHighlighter(QSyntaxHighlighter):
from pygments.token import (
Comment, Error, Keyword, Name, Number, Operator, Punctuation, String,
)
+ p = current_palette()
# Ordered specific → general: first matching token type wins.
+ # Colours are resolved when the editor is built, so reopening a file
+ # after a theme switch re-highlights it in the new theme.
return [
- (Comment, _fmt("#6A9955", italic=True)),
- (Keyword.Type, _fmt("#4EC9B0")),
- (Keyword, _fmt("#569CD6")),
- (Name.Function, _fmt("#DCDCAA")),
- (Name.Class, _fmt("#4EC9B0")),
- (Name.Decorator, _fmt("#DCDCAA")),
- (Name.Builtin, _fmt("#4EC9B0")),
- (Name.Tag, _fmt("#569CD6")),
- (Name.Attribute, _fmt("#9CDCFE")),
- (String.Doc, _fmt("#6A9955", italic=True)),
- (String, _fmt("#CE9178")),
- (Number, _fmt("#B5CEA8")),
- (Operator, _fmt("#D4D4D4")),
- (Punctuation, _fmt("#D4D4D4")),
- (Error, _fmt("#F44747")),
+ (Comment, _fmt(p.code_comment, italic=True)),
+ (Keyword.Type, _fmt(p.code_type)),
+ (Keyword, _fmt(p.code_keyword)),
+ (Name.Function, _fmt(p.code_func)),
+ (Name.Class, _fmt(p.code_type)),
+ (Name.Decorator, _fmt(p.code_func)),
+ (Name.Builtin, _fmt(p.code_type)),
+ (Name.Tag, _fmt(p.code_keyword)),
+ (Name.Attribute, _fmt(p.code_attr)),
+ (String.Doc, _fmt(p.code_comment, italic=True)),
+ (String, _fmt(p.code_string)),
+ (Number, _fmt(p.code_number)),
+ (Operator, _fmt(p.code_fg)),
+ (Punctuation, _fmt(p.code_fg)),
+ (Error, _fmt(p.code_error)),
]
def set_filename(self, filename: str, text: str = "") -> None:
@@ -179,9 +183,7 @@ class CodeEditor(QPlainTextEdit):
font.setStyleHint(QFont.Monospace)
font.setPointSize(10)
self.setFont(font)
- self.setStyleSheet(
- "#codeEditor { background: #1e1e1e; color: #d4d4d4; border: none; "
- "selection-background-color: #264f78; }")
+ # Surface comes from the central style sheet (#codeEditor) — see theme.py.
self._gutter = _LineNumbers(self)
self.blockCountChanged.connect(lambda _=0: self._update_gutter_width())
self.updateRequest.connect(self._on_update_request)
@@ -210,13 +212,14 @@ class CodeEditor(QPlainTextEdit):
self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.line_number_width(), cr.height()))
def paint_line_numbers(self, event) -> None:
+ p = current_palette()
painter = QPainter(self._gutter)
- painter.fillRect(event.rect(), QColor("#1a1a1a"))
+ painter.fillRect(event.rect(), QColor(p.code_gutter_bg))
block = self.firstVisibleBlock()
num = block.blockNumber()
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
bottom = top + self.blockBoundingRect(block).height()
- painter.setPen(QColor("#858585"))
+ painter.setPen(QColor(p.code_gutter_fg))
while block.isValid() and top <= event.rect().bottom():
if block.isVisible() and bottom >= event.rect().top():
painter.drawText(0, int(top), self._gutter.width() - 6,
@@ -258,14 +261,20 @@ class FolderTab(QWidget):
root = QVBoxLayout(self)
+ # The path IS the title of this screen, so it is written as one rather
+ # than shown in a read-only text box that looks editable and costs a
+ # whole row of its own. Full path on hover; the button still opens the
+ # folder picker.
bar = QHBoxLayout()
- self.path_edit = QLineEdit(self._root)
- self.path_edit.setReadOnly(True)
+ self.path_lbl = QLabel(self._root)
+ self.path_lbl.setObjectName("folderTitle")
+ self.path_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ self.path_lbl.setToolTip(self._root)
self._open_btn = QPushButton()
self._open_btn.setIcon(icon("folder"))
self._open_btn.setObjectName("primary")
self._open_btn.clicked.connect(self._pick_root)
- bar.addWidget(self.path_edit, 1)
+ bar.addWidget(self.path_lbl, 1)
bar.addWidget(self._open_btn)
root.addLayout(bar)
@@ -380,7 +389,8 @@ class FolderTab(QWidget):
if not p or not os.path.isdir(p):
return
self._root = p
- self.path_edit.setText(p)
+ self.path_lbl.setText(p)
+ self.path_lbl.setToolTip(p)
self.model.setRootPath(p)
self.tree.setRootIndex(self.model.index(p))
if getattr(self, "terminal", None) is not None:
@@ -1096,7 +1106,7 @@ class FolderTab(QWidget):
if n and hasattr(self, "_ai_status"):
self._ai_status.setText("⏳ " + tr("folder.ai_status_running")
+ " · " + tr("folder.ai_queue_count", n=n))
- self._ai_status.setStyleSheet("color:#0096C7;")
+ self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
def _ai_maybe_dequeue(self) -> None:
"""When the pipeline is fully idle, start the next queued instruction."""
@@ -1308,7 +1318,7 @@ class FolderTab(QWidget):
name = target if create else getattr(self, "_ai_running_file", "")
self.status_message.emit(tr("folder.ai_proposed_status", name=name))
self._ai_status.setText("● " + hint)
- self._ai_status.setStyleSheet("color:#c77d00;")
+ self._ai_status.setStyleSheet(f"color:{current_palette().warning};")
def _ai_apply(self) -> None:
"""Confirmed by the user. If the edit GENERATES images, ask the image
@@ -1464,7 +1474,7 @@ class FolderTab(QWidget):
self.ai_send_btn.setEnabled(not busy)
if busy:
self._ai_status.setText("⏳ " + tr("folder.ai_status_running"))
- self._ai_status.setStyleSheet("color:#0096C7;")
+ self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
self.ai_btn.setText(tr("folder.ai_edit") + " ⏳") # visible even when collapsed
else:
self._ai_status.setText("")
@@ -1478,7 +1488,7 @@ class FolderTab(QWidget):
self._ai_maybe_dequeue()
return
self._ai_status.setText("✓ " + tr("folder.ai_status_done"))
- self._ai_status.setStyleSheet("color:#1f9d63;")
+ self._ai_status.setStyleSheet(f"color:{current_palette().success};")
if not self.ai_btn.isChecked() or self._ai_panel.isHidden():
self.ai_btn.setText(tr("folder.ai_edit") + " ✓")
@@ -1489,7 +1499,9 @@ class FolderTab(QWidget):
else tr("folder.preview"))
def _retranslate(self) -> None:
- self.path_edit.setPlaceholderText(tr("folder.path_placeholder"))
+ # The label always shows a real path, so the placeholder became a
+ # tooltip hint on the button that changes it.
+ self._open_btn.setToolTip(tr("folder.path_placeholder"))
self._open_btn.setText(tr("folder.open_folder"))
self.save_btn.setText(tr("folder.save"))
self.ext_btn.setText(tr("folder.open_external"))
diff --git a/ui/help_agent_widget.py b/ui/help_agent_widget.py
index e1eb11a..29c85a1 100644
--- a/ui/help_agent_widget.py
+++ b/ui/help_agent_widget.py
@@ -17,9 +17,9 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from PySide6.QtCore import QSize, Qt, Signal
-from PySide6.QtGui import QIcon, QPixmap
+from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
- QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTextBrowser,
+ QFrame, QHBoxLayout, QLabel, QLineEdit, QMenu, QPushButton, QTextBrowser,
QVBoxLayout, QWidget,
)
@@ -31,13 +31,31 @@ from .icons import icon
_ASSETS = Path(__file__).resolve().parent.parent / "assets"
_MARGIN = 18 # gap from the window's bottom-right corner
-_LAUNCHER = 64 # collapsed app-icon badge size (a clean rounded card, like image 2)
-_LAUNCHER_ICON = 52 # the icon inside it, inset so the light badge frames it
-_COLLAPSE_W, _COLLAPSE_H = 18, 44 # the "hide to the edge" chevron beside it
-_GAP = 2
-_TAB_W, _TAB_H = 16, 48 # the thin "show" tab when hidden at the edge
+# Closed, the assistant is a single 26px dot. It used to be an 84×64 block (a
+# 64px badge plus an 18px "hide" chevron beside it) sitting permanently over the
+# bottom-right of every screen — on Cowork, right on top of the Send button —
+# for something opened a few times a day. The name now appears on hover only,
+# and "hide to the edge" moved into the panel's ⋯ menu.
+# The audit page draws this at 26px ("26×26 · không chữ, không chevron").
+# Doubled at the user's request: 26 read as too small to notice on a 1920
+# screen. Still half the area of the 84×64 button it replaced.
+_DOT = 52 # closed launcher (a round chip)
+_DOT_ICON = 28 # the sparkle inside it
+_PILL_PAD = 12 # extra width for the label when hovered
+_TAB_W, _TAB_H = 28, 48 # the "show" tab when hidden at the edge (was 16 wide)
_PANEL_W, _PANEL_H = 340, 460 # expanded chat panel size
+# Straight from docs/ui-audit.html (.wf .fab / .fabpill / .spark): the
+# assistant is teal, not the app accent, and the same in both themes —
+# it is one recognisable object floating over every screen.
+_TEAL_BG, _TEAL_LINE = "#E6F6F4", "#7FD0C4"
+_TEAL_TEXT = "#0F6E62"
+# The page's CSS says .spark{color:#0F9B8A}, but the glyph is the ✨ EMOJI and a
+# colour emoji ignores CSS colour — so what the page actually renders is the
+# gold star. Sampled from the page's own render of section 27 (1055 pixels of
+# the star, averaged): #FDBE59.
+_SPARK_GOLD = "#FDBE59"
+
# The three states the floating assistant cycles through.
_HIDDEN, _LAUNCHER_ST, _PANEL = "hidden", "launcher", "panel"
@@ -53,27 +71,46 @@ def _app_icon() -> QIcon:
return QIcon(str(p)) if p.exists() else icon("robot")
-def _app_pixmap(size: int) -> QPixmap:
- """icon.png scaled to ``size`` (smooth), for the launcher badge label."""
- p = _ASSETS / "icon.png"
- if p.exists():
- return QPixmap(str(p)).scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
- return icon("robot").pixmap(size, size)
+# _app_pixmap()/_IconTap were the 52px icon and its click-through QLabel for the
+# old 64px badge. The launcher is a real button now, so both are gone.
-class _IconTap(QLabel):
- """A QLabel that behaves like a button (click → signal) — used for the
- launcher badge so it carries NO QPushButton chrome/box, just the icon on a
- clean rounded card."""
+class _HoverPill(QPushButton):
+ """The closed launcher: a dot at rest, a labelled pill under the pointer.
- clicked = Signal()
+ Keyboard focus counts as hover, so the name is reachable without a mouse.
+ Resizing is delegated to the owner because this widget is inside an overlay
+ that has to re-pin itself to the window corner whenever its size changes.
+ """
- def mousePressEvent(self, e): # noqa: N802 - Qt override
- if e.button() == Qt.LeftButton:
- self.clicked.emit()
- e.accept()
+ def __init__(self, owner):
+ super().__init__(owner)
+ self._owner = owner
+ self.open = False
+
+ def _set_open(self, value: bool) -> None:
+ if value == self.open:
return
- super().mousePressEvent(e)
+ self.open = value
+ self.setText(f" {tr('help_agent.badge')}" if value else "")
+ self._owner._layout_launcher()
+
+ def enterEvent(self, e): # noqa: N802 - Qt override
+ self._set_open(True)
+ super().enterEvent(e)
+
+ def leaveEvent(self, e): # noqa: N802 - Qt override
+ if not self.hasFocus():
+ self._set_open(False)
+ super().leaveEvent(e)
+
+ def focusInEvent(self, e): # noqa: N802 - Qt override
+ self._set_open(True)
+ super().focusInEvent(e)
+
+ def focusOutEvent(self, e): # noqa: N802 - Qt override
+ self._set_open(False)
+ super().focusOutEvent(e)
class HelpAgentWidget(QWidget):
@@ -91,9 +128,12 @@ class HelpAgentWidget(QWidget):
self._worker: Optional[AgentWorker] = None
# Conversation history (excludes the system prompt, prepended per call).
# Seeded with the greeting so the panel always opens on a friendly hello.
- self._history: List[Dict[str, str]] = [
- {"role": "assistant", "content": self._greeting()}
- ]
+ # Kept by identity so retranslate() can rewrite it without having to
+ # guess which language the visible text is in — and without touching a
+ # real reply that happens to look like a greeting.
+ self._greet_msg: Dict[str, str] = {
+ "role": "assistant", "content": self._greeting()}
+ self._history: List[Dict[str, str]] = [self._greet_msg]
self.setAttribute(Qt.WA_StyledBackground, True)
self._pal = self._compute_palette()
self._build_edge_tab()
@@ -103,67 +143,63 @@ class HelpAgentWidget(QWidget):
self._apply_state()
# ---- theming ----------------------------------------------------------
- def _compute_palette(self) -> Dict[str, str]:
- """Chat-body colours that FOLLOW the app's light/dark theme. The header
- is intentionally NOT themed here (it stays a fixed light bar — see
- _apply_style), only the conversation area adapts."""
- from ..theme import resolve_theme
- dark = resolve_theme(getattr(self.ctx.config, "theme", "system")) == "dark"
- if dark:
- return {
- "panel_bg": "#16202b", "text": "#e3ebf5", "log_bg": "#0f1720",
- "input_bg": "#1b2733", "border": "#33404d",
- "user_bg": "#123a52", "user_label": "#58c0ee",
- "bot_bg": "#232f3b", "bot_label": "#6fe3a4",
- }
- return {
- "panel_bg": "#ffffff", "text": "#14212b", "log_bg": "#f7f9fb",
- "input_bg": "#ffffff", "border": "#d5d9de",
- "user_bg": "#dceff8", "user_label": "#0077B6",
- "bot_bg": "#eef1f4", "bot_label": "#2f7d55",
- }
+ def _compute_palette(self):
+ """The app's design tokens for the theme in effect. The whole dock —
+ header included — follows the app theme; a header locked to a light
+ strip stranded a bright bar in the middle of the dark UI."""
+ from ..theme import palette
+ return palette(getattr(self.ctx.config, "theme", "system"))
def apply_theme(self) -> None:
"""Re-style + re-render when the app theme switches (called from
- MainWindow._apply_theme). Header stays fixed; chat body re-colours."""
+ MainWindow._apply_theme). The whole dock re-colours, icons included —
+ icons are painted bitmaps, so they must be rebuilt, not restyled."""
self._pal = self._compute_palette()
self._apply_style()
+ muted = self._pal.text_muted
+ self.edge_tab.setIcon(icon("chevron-left", color=_TEAL_TEXT))
+ self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_SPARK_GOLD))
+ self.min_btn.setIcon(icon("minus", color=muted))
self._render()
def _apply_style(self) -> None:
- # The HEADER bar is a FIXED light strip in both themes (per request); only
- # the chat body below follows the app's light/dark palette (self._pal).
- from ..theme import ACCENT, ACCENT2, GRADIENT
+ """The dock owns its own style sheet (it floats above the window, so the
+ app-wide sheet does not reach it cleanly) but draws every value from the
+ shared tokens — see theme.py."""
p = self._pal
+ r, rl = p.radius, p.radius_lg
self.setStyleSheet(f"""
- /* Clean rounded app-icon badge (like image 2): a fixed light card
- framing the icon — no QPushButton box. */
- #helpLauncher {{ background: #e8f2fb; border: 1px solid #d3e3f2;
- border-radius: 16px; }}
- #helpLauncher:hover {{ background: #dcedfb; }}
- #helpCollapseBtn, #helpEdgeTab {{ background: rgba(0,0,0,0.06); border: none;
- border-radius: 6px; }}
- #helpCollapseBtn:hover, #helpEdgeTab:hover {{ background: rgba(0,0,0,0.14); }}
- #helpPanel {{ background: {p['panel_bg']}; border: 1px solid {p['border']};
- border-radius: 14px; color: {p['text']}; }}
- /* Faint-blue header bar — LOCKED light, dark title, in both themes.
- The header AND its child labels set fixed backgrounds so the dark
- theme never bleeds into the App-Assistant title strip. */
- #helpHeader {{ background: #e8f2fb; border-bottom: 1px solid #d9e6f2;
- border-top-left-radius: 14px; border-top-right-radius: 14px; }}
- #helpHeader QLabel {{ background: transparent; color: #14212b; }}
- #helpTitle {{ color: #14212b; font-weight: 700; font-size: 13px; background: transparent; }}
- #helpMinBtn {{ background: transparent; border: none; }}
- #helpMinBtn:hover {{ background: rgba(0,0,0,0.10); border-radius: 6px; }}
- #helpLog {{ background: {p['log_bg']}; border: none; color: {p['text']}; padding: 4px 6px; }}
- #helpInputRow {{ background: {p['panel_bg']}; border-bottom-left-radius: 14px;
- border-bottom-right-radius: 14px; }}
- #helpInput {{ border: 1px solid {p['border']}; border-radius: 8px; padding: 5px 8px;
- background: {p['input_bg']}; color: {p['text']}; }}
- #helpInput:focus {{ border: 1px solid {ACCENT}; }}
- #helpSendBtn {{ background: {GRADIENT}; border: none; border-radius: 8px; }}
- #helpSendBtn:hover {{ background: {ACCENT2}; }}
- #helpSendBtn:disabled {{ background: #b7c0c9; }}
+ /* Closed launcher: a {_DOT}px dot. `pill` flips to true on hover, when
+ the label comes out and the shape stretches to a rounded bar. */
+ #helpLauncher {{ background: {_TEAL_BG}; border: 1px solid {_TEAL_LINE};
+ border-radius: {_DOT // 2}px; color: {_TEAL_TEXT}; font-weight: 700;
+ font-size: 12px; padding: 0; text-align: center; }}
+ #helpLauncher[pill="true"] {{ text-align: left; padding-left: 6px; }}
+ #helpLauncher:hover {{ background: #D5EFEA; border-color: {_TEAL_LINE}; }}
+ #helpLauncher:focus {{ border: 1px solid {_TEAL_TEXT}; }}
+ #helpEdgeTab {{ background: {_TEAL_BG}; border: 1px solid {_TEAL_LINE};
+ border-right: none; border-top-left-radius: {r}px;
+ border-bottom-left-radius: {r}px; }}
+ #helpEdgeTab:hover {{ background: #D5EFEA; }}
+ #helpPanel {{ background: {p.surface}; border: 1px solid {p.border};
+ border-radius: {rl}px; color: {p.text}; }}
+ #helpHeader {{ background: {p.surface}; border-bottom: 1px solid {p.border};
+ border-top-left-radius: {rl}px; border-top-right-radius: {rl}px; }}
+ #helpHeader QLabel {{ background: transparent; color: {p.text}; }}
+ #helpTitle {{ color: {p.text}; font-weight: 600; font-size: 13px;
+ background: transparent; }}
+ #helpMinBtn {{ background: transparent; border: none; border-radius: {r}px; }}
+ #helpMinBtn:hover {{ background: {p.hover}; }}
+ #helpLog {{ background: {p.sunken}; border: none; color: {p.text};
+ padding: 4px 6px; }}
+ #helpInputRow {{ background: {p.surface};
+ border-bottom-left-radius: {rl}px; border-bottom-right-radius: {rl}px; }}
+ #helpInput {{ border: 1px solid {p.border}; border-radius: {r}px; padding: 5px 8px;
+ background: {p.surface_raised}; color: {p.text}; }}
+ #helpInput:focus {{ border: 1px solid {p.focus_ring}; }}
+ #helpSendBtn {{ background: {p.accent_solid}; border: none; border-radius: {r}px; }}
+ #helpSendBtn:hover {{ background: {p.accent_solid_hover}; }}
+ #helpSendBtn:disabled {{ background: {p.border_strong}; }}
""")
# ---- greeting / labels ------------------------------------------------
@@ -177,28 +213,21 @@ class HelpAgentWidget(QWidget):
# assistant back (chevron points left = "slide out").
self.edge_tab = QPushButton(self)
self.edge_tab.setObjectName("helpEdgeTab")
- self.edge_tab.setIcon(icon("chevron-left", color="#5a6570"))
+ self.edge_tab.setIcon(icon("chevron-left", color=_TEAL_TEXT))
self.edge_tab.setCursor(Qt.PointingHandCursor)
self.edge_tab.setToolTip(tr("help_agent.show_tooltip"))
self.edge_tab.clicked.connect(self._show_launcher)
def _build_launcher(self) -> None:
- # A left-side chevron collapses the assistant to the edge…
- self.collapse_btn = QPushButton(self)
- self.collapse_btn.setObjectName("helpCollapseBtn")
- self.collapse_btn.setIcon(icon("chevron-right", color="#5a6570"))
- self.collapse_btn.setCursor(Qt.PointingHandCursor)
- self.collapse_btn.setToolTip(tr("help_agent.hide_tooltip"))
- self.collapse_btn.clicked.connect(self._hide_to_edge)
- # …and the app icon itself opens the chat — a clean rounded badge (like
- # image 2), NOT a QPushButton (which added a pale box around the icon).
- self.launcher = _IconTap(self)
+ # One control, one job: this opens the chat. The chevron that used to sit
+ # beside it (a second 18px hit target for a second meaning of "closed")
+ # is gone — hiding to the edge is now a line in the panel's ⋯ menu.
+ self.launcher = _HoverPill(self)
self.launcher.setObjectName("helpLauncher")
- self.launcher.setFixedSize(_LAUNCHER, _LAUNCHER)
- self.launcher.setAlignment(Qt.AlignCenter)
- self.launcher.setPixmap(_app_pixmap(_LAUNCHER_ICON))
+ self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_SPARK_GOLD))
self.launcher.setCursor(Qt.PointingHandCursor)
- self.launcher.setToolTip(tr("help_agent.open_tooltip"))
+ self.launcher.setToolTip(
+ f'{tr("help_agent.open_tooltip")} · {tr("help_agent.dot_hint")}')
self.launcher.clicked.connect(self._expand)
def _build_panel(self) -> None:
@@ -215,19 +244,31 @@ class HelpAgentWidget(QWidget):
hb = QHBoxLayout(header)
hb.setContentsMargins(12, 8, 8, 8)
self.title_icon = QLabel(header)
- self.title_icon.setPixmap(_app_icon().pixmap(20, 20))
+ self.title_icon.setPixmap(
+ icon("sparkle", size=16, color=_SPARK_GOLD).pixmap(16, 16))
hb.addWidget(self.title_icon)
self.title = QLabel(tr("help_agent.title"), header)
self.title.setObjectName("helpTitle")
hb.addWidget(self.title, 1)
self.min_btn = QPushButton(header)
self.min_btn.setObjectName("helpMinBtn")
- self.min_btn.setIcon(icon("minus", color="#5a6570"))
+ self.min_btn.setIcon(icon("minus", color=self._pal.text_muted))
self.min_btn.setFixedSize(24, 24)
self.min_btn.setCursor(Qt.PointingHandCursor)
self.min_btn.setToolTip(tr("help_agent.collapse_tooltip"))
self.min_btn.clicked.connect(self._collapse)
hb.addWidget(self.min_btn)
+ # No ⋯ menu. The audit page put "Ẩn trợ lý" in one, but its two entries
+ # were "thu nhỏ" — which the − button beside it already does — and
+ # "ẩn vào cạnh phải". A drop-list to reach one action that duplicates
+ # its neighbour is chrome; removed at the user's request.
+ #
+ # Hiding stays reachable by right-click, on the header while the panel
+ # is open and on the dot while it is shut, so no route is lost.
+ for target in (header, self.launcher):
+ target.setContextMenuPolicy(Qt.CustomContextMenu)
+ target.customContextMenuRequested.connect(
+ lambda pos, w=target: self._hide_menu(w, pos))
v.addWidget(header)
# Conversation log
@@ -268,6 +309,19 @@ class HelpAgentWidget(QWidget):
self._state = _LAUNCHER_ST
self._apply_state()
+ def _hide_menu(self, widget, pos) -> None:
+ """Right-click, on the dot or the open panel's header: hide to the edge.
+
+ The only action worth offering here — collapsing is what the − button
+ and the dot itself already are.
+ """
+ from PySide6.QtWidgets import QMenu
+
+ menu = QMenu(widget)
+ act = menu.addAction(tr("help_agent.hide_tooltip"))
+ act.triggered.connect(self._hide_to_edge)
+ menu.exec(widget.mapToGlobal(pos))
+
def _hide_to_edge(self) -> None:
self._state = _HIDDEN
self._apply_state()
@@ -276,36 +330,57 @@ class HelpAgentWidget(QWidget):
self._state = _LAUNCHER_ST
self._apply_state()
+ def _layout_launcher(self) -> None:
+ """Size the overlay to the dot, or to the pill while it is hovered."""
+ w = _DOT
+ if self.launcher.open:
+ w = max(_DOT, self.launcher.fontMetrics()
+ .horizontalAdvance(self.launcher.text()) + _DOT + _PILL_PAD)
+ self.resize(w, _DOT)
+ self.launcher.setGeometry(0, 0, w, _DOT)
+ # Round while it is a dot, pill-shaped once the label is out.
+ self.launcher.setProperty("pill", bool(self.launcher.open))
+ self.launcher.style().unpolish(self.launcher)
+ self.launcher.style().polish(self.launcher)
+ self.reposition()
+ self.raise_()
+
def _apply_state(self) -> None:
st = self._state
self.edge_tab.setVisible(st == _HIDDEN)
- self.collapse_btn.setVisible(st == _LAUNCHER_ST)
self.launcher.setVisible(st == _LAUNCHER_ST)
self.panel.setVisible(st == _PANEL)
if st == _PANEL:
self.resize(_PANEL_W, _PANEL_H)
self.panel.setGeometry(0, 0, _PANEL_W, _PANEL_H)
elif st == _LAUNCHER_ST:
- w = _LAUNCHER + _GAP + _COLLAPSE_W
- self.resize(w, _LAUNCHER)
- # Icon on the left, the collapse chevron on the RIGHT (toward the
- # screen edge it tucks into).
- self.launcher.setGeometry(0, 0, _LAUNCHER, _LAUNCHER)
- self.collapse_btn.setGeometry(_LAUNCHER + _GAP, (_LAUNCHER - _COLLAPSE_H) // 2,
- _COLLAPSE_W, _COLLAPSE_H)
+ self._layout_launcher()
+ return # _layout_launcher repositions and raises
else: # hidden
self.resize(_TAB_W, _TAB_H)
self.edge_tab.setGeometry(0, 0, _TAB_W, _TAB_H)
self.reposition()
self.raise_()
+ # A screen whose bottom edge is an input row (Cowork's composer) must not
+ # have the dock sitting on top of it — set by MainWindow when the page
+ # changes, in window coordinates.
+ _bottom_guard = 0
+
+ def set_bottom_guard(self, height: int) -> None:
+ """Reserve `height` px at the foot of the window for the page's own
+ controls; the dock floats above it instead of over the Send button."""
+ if height != self._bottom_guard:
+ self._bottom_guard = max(0, height)
+ self.reposition()
+
def reposition(self) -> None:
"""Pin to the parent's bottom-right corner (called on parent resize)."""
p = self.parentWidget()
if p is None:
return
x = max(0, p.width() - self.width() - _MARGIN)
- y = max(0, p.height() - self.height() - _MARGIN)
+ y = max(0, p.height() - self.height() - _MARGIN - self._bottom_guard)
self.move(x, y)
# ---- rendering --------------------------------------------------------
@@ -318,17 +393,19 @@ class HelpAgentWidget(QWidget):
p = self._pal
text = (content or "").replace("&", "&").replace("<", "<").replace(">", ">")
text = text.replace("\n", "
")
+ # bgcolor= is a solid-only HTML attribute, hence accent_wash (pre-blended)
+ # rather than the translucent accent_soft used in style sheets.
if who == "user":
- align, bg, label_color = "right", p["user_bg"], p["user_label"]
+ align, bg, label_color = "right", p.accent_wash, p.accent
label = tr("chat.you")
else:
- align, bg, label_color = "left", p["bot_bg"], p["bot_label"]
+ align, bg, label_color = "left", p.surface_raised, p.success
label = tr("help_agent.title")
return (
f'
'
f''
f''
- f'| '
+ f' | '
f'{label} {text}'
f' |
|
'
'
' # gap between turns
@@ -388,9 +465,18 @@ class HelpAgentWidget(QWidget):
self.send_btn.setEnabled(not busy)
def retranslate(self) -> None:
+ # The transcript is rendered HTML, so switching language left the
+ # greeting — and every "AI Assistant" speaker label — in the language
+ # the panel was built in.
+ if self._history and self._history[0] is self._greet_msg:
+ self._greet_msg["content"] = self._greeting()
+ self._render()
self.title.setText(tr("help_agent.title"))
self.input.setPlaceholderText(tr("help_agent.placeholder"))
- self.launcher.setToolTip(tr("help_agent.open_tooltip"))
+ self.launcher.setToolTip(
+ f'{tr("help_agent.open_tooltip")} · {tr("help_agent.dot_hint")}')
+ if self.launcher.open:
+ self.launcher.setText(f" {tr('help_agent.badge')}")
+ self._layout_launcher()
self.min_btn.setToolTip(tr("help_agent.collapse_tooltip"))
- self.collapse_btn.setToolTip(tr("help_agent.hide_tooltip"))
self.edge_tab.setToolTip(tr("help_agent.show_tooltip"))
diff --git a/ui/icons.py b/ui/icons.py
index 1307367..a91160c 100644
--- a/ui/icons.py
+++ b/ui/icons.py
@@ -21,7 +21,12 @@ from PySide6.QtGui import QBrush, QColor, QIcon, QPainter, QPen, QPixmap
from PySide6.QtSvg import QSvgRenderer
from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QWidget
-_COLOR = "#8b8d98" # neutral grey, visible on both light and dark buttons
+def _default_color() -> str:
+ """The default icon tint: the theme's muted text colour, so glyphs sit at
+ the same weight as the labels beside them. Resolved per call — icons are
+ painted bitmaps, so a theme switch must repaint them, not restyle them."""
+ from ..theme import current_palette
+ return current_palette().text_muted
def _hidpi_pixmap(size: int) -> QPixmap:
@@ -230,10 +235,11 @@ def icon_picker_combo(current: str = "") -> QComboBox:
return combo
-def icon(name: str, size: int = 16, color: str = _COLOR) -> QIcon:
+def icon(name: str, size: int = 16, color: str | None = None) -> QIcon:
"""A flat thin-line icon for ``name`` (see ``_PATHS`` for the full list),
tinted ``color`` — rendered from local SVG data, no image files/network.
Stroke width 1.7 matches the Nova Platform web app's shared icon set."""
+ color = color or _default_color()
# A user-added custom icon (full SVG under ~/.cowork_local/icons) is rendered
# as-is (keeps its own colours). Then built-in glyphs; then a neutral fallback.
if name not in _PATHS:
@@ -264,9 +270,10 @@ def icon(name: str, size: int = 16, color: str = _COLOR) -> QIcon:
return QIcon(pm)
-def _panel_icon(fill_left: bool, size: int = 16, color: str = _COLOR) -> QIcon:
+def _panel_icon(fill_left: bool, size: int = 16, color: str | None = None) -> QIcon:
"""A rounded panel split by a divider, with one narrow side filled solid
(the 'sidebar' toggle look)."""
+ color = color or _default_color()
pm = _hidpi_pixmap(size)
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
@@ -302,19 +309,24 @@ def collapse_right_icon() -> QIcon:
return _panel_icon(fill_left=False)
-def pixmap(name: str, size: int = 16, color: str = _COLOR) -> QPixmap:
+def pixmap(name: str, size: int = 16, color: str | None = None) -> QPixmap:
"""The line-icon ``name`` as a QPixmap (for QLabel.setPixmap — QLabel has no
setIcon). Same glyph/renderer as ``icon()``."""
return icon(name, size, color).pixmap(size, size)
-# Status-LED colors — a filled dot, the one place a solid glyph (not a line
+# Status-LED colours — a filled dot, the one place a solid glyph (not a line
# icon) is the right metaphor for an on/off/running indicator.
-DOT_GREEN = "#22c55e"
-DOT_RED = "#ef4444"
-DOT_AMBER = "#f59e0b"
-DOT_BLUE = "#3b82f6"
-DOT_GREY = "#9ca3af"
+#
+# Deliberately the SAME in light and dark. An LED means one thing regardless of
+# theme, and these mid-saturation hues clear 3:1 against both #0B0B0C and
+# #FFFFFF, so a status dot never has to be re-learned. Everything else in the
+# UI goes through theme.palette(); this is the documented exception.
+DOT_GREEN = "#2EA043"
+DOT_RED = "#E5484D"
+DOT_AMBER = "#B7791F"
+DOT_BLUE = "#4C7BE8"
+DOT_GREY = "#8B8B94"
def dot_icon(color: str = DOT_GREY, size: int = 12) -> QIcon:
@@ -338,7 +350,7 @@ class IconLabel(QWidget):
status labels (lock/unlock, …) keep working."""
def __init__(self, name: str, text: str = "", *, size: int = 16,
- color: str = _COLOR, gap: int = 6, parent=None):
+ color: str | None = None, gap: int = 6, parent=None):
super().__init__(parent)
self._size = size
lay = QHBoxLayout(self)
@@ -357,7 +369,7 @@ class IconLabel(QWidget):
def setText(self, text: str) -> None: # noqa: N802 — QLabel-compatible alias
self._text.setText(text)
- def set_icon(self, name: str, color: str = _COLOR) -> None:
+ def set_icon(self, name: str, color: str | None = None) -> None:
self._icon.setPixmap(pixmap(name, self._size, color))
def text_label(self) -> QLabel:
diff --git a/ui/icons_admin_tab.py b/ui/icons_admin_tab.py
index 6ba27d2..6e43a2d 100644
--- a/ui/icons_admin_tab.py
+++ b/ui/icons_admin_tab.py
@@ -22,6 +22,7 @@ from .icons import icon
def _grid() -> QListWidget:
g = QListWidget()
+ g.setObjectName("iconGrid") # accent border on hover/selection, see theme.py
g.setViewMode(QListWidget.IconMode)
g.setResizeMode(QListWidget.Adjust)
g.setMovement(QListWidget.Static)
@@ -36,34 +37,44 @@ class IconsAdminTab(QWidget):
super().__init__()
self.ctx = ctx
root = QVBoxLayout(self)
- self._hint = QLabel(); self._hint.setObjectName("hint"); self._hint.setWordWrap(True)
- root.addWidget(self._hint)
-
- # search over built-in names
- self.search = QLineEdit()
- self.search.textChanged.connect(self._reload_builtin)
- root.addWidget(self.search)
-
- self._builtin_lbl = QLabel()
- root.addWidget(self._builtin_lbl)
- self.builtin_grid = _grid()
- root.addWidget(self.builtin_grid, 2)
-
- self._custom_lbl = QLabel()
- root.addWidget(self._custom_lbl)
- self.custom_grid = _grid()
- root.addWidget(self.custom_grid, 1)
-
- btns = QHBoxLayout()
+ # Header row: the three actions sit beside the title, where the drawing
+ # puts them, instead of in a strip below the two grids where they read
+ # as belonging to the custom grid alone.
+ head = QHBoxLayout()
+ self._title = QLabel()
+ self._title.setObjectName("monTitle")
+ head.addWidget(self._title)
+ head.addStretch(1)
self.add_btn = QPushButton(); self.add_btn.setIcon(icon("plus"))
self.add_btn.clicked.connect(self._add_icon)
self.paste_btn = QPushButton()
self.paste_btn.clicked.connect(self._add_from_svg_text)
self.del_btn = QPushButton(); self.del_btn.setIcon(icon("trash"))
self.del_btn.clicked.connect(self._delete_icon)
- btns.addWidget(self.add_btn); btns.addWidget(self.paste_btn)
- btns.addWidget(self.del_btn); btns.addStretch(1)
- root.addLayout(btns)
+ for b in (self.add_btn, self.paste_btn, self.del_btn):
+ head.addWidget(b)
+ root.addLayout(head)
+
+ self._hint = QLabel(); self._hint.setObjectName("hint"); self._hint.setWordWrap(True)
+ root.addWidget(self._hint)
+
+ # search over built-in names, with the magnifier the drawing asks for
+ self.search = QLineEdit()
+ self.search.addAction(icon("search"), QLineEdit.LeadingPosition)
+ self.search.textChanged.connect(self._reload_builtin)
+ root.addWidget(self.search)
+
+ self._builtin_lbl = QLabel()
+ self._builtin_lbl.setObjectName("navSectionHdr") # quiet caps heading
+ root.addWidget(self._builtin_lbl)
+ self.builtin_grid = _grid()
+ root.addWidget(self.builtin_grid, 2)
+
+ self._custom_lbl = QLabel()
+ self._custom_lbl.setObjectName("navSectionHdr")
+ root.addWidget(self._custom_lbl)
+ self.custom_grid = _grid()
+ root.addWidget(self.custom_grid, 1)
on_language_changed(self._retranslate)
self._retranslate()
@@ -131,10 +142,13 @@ class IconsAdminTab(QWidget):
self._reload_custom()
def _retranslate(self) -> None:
+ self._title.setText(tr("monitoring.tab_icons"))
self._hint.setText(tr("icons_admin.hint"))
self.search.setPlaceholderText(tr("icons_admin.search"))
- self._builtin_lbl.setText(tr("icons_admin.builtin"))
- self._custom_lbl.setText(tr("icons_admin.custom"))
+ # ICON TÍCH HỢP / ICON TÙY CHỈNH — caps, like every other section
+ # heading the audit page draws.
+ self._builtin_lbl.setText(tr("icons_admin.builtin").upper())
+ self._custom_lbl.setText(tr("icons_admin.custom").upper())
self.add_btn.setText(tr("icons_admin.add"))
self.paste_btn.setText(tr("icons_admin.paste"))
self.del_btn.setText(tr("icons_admin.delete"))
diff --git a/ui/monitoring_tab.py b/ui/monitoring_tab.py
index 699ce12..ddc30c7 100644
--- a/ui/monitoring_tab.py
+++ b/ui/monitoring_tab.py
@@ -24,20 +24,24 @@ import time
from datetime import datetime
from typing import List
-from PySide6.QtCore import Qt, QTimer, Signal
-from PySide6.QtGui import QBrush, QColor
+from PySide6.QtCore import Qt, QEvent, QObject, QRect, QSize, QTimer, Signal
+from PySide6.QtGui import (
+ QBrush, QColor, QFont, QGuiApplication, QIcon, QKeySequence, QPainter,
+ QPixmap, QShortcut,
+)
from PySide6.QtWidgets import (
- QComboBox, QGridLayout, QGroupBox, QHBoxLayout,
+ QApplication, QComboBox, QGridLayout, QGroupBox, QHBoxLayout,
QHeaderView, QLabel, QLineEdit, QProgressBar, QPushButton, QScrollArea,
- QTableWidget, QTableWidgetItem, QTabWidget, QVBoxLayout, QWidget,
+ QSplitter, QTableWidget, QTableWidgetItem, QTabWidget, QVBoxLayout, QWidget,
)
from ..core import agent_roles, audit_log
from ..core import usage_tracker as ut
from ..i18n import on_language_changed, tr
from ..state import AppContext
+from ..theme import current_palette
from .icons import icon, DOT_GREEN, DOT_RED, DOT_AMBER
-from .widgets import BudgetCard, StatCard, fmt_tokens
+from .widgets import BudgetCard, StatCard, badge_pill_widget, fmt_tokens
_REFRESH_MS = 3000
_MAX_ROWS = 300
@@ -51,6 +55,59 @@ def _fmt_bytes(n: float) -> str:
return f"{n:.1f} TB"
+def _fmt_event_time(ts: str) -> str:
+ """"dd/MM hh:mm" for the Time column — e.g. 25/05 15:03."""
+ try:
+ dt = datetime.fromisoformat(ts)
+ except (TypeError, ValueError):
+ return ts
+ return dt.strftime("%d/%m %H:%M")
+
+
+def _agent_initials(name: str) -> str:
+ """First letter of each word, max 2 — ``ini()`` in ui-audit_v2.html."""
+ return "".join(w[0] for w in name.split() if w)[:2].upper()
+
+
+def _agent_avatar_colour(name: str) -> str:
+ """Same mapping as ``ac()`` in ui-audit_v2.html — a fixed identity colour
+ per agent kind, unchanged by theme (like the mockup's badge colours)."""
+ if "Security" in name:
+ return "#D13438"
+ if "Cowork" in name:
+ return "#0078D4"
+ if name == "schedule" or "Task" in name:
+ return "#FFB900"
+ if name == "graphrag" or "Knowledge" in name:
+ return "#8764B8"
+ if "Code" in name:
+ return "#107C10"
+ if "Planner" in name or "Reasoning" in name:
+ return "#8A8886"
+ return "#0078D4"
+
+
+def _agent_avatar_icon(name: str, size: int = 20) -> QIcon:
+ """A small round initials badge for the Agent column — 1:1 with the
+ ``.wf .av`` avatar in ui-audit_v2.html (colour-coded circle + up to
+ 2-letter initials, drawn to the left of the agent's name)."""
+ pm = QPixmap(size, size)
+ pm.fill(Qt.transparent)
+ p = QPainter(pm)
+ p.setRenderHint(QPainter.Antialiasing)
+ p.setPen(Qt.NoPen)
+ p.setBrush(QColor(_agent_avatar_colour(name)))
+ p.drawEllipse(0, 0, size, size)
+ font = QFont()
+ font.setPixelSize(max(7, size // 2))
+ font.setBold(True)
+ p.setFont(font)
+ p.setPen(QColor("#FFFFFF"))
+ p.drawText(pm.rect(), Qt.AlignCenter, _agent_initials(name))
+ p.end()
+ return QIcon(pm)
+
+
def _relative_time(ts: str) -> str:
"""A short "Xm ago"-style string for an audit-log ``ts`` (naive local
ISO timestamp, see ``audit_log.record``); "" if unparsable."""
@@ -68,29 +125,73 @@ def _relative_time(ts: str) -> str:
return tr("monitoring.time_days_ago", n=int(delta // 86400))
+class _TimeItem(QTableWidgetItem):
+ """The Time column shows "dd/MM hh:mm", which does not sort correctly as
+ text (day-of-month leads, not year/month) — so sorting compares the raw
+ ISO ``ts`` each item is built from instead of its displayed text."""
+
+ def __init__(self, raw_ts: str, display: str):
+ super().__init__(display)
+ self._raw_ts = raw_ts
+
+ def __lt__(self, other):
+ if isinstance(other, _TimeItem):
+ return self._raw_ts < other._raw_ts
+ return super().__lt__(other)
+
+
class _EventTable(QTableWidget):
"""A read-only table of audit-log events — newest-first by default, and
every column header is click-to-sort (ascending/descending toggle; the
- Time column's ISO timestamps sort correctly as text)."""
+ Time column sorts by the underlying ISO timestamp, not its "dd/MM hh:mm"
+ display text — see :class:`_TimeItem`)."""
- def __init__(self):
- super().__init__(0, 7)
+ # What each blocked action is, as a colour. Security events all record
+ # ok=False, so the tick/cross column said the same thing on every row; the
+ # useful distinction is WHICH rule fired.
+ _ACTION_TINTS = {
+ "prompt": "accent",
+ "dangerous_command": "danger",
+ "run_command": "danger",
+ "install_package": "warning",
+ "path_outside_sandbox": "success",
+ "network_blocked": "accent",
+ "secret_in_output": "warning",
+ }
+
+ def __init__(self, show_result: bool = True):
+ # Security Events drops the result column entirely (see _ACTION_TINTS).
+ self._show_result = show_result
+ super().__init__(0, 7 if show_result else 6)
self.setEditTriggers(QTableWidget.NoEditTriggers)
self.setSelectionBehavior(QTableWidget.SelectRows)
+ self.setIconSize(QSize(20, 20)) # ~1.25x the mockup's 16x16 avatar badge
self.verticalHeader().setVisible(False)
+ # Fixed row height — letting Qt auto-size rows from content fought with
+ # the Hành động column's cell widget (its layout would settle on a
+ # stale, oversized geometry from an intermediate sizing pass, clipping
+ # the pill's text). A fixed height sidesteps that entirely.
+ self.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
+ self.verticalHeader().setDefaultSectionSize(32)
self.setSortingEnabled(True)
header = self.horizontalHeader()
header.setStretchLastSection(True)
- for col in range(6):
+ for col in range(self.columnCount() - 1):
header.setSectionResizeMode(col, QHeaderView.ResizeToContents)
def retranslate(self) -> None:
- self.setHorizontalHeaderLabels([
- tr("monitoring.col_time"), tr("monitoring.col_role"),
- tr("monitoring.col_account"), tr("monitoring.col_machine"),
- tr("monitoring.col_name"), tr("monitoring.col_result"),
- tr("monitoring.col_detail"),
- ])
+ # Security Events (show_result=False) is the ui-audit_v2.html
+ # wireframe's table 1:1 — "Agent" and "Chi tiết chặn", not the
+ # longer generic wording MCP/Action Logs share.
+ cols = [tr("monitoring.col_time"),
+ tr("monitoring.col_agent") if not self._show_result else tr("monitoring.col_role"),
+ tr("monitoring.col_account"), tr("monitoring.col_machine")]
+ if self._show_result:
+ cols += [tr("monitoring.col_name"), tr("monitoring.col_result")]
+ else:
+ cols += [tr("monitoring.col_action")]
+ cols += [tr("monitoring.col_detail_block") if not self._show_result else tr("monitoring.col_detail")]
+ self.setHorizontalHeaderLabels(cols)
def set_events(self, events: List[dict]) -> None:
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:_MAX_ROWS]
@@ -101,14 +202,47 @@ class _EventTable(QTableWidget):
cells = [
ev.get("ts", ""), agent_roles.label_for(ev.get("agent_role", "")),
ev.get("account", "") or "—", ev.get("machine", "") or "—",
- ev.get("name", ""), "",
- (ev.get("detail") or "")[:300],
+ ev.get("name", ""),
]
+ if self._show_result:
+ cells.append("")
+ cells.append((ev.get("detail") or "")[:300])
+ pal = current_palette()
for col, text in enumerate(cells):
- item = QTableWidgetItem(str(text))
- if col == 5: # Result — green check / red close icon (no emoji)
+ item = (_TimeItem(str(text), _fmt_event_time(str(text))) if col == 0
+ else QTableWidgetItem(str(text)))
+ if col == 0:
+ # Stash the full event (untruncated detail included) on the
+ # Time cell, so a click-to-open detail panel survives the
+ # user re-sorting the table by any column.
+ item.setData(Qt.UserRole, ev)
+ if col == 1:
+ # Agent — colour-coded initials avatar (see ui-audit_v2.html).
+ item.setIcon(_agent_avatar_icon(str(text)))
+ if self._show_result and col == 5:
+ # Result — green check / red close icon (no emoji)
item.setIcon(icon("check", color=DOT_GREEN) if ev.get("ok")
else icon("close", color=DOT_RED))
+ if not self._show_result and col == 4:
+ # Human-readable label ("Path ngoài sandbox"), not the raw
+ # audit_log name ("path_outside_sandbox") — same wording as
+ # the detail panel's Loại field (_action_label). Tinted by
+ # which rule fired, via the ITEM's own colours — NOT a
+ # setCellWidget() pill: a cell widget is pinned to a (row,
+ # column) screen position, not to the item that travels
+ # with a sort, so the table's OWN re-sort (every refresh()
+ # re-applies the active sort indicator, and a user click on
+ # any column header does too) left a stale pill floating
+ # over whatever row ended up at that position instead —
+ # the "wrong colour/text peeking out" glitch.
+ tint = getattr(pal, self._ACTION_TINTS.get(
+ ev.get("name", ""), "text_muted"), pal.text_muted)
+ item.setText(_action_label(str(text)))
+ colour = QColor(tint)
+ item.setForeground(QBrush(colour))
+ soft = QColor(colour)
+ soft.setAlpha(38)
+ item.setBackground(QBrush(soft))
if is_admin_violation:
item.setBackground(QBrush(QColor(229, 72, 77, 60)))
self.setItem(row, col, item)
@@ -127,6 +261,322 @@ class _EventTable(QTableWidget):
for col in range(self.columnCount()))
self.setRowHidden(row, not match)
+ def event_at_row(self, row: int) -> dict | None:
+ item = self.item(row, 0)
+ return item.data(Qt.UserRole) if item else None
+
+
+def _fmt_event_time_full(ts: str) -> str:
+ """"dd/MM/yyyy · HH:mm:ss" — the detail panel's Thời gian field, per
+ ``fmtFull()`` in ui-audit_v2.html (the table's own Time column uses the
+ shorter ``_fmt_event_time`` instead)."""
+ try:
+ dt = datetime.fromisoformat(ts)
+ except (TypeError, ValueError):
+ return ts
+ return dt.strftime("%d/%m/%Y · %H:%M:%S")
+
+
+def _event_id(ts: str, row: int) -> str:
+ """A display-only id in the ``evt_
_`` shape the
+ mockup uses — the real audit log has no native event id, so this is
+ derived from the timestamp and the row's position in the currently
+ displayed (sorted) table, not persisted anywhere."""
+ digits = "".join(ch for ch in ts if ch.isdigit())[:12]
+ return f"evt_{digits}_{row:03d}"
+
+
+# Human label for the raw ``name`` an audit event is recorded under — the
+# "Loại" field in the detail panel. Anything not in this map (custom tool
+# names, etc.) just shows its raw name, same as the table's Hành động column.
+_ACTION_LABEL_KEYS = {
+ "prompt": "monitoring.action_prompt",
+ "dangerous_command": "monitoring.action_dangerous_command",
+ "run_command": "monitoring.action_dangerous_command",
+ "install_package": "monitoring.action_install_package",
+ "path_outside_sandbox": "monitoring.action_path_outside_sandbox",
+ "network_blocked": "monitoring.action_network_blocked",
+ "secret_in_output": "monitoring.action_secret_in_output",
+}
+
+
+def _action_label(name: str) -> str:
+ key = _ACTION_LABEL_KEYS.get(name)
+ return tr(key) if key else name
+
+
+# (badge QSS object name, i18n key) for the "Trạng thái" pill — statusInfo()
+# in ui-audit_v2.html, mapped onto the app's existing badge* tones (theme.py)
+# rather than adding the mockup's one-off teal/orange hues.
+_STATUS_INFO = {
+ "path_outside_sandbox": ("badgeSuccess", "monitoring.status_path"),
+ "network_blocked": ("badge", "monitoring.status_network"),
+ "secret_in_output": ("badgeWarn", "monitoring.status_secret"),
+}
+_STATUS_DEFAULT = ("badgePurple", "monitoring.status_blocked")
+
+
+def _status_info(name: str, kind: str = "security_block", ok: bool = False) -> tuple[str, str]:
+ if name in _STATUS_INFO:
+ return _STATUS_INFO[name]
+ if kind == "security_block":
+ # Security Events rows are always ok=False (see _EventTable's
+ # _ACTION_TINTS comment) — an unmapped name here still means "blocked
+ # by some rule", never a plain failure.
+ return _STATUS_DEFAULT
+ # MCP calls / generic Action Logs rows: no fixed enforcement-rule
+ # vocabulary applies, so fall back to the event's own ok/fail outcome.
+ return ("badgeSuccess", "monitoring.status_ok") if ok else ("badgeDanger", "monitoring.status_failed")
+
+
+def _severity_info(name: str, kind: str = "security_block", ok: bool = False) -> tuple[str, str]:
+ # Same two-tier read as ui-audit_v2.html's mock: an unapproved shell
+ # command is the one CRITICAL case; everything else blocked is MEDIUM.
+ if name in ("dangerous_command", "run_command"):
+ return "badgeDanger", "monitoring.severity_critical"
+ if kind == "security_block":
+ return "badgeWarn", "monitoring.severity_medium"
+ # A successful MCP call / action is routine (INFO); a failed one still
+ # deserves the same MEDIUM tone Security Events uses for a blocked rule.
+ return ("badge", "monitoring.severity_info") if ok else ("badgeWarn", "monitoring.severity_medium")
+
+
+def _agent_badge_name(name: str) -> str:
+ """Badge tone for the Agent field's pill — the same identity-colour
+ mapping ``_agent_avatar_colour``/``ac()`` (ui-audit_v2.html) uses,
+ expressed as one of the shared badge* QSS classes (theme.py) instead of a
+ literal hex, since this pill lives on a themed label, not a custom swatch."""
+ if "Security" in name:
+ return "badgeDanger"
+ if "Cowork" in name:
+ return "badge"
+ if name == "schedule":
+ return "badgeWarn"
+ if name == "graphrag":
+ return "badgePurple"
+ if "Code" in name:
+ return "badgeSuccess"
+ return "badge"
+
+
+_STATIC_POLICY_LABEL = "security_policy_v2" # cosmetic label only — no real policy-versioning system exists yet
+
+
+class _EventDetailPanel(QWidget):
+ """Right-hand "Chi tiết sự kiện" panel — the full record behind whichever
+ row is selected in an :class:`_EventTable`, laid out to match the
+ "Đề xuất" detail panel in ui-audit_v2.html (openDetail()): three labelled
+ sections, a terminal-style block quote for the detail text, and a
+ METADATA footer, closed by the header ✕, the footer button, Esc, or a
+ click outside the table/panel (see :class:`_ClickOutsideCloser`)."""
+
+ closed = Signal()
+
+ def __init__(self):
+ super().__init__()
+ self.setObjectName("monSection")
+ self._detail_text = ""
+ outer = QVBoxLayout(self)
+
+ hdr = QHBoxLayout()
+ self._title_lbl = QLabel()
+ self._title_lbl.setStyleSheet("font-weight:700;")
+ hdr.addWidget(self._title_lbl, 1)
+ self._close_btn = QPushButton()
+ self._close_btn.setIcon(icon("close"))
+ self._close_btn.setFlat(True)
+ self._close_btn.setFixedWidth(28)
+ self._close_btn.setCursor(Qt.PointingHandCursor)
+ self._close_btn.clicked.connect(self.closed.emit)
+ hdr.addWidget(self._close_btn)
+ outer.addLayout(hdr)
+
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QScrollArea.NoFrame)
+ body = QWidget()
+ self._body_lay = QVBoxLayout(body)
+ self._body_lay.setContentsMargins(0, 0, 4, 0)
+ scroll.setWidget(body)
+ outer.addWidget(scroll, 1)
+
+ self._section_hdrs: list[tuple[str, QLabel]] = []
+ self._rows: dict[str, tuple[QLabel, QLabel]] = {}
+
+ def _section(key: str) -> None:
+ lbl = QLabel()
+ lbl.setObjectName("detailSectionHdr")
+ self._body_lay.addWidget(lbl)
+ self._section_hdrs.append((key, lbl))
+
+ def _field(key: str) -> QLabel:
+ r = QHBoxLayout()
+ lbl = QLabel()
+ lbl.setObjectName("hint")
+ val = QLabel()
+ r.addWidget(lbl)
+ r.addStretch(1)
+ r.addWidget(val)
+ self._body_lay.addLayout(r)
+ self._rows[key] = (lbl, val)
+ return val
+
+ _section("general")
+ _field("time")
+ self._agent_val = _field("agent")
+ _field("account")
+ self._machine_val = _field("machine")
+ self._machine_val.setObjectName("monoChip")
+
+ _section("action")
+ self._type_val = _field("type")
+ self._type_val.setObjectName("neutralTag")
+ self._status_val = _field("status")
+
+ _section("block")
+ code_box = QWidget()
+ code_box.setObjectName("detailCodeBlock")
+ code_lay = QHBoxLayout(code_box)
+ code_lay.setContentsMargins(8, 6, 8, 6)
+ self._code_text = QLabel()
+ self._code_text.setObjectName("detailCodeText")
+ self._code_text.setWordWrap(True)
+ self._code_text.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ code_lay.addWidget(self._code_text, 1)
+ self._copy_btn = QPushButton()
+ self._copy_btn.setObjectName("detailCopyBtn")
+ self._copy_btn.setCursor(Qt.PointingHandCursor)
+ self._copy_btn.clicked.connect(self._copy_detail)
+ code_lay.addWidget(self._copy_btn, 0, Qt.AlignVCenter)
+ self._body_lay.addWidget(code_box)
+
+ _section("metadata")
+ self._event_id_val = _field("event_id")
+ self._event_id_val.setObjectName("monoChip")
+ self._policy_val = _field("policy")
+ self._severity_val = _field("severity")
+
+ self._body_lay.addStretch(1)
+
+ footer = QHBoxLayout()
+ footer.setContentsMargins(0, 6, 0, 0)
+ self._footer_close_btn = QPushButton()
+ self._footer_close_btn.setObjectName("primary")
+ self._footer_close_btn.setCursor(Qt.PointingHandCursor)
+ self._footer_close_btn.clicked.connect(self.closed.emit)
+ footer.addWidget(self._footer_close_btn, 1)
+ outer.addLayout(footer)
+
+ @staticmethod
+ def _apply_badge(label: QLabel, object_name: str) -> None:
+ label.setObjectName(object_name)
+ label.style().unpolish(label)
+ label.style().polish(label)
+
+ def retranslate(self) -> None:
+ self._title_lbl.setText(tr("monitoring.security_detail_title"))
+ self._close_btn.setToolTip(tr("monitoring.security_detail_close"))
+ self._footer_close_btn.setText(tr("monitoring.security_detail_close"))
+ self._footer_close_btn.setIcon(icon("close"))
+ section_keys = {
+ "general": "monitoring.detail_section_general",
+ "action": "monitoring.detail_section_action",
+ "block": "monitoring.col_detail_block",
+ "metadata": "monitoring.detail_section_metadata",
+ }
+ for key, lbl in self._section_hdrs:
+ lbl.setText(tr(section_keys[key]).upper())
+ self._rows["time"][0].setText(tr("monitoring.col_time"))
+ self._rows["agent"][0].setText(tr("monitoring.col_agent"))
+ self._rows["account"][0].setText(tr("monitoring.col_account"))
+ self._rows["machine"][0].setText(tr("monitoring.col_machine"))
+ self._rows["type"][0].setText(tr("monitoring.detail_type"))
+ self._rows["status"][0].setText(tr("monitoring.detail_status"))
+ self._rows["event_id"][0].setText(tr("monitoring.detail_event_id"))
+ self._rows["policy"][0].setText(tr("monitoring.detail_policy"))
+ self._rows["severity"][0].setText(tr("monitoring.detail_severity"))
+ if not self._copy_btn.text() or self._copy_btn.text() != tr("monitoring.detail_copied"):
+ self._reset_copy_btn()
+
+ def show_event(self, ev: dict, row: int) -> None:
+ na = "—"
+ self._rows["time"][1].setText(_fmt_event_time_full(ev.get("ts", "")) or na)
+
+ agent_label = agent_roles.label_for(ev.get("agent_role", "")) or na
+ self._agent_val.setText(agent_label)
+ self._apply_badge(self._agent_val,
+ _agent_badge_name(agent_label) if agent_label != na else "badge")
+
+ self._rows["account"][1].setText(ev.get("account", "") or na)
+ self._machine_val.setText(ev.get("machine", "") or na)
+
+ name = ev.get("name", "")
+ kind = ev.get("kind", "security_block")
+ ok = ev.get("ok", False)
+ self._type_val.setText(_action_label(name) or na)
+ status_badge, status_key = _status_info(name, kind, ok)
+ self._status_val.setText(tr(status_key))
+ self._apply_badge(self._status_val, status_badge)
+
+ self._detail_text = ev.get("detail", "") or na
+ self._code_text.setText(self._detail_text)
+ self._reset_copy_btn()
+
+ self._event_id_val.setText(_event_id(ev.get("ts", ""), row))
+ # The static policy label names a real enforcement ruleset — only
+ # meaningful for a Security Events row; MCP calls/generic actions
+ # were never evaluated against it.
+ self._policy_val.setText(_STATIC_POLICY_LABEL if kind == "security_block" else na)
+ severity_badge, severity_key = _severity_info(name, kind, ok)
+ self._severity_val.setText(tr(severity_key))
+ self._apply_badge(self._severity_val, severity_badge)
+
+ def _copy_detail(self) -> None:
+ QGuiApplication.clipboard().setText(self._detail_text)
+ self._copy_btn.setText(tr("monitoring.detail_copied"))
+ self._copy_btn.setIcon(icon("check"))
+ QTimer.singleShot(1500, self._reset_copy_btn)
+
+ def _reset_copy_btn(self) -> None:
+ self._copy_btn.setText(tr("monitoring.detail_copy"))
+ self._copy_btn.setIcon(icon("document"))
+
+
+class _ClickOutsideCloser(QObject):
+ """Closes the event-detail panel on a click anywhere outside the
+ table/panel splitter — a row click changes the selection instead (its
+ own handler), so this only needs to catch everything else: the search
+ box, another tab, the nav rail… mirrors the document-level "click
+ outside the panel" listener in ui-audit_v2.html.
+
+ Judged by screen-space GEOMETRY (is the click's global position inside
+ the splitter's on-screen rectangle), not by which exact widget object
+ received the event. Two earlier attempts both broke on the splitter's
+ drag handle: ``QApplication.widgetAt(globalPos)`` re-hit-tests through
+ the window server rather than using what Qt actually delivered the event
+ to, and even the delivered ``obj`` isn't reliable mid-drag — the handle
+ grabs the mouse and Qt's internal drag bookkeeping doesn't always hand
+ back the same widget identity a plain ``isAncestorOf`` check expects.
+ A geometric rect containment check has neither problem: it doesn't care
+ which sub-widget (viewport, cell widget, scrollbar, handle) the event
+ was actually delivered to, only whether the click landed on-screen
+ within the container's bounds."""
+
+ def __init__(self, table: "_EventTable", panel: "_EventDetailPanel", container: QWidget):
+ super().__init__(container)
+ self._table = table
+ self._panel = panel
+ self._container = container
+
+ def eventFilter(self, obj, event) -> bool:
+ if event.type() == QEvent.MouseButtonPress and self._panel.isVisible():
+ global_pos = event.globalPosition().toPoint()
+ top_left = self._container.mapToGlobal(self._container.rect().topLeft())
+ rect = QRect(top_left, self._container.size())
+ if not rect.contains(global_pos):
+ self._table.clearSelection()
+ return False
+
class MonitoringTab(QWidget):
status_message = Signal(str)
@@ -145,10 +595,6 @@ class MonitoringTab(QWidget):
self._title.setStyleSheet("font-weight:700; font-size:15px;")
head.addWidget(self._title)
head.addStretch(1)
- self.refresh_btn = QPushButton()
- self.refresh_btn.setIcon(icon("refresh"))
- self.refresh_btn.clicked.connect(self.refresh)
- head.addWidget(self.refresh_btn)
root.addLayout(head)
self.tabs = QTabWidget()
@@ -158,25 +604,41 @@ class MonitoringTab(QWidget):
self.tabs.addTab(self._build_overview_page(), "")
visible = self._tab_visible
- self.security_table = _EventTable()
- self.security_page = self._wrap_with_filter(self.security_table)
+ # Security events are always ok=False, so this table trades the
+ # tick/cross column for a tinted Action column (see _EventTable).
+ self.security_table = _EventTable(show_result=False)
+ self.security_page = self._wrap_with_filter(
+ self.security_table, with_detail=True, title_key="monitoring.security_events_title")
if visible("security_events"):
self.tabs.addTab(self.security_page, "")
self.mcp_table = _EventTable()
+ self.mcp_page = self._wrap_with_filter(
+ self.mcp_table, with_detail=True, title_key="monitoring.mcp_history_title")
if visible("mcp_history"):
- self.tabs.addTab(self.mcp_table, "")
+ self.tabs.addTab(self.mcp_page, "")
self.action_table = _EventTable()
- self.action_page = self._wrap_with_filter(self.action_table)
+ self.action_page = self._wrap_with_filter(
+ self.action_table, with_detail=True, title_key="monitoring.action_logs_title")
if visible("action_logs"):
self.tabs.addTab(self.action_page, "")
# ---- Agent Status -------------------------------------------------
self.status_table = QTableWidget(0, 3)
self.status_table.setEditTriggers(QTableWidget.NoEditTriggers)
+ # No detail to open on click, no filtering — a plain read-only table,
+ # so selection is off rather than left dangling with no effect (and it
+ # sidesteps the Trạng thái badge's dark-theme rgba() background ever
+ # compositing differently selected vs not — see badge_pill_widget).
+ self.status_table.setSelectionMode(QTableWidget.NoSelection)
self.status_table.verticalHeader().setVisible(False)
self.status_table.horizontalHeader().setStretchLastSection(True)
+ self.status_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
+ self.status_table.setColumnWidth(1, 130) # Trạng thái is a cell widget — size it explicitly
+ self.status_table.setIconSize(QSize(20, 20))
+ self.status_page = self._wrap_with_filter(
+ self.status_table, title_key="monitoring.agent_status_title", with_search=False)
if visible("agent_status"):
- self.tabs.addTab(self.status_table, "")
+ self.tabs.addTab(self.status_page, "")
# ---- Agents Admin (catalog: assign a role + pinned model per agent) --
# The system-management agents (Security, GraphRAG/Knowledge, Monitor…)
@@ -203,8 +665,14 @@ class MonitoringTab(QWidget):
self._timer = QTimer(self)
# (nav integration methods defined below)
+ # Only the Overview cards auto-refresh on this tick — Security, MCP,
+ # Action Logs, Agent Status (and the Agents Admin/Tools/Icons tabs,
+ # which were never wired to this timer) are read-only tables that a
+ # background re-sort would otherwise disturb mid-interaction (e.g.
+ # while a row is selected or the detail-panel splitter is being
+ # dragged); the user refreshes them explicitly via a "Làm mới" button.
self._timer.setInterval(_REFRESH_MS)
- self._timer.timeout.connect(self.refresh)
+ self._timer.timeout.connect(self._auto_refresh)
self._timer.start()
on_language_changed(self._retranslate)
@@ -217,8 +685,8 @@ class MonitoringTab(QWidget):
they're correct in every language."""
by_widget = {self.agents_admin_tab: "robot", self.tools_admin_tab: "wrench",
self.icons_admin_tab: "star"}
- for attr, name in (("security_page", "shield"), ("mcp_table", "plug"),
- ("action_page", "bolt"), ("status_table", "monitor")):
+ for attr, name in (("security_page", "shield"), ("mcp_page", "plug"),
+ ("action_page", "bolt"), ("status_page", "monitor")):
w = getattr(self, attr, None)
if w is not None:
by_widget[w] = name
@@ -338,26 +806,94 @@ class MonitoringTab(QWidget):
self.ctx.save()
self._reload_pricing_table()
- def _wrap_with_filter(self, table: "_EventTable") -> QWidget:
+ def _wrap_with_filter(self, table: QTableWidget, with_detail: bool = False,
+ title_key: str | None = None, with_search: bool = True) -> QWidget:
page = QWidget()
lay = QVBoxLayout(page)
lay.setContentsMargins(0, 0, 0, 0)
- row = QHBoxLayout()
- search = QLineEdit()
- search.setPlaceholderText(tr("monitoring.filter_placeholder"))
- 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"))
- ai_btn.clicked.connect(lambda: self._ai_filter(search, ai_btn))
- row.addWidget(search, 1)
- row.addWidget(ai_btn)
- lay.addLayout(row)
- lay.addWidget(table, 1)
- page.filter_edit = search
- page.ai_filter_btn = ai_btn
+
+ if title_key:
+ # The wireframe titles this tab's content on its own row — "Sự kiện
+ # bảo mật" + a primary Refresh button — separately from the section
+ # tab strip above (which just says "Bảo mật").
+ hdr = QHBoxLayout()
+ title_lbl = QLabel(tr(title_key))
+ title_lbl.setStyleSheet("font-weight:700; font-size:14px;")
+ hdr.addWidget(title_lbl)
+ hdr.addStretch(1)
+ refresh_btn = QPushButton(tr("monitoring.refresh"))
+ refresh_btn.setIcon(icon("refresh"))
+ refresh_btn.setObjectName("primary")
+ refresh_btn.setCursor(Qt.PointingHandCursor)
+ refresh_btn.clicked.connect(self.refresh)
+ hdr.addWidget(refresh_btn)
+ lay.addLayout(hdr)
+ page.title_lbl = title_lbl
+ page.title_key = title_key
+ page.title_refresh_btn = refresh_btn
+
+ if with_search:
+ row = QHBoxLayout()
+ search = QLineEdit()
+ search.setPlaceholderText(tr("monitoring.filter_placeholder"))
+ 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"))
+ ai_btn.setCursor(Qt.PointingHandCursor)
+ ai_btn.clicked.connect(lambda: self._ai_filter(search, ai_btn))
+ row.addWidget(search, 1)
+ row.addWidget(ai_btn)
+ lay.addLayout(row)
+ page.filter_edit = search
+ page.ai_filter_btn = ai_btn
+
+ if with_detail:
+ # Click a row → its full record opens in a "Chi tiết sự kiện" panel
+ # on the right (the table itself clips the detail text to 300 chars).
+ # A pointing-hand cursor over the rows signals that they're clickable.
+ table.setCursor(Qt.PointingHandCursor)
+ detail = _EventDetailPanel()
+ detail.setVisible(False)
+ detail.closed.connect(table.clearSelection)
+ table.itemSelectionChanged.connect(lambda: self._sync_event_detail(table, detail))
+ split = QSplitter(Qt.Horizontal)
+ split.addWidget(table)
+ split.addWidget(detail)
+ split.setStretchFactor(0, 1)
+ split.setStretchFactor(1, 0)
+ split.setChildrenCollapsible(False)
+ split.setSizes([700, 320])
+ lay.addWidget(split, 1)
+ page.detail_panel = detail
+
+ # Esc, anywhere focus is inside this page, closes the panel the
+ # same way the close button does.
+ esc = QShortcut(QKeySequence(Qt.Key_Escape), page)
+ esc.setContext(Qt.WidgetWithChildrenShortcut)
+ esc.activated.connect(table.clearSelection)
+ page.detail_esc_shortcut = esc
+
+ # A click outside both the table and the panel also closes it —
+ # mirrors ui-audit_v2.html's document-level click-outside listener.
+ click_filter = _ClickOutsideCloser(table, detail, split)
+ QApplication.instance().installEventFilter(click_filter)
+ page.detail_click_filter = click_filter
+ else:
+ lay.addWidget(table, 1)
return page
+ def _sync_event_detail(self, table: "_EventTable", panel: "_EventDetailPanel") -> None:
+ # currentRow() alone is not enough: clearSelection() (used by the
+ # panel's close button) drops the selection but leaves the current
+ # cell in place, so a stale currentRow() would keep the panel open.
+ row = table.currentRow()
+ has_selection = bool(table.selectedItems())
+ ev = table.event_at_row(row) if (has_selection and row >= 0) else None
+ if ev:
+ panel.show_event(ev, row)
+ panel.setVisible(bool(ev))
+
def _ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
query = search.text().strip()
if not query or getattr(self, "_ai_filter_worker", None) is not None:
@@ -403,17 +939,20 @@ class MonitoringTab(QWidget):
scroll.setWidget(content)
outer.addWidget(scroll)
- root = QHBoxLayout(content)
+ # ONE main column, scrolled vertically, sections in a fixed order — the
+ # two-column grid put six group boxes side by side and mixed three
+ # unrelated concerns (cost, machine resources, security) at the same
+ # level, which made this the densest screen in the app. Each section now
+ # spans the full width and lays its own contents out horizontally, so a
+ # wide window is still used well.
+ root = QVBoxLayout(content)
root.setSpacing(12)
- left = QVBoxLayout()
- left.setSpacing(12)
- right = QVBoxLayout()
- right.setSpacing(12)
- root.addLayout(left, 2)
- root.addLayout(right, 1)
+ left = root # sections are appended in reading order
+ right = root
# ---- Token Usage & Cost --------------------------------------------
self.ov_usage_group = QGroupBox()
+ self.ov_usage_group.setObjectName("monSection")
usage_lay = QGridLayout(self.ov_usage_group)
usage_lay.setSpacing(8)
self.ov_usage_total = StatCard()
@@ -421,19 +960,27 @@ class MonitoringTab(QWidget):
self.ov_usage_out = StatCard()
self.ov_usage_cache = StatCard()
self.ov_usage_cost = StatCard()
- for i, card in enumerate((self.ov_usage_total, self.ov_usage_in, self.ov_usage_out,
- self.ov_usage_cache, self.ov_usage_cost)):
+ self.ov_usage_calls = StatCard()
+ # The wireframe's usage block is five figures: cost (with the turn
+ # count on its label), then Tổng token / Input / Output / Cache. Folding
+ # the last three into a sub-line took their PER-PART COST off screen —
+ # the total tile has room for the token counts but not for three more
+ # prices — and the drawing asks for the tiles anyway.
+ for i, card in enumerate((self.ov_usage_cost, self.ov_usage_total,
+ self.ov_usage_in, self.ov_usage_out,
+ self.ov_usage_cache)):
usage_lay.addWidget(card, 0, i)
+ self.ov_usage_calls.setVisible(False) # rides on the cost tile's label
# Budget: remaining/budget, direct entry, auto-warns red past 85% used —
# same box (and same usage.budget_* config) as the Dashboard's.
self.ov_budget_card = BudgetCard()
self.ov_budget_card.apply_btn.setIcon(icon("check"))
self.ov_budget_card.apply_btn.clicked.connect(self._apply_budget)
- usage_lay.addWidget(self.ov_budget_card, 0, 5, 2, 1)
+ usage_lay.addWidget(self.ov_budget_card, 0, 3)
# Equal stretch on every column — otherwise the grid sizes each column
# to its widest cell's natural content (Budget's longer "$X / $Y" value
# + entry row makes its column wider than the plain stat cards).
- for col in range(6):
+ for col in range(4):
usage_lay.setColumnStretch(col, 1)
# Unit prices are NOT entered here anymore — the cost total is computed
@@ -444,41 +991,60 @@ class MonitoringTab(QWidget):
# ---- Recent Activity ----------------------------------------------
self.ov_activity_group = QGroupBox()
+ self.ov_activity_group.setObjectName("monSection")
act_lay = QVBoxLayout(self.ov_activity_group)
self.ov_activity_lbl = QLabel()
self.ov_activity_lbl.setWordWrap(True)
self.ov_activity_lbl.setTextFormat(Qt.RichText)
act_lay.addWidget(self.ov_activity_lbl)
- left.addWidget(self.ov_activity_group)
+ # added near the bottom, beside the audit log — see below
# ---- Resource Usage -------------------------------------------------
self.ov_resource_group = QGroupBox()
- res_lay = QVBoxLayout(self.ov_resource_group)
+ self.ov_resource_group.setObjectName("monSection")
+ # ONE compact line, as the wireframe writes it: name and value sit
+ # together and the pairs are separated by a middle dot, packed left —
+ # spread across the full width they read as four unrelated columns with
+ # the value stranded at the far edge of a 1900px screen.
+ res_lay = QHBoxLayout(self.ov_resource_group)
+ res_lay.setSpacing(6)
+ self._res_first = True
+
+ def _pair():
+ if not self._res_first:
+ sep = QLabel("·")
+ sep.setObjectName("hint")
+ res_lay.addWidget(sep)
+ self._res_first = False
+ lbl = QLabel(); lbl.setObjectName("hint")
+ val = QLabel()
+ res_lay.addWidget(lbl)
+ res_lay.addWidget(val)
+ return lbl, val
def _bar_row():
- row = QHBoxLayout()
- lbl = QLabel(); lbl.setFixedWidth(70)
- bar = QProgressBar(); bar.setFixedHeight(8); bar.setTextVisible(False)
- val = QLabel(); val.setFixedWidth(90); val.setAlignment(Qt.AlignRight)
- row.addWidget(lbl); row.addWidget(bar, 1); row.addWidget(val)
- res_lay.addLayout(row)
+ # The gauge is kept and updated, but off the line: at a glance the
+ # number is what is read, and the bar was drawing a 700px rule.
+ lbl, val = _pair()
+ bar = QProgressBar()
+ bar.setVisible(False)
return lbl, bar, val
def _text_row():
- row = QHBoxLayout()
- lbl = QLabel(); val = QLabel(); val.setAlignment(Qt.AlignRight)
- row.addWidget(lbl); row.addStretch(1); row.addWidget(val)
- res_lay.addLayout(row)
- return lbl, val
+ return _pair()
self.ov_cpu_lbl, self.ov_cpu_bar, self.ov_cpu_val = _bar_row()
self.ov_mem_lbl, self.ov_mem_bar, self.ov_mem_val = _bar_row()
- self.ov_disk_lbl, self.ov_disk_val = _text_row()
- self.ov_network_lbl, self.ov_network_val = _text_row()
+ self.ov_diskfree_lbl, self.ov_diskfree_val = _text_row()
+ # I/O and network rates keep working; they are read from the detail
+ # fold rather than the summary line the wireframe draws.
+ self.ov_disk_lbl, self.ov_disk_val = QLabel(), QLabel()
+ self.ov_network_lbl, self.ov_network_val = QLabel(), QLabel()
res_lay.addStretch(1)
# ---- Model pricing (beside the CPU/resource group) ------------------
self.ov_pricing_group = QGroupBox()
+ self.ov_pricing_group.setObjectName("monSection")
pg = QVBoxLayout(self.ov_pricing_group)
phdr = QHBoxLayout()
self.ov_pricing_ccy_lbl = QLabel(); self.ov_pricing_ccy_lbl.setObjectName("hint")
@@ -513,17 +1079,32 @@ class MonitoringTab(QWidget):
self.ov_pricing_table.setSelectionBehavior(QTableWidget.SelectRows)
pg.addWidget(self.ov_pricing_table, 1)
- res_row = QHBoxLayout()
- res_row.setSpacing(12)
- res_row.addWidget(self.ov_resource_group, 1)
- res_row.addWidget(self.ov_pricing_group, 2) # pricing sits beside CPU/resources
- left.addLayout(res_row)
- left.addStretch(1)
+ # Resources keep the row to themselves; the model price table gets its
+ # own full-width section further down (it is a reference table, not a
+ # live meter, and squeezing it next to the CPU bars made both unreadable).
+ left.addWidget(self.ov_resource_group)
self._reload_pricing_table()
# ---- Sandbox Details --------------------------------------------
self.ov_sandbox_details_group = QGroupBox()
+ self.ov_sandbox_details_group.setObjectName("monSection")
sbx_lay = QVBoxLayout(self.ov_sandbox_details_group)
+ self.ov_sbx_summary = QLabel()
+ self.ov_sbx_summary.setWordWrap(True)
+ sbx_lay.addWidget(self.ov_sbx_summary)
+ self.ov_sbx_more_btn = QPushButton()
+ self.ov_sbx_more_btn.setObjectName("co4eSectionAction")
+ self.ov_sbx_more_btn.setFlat(True)
+ self.ov_sbx_more_btn.setCheckable(True)
+ self.ov_sbx_more_btn.setCursor(Qt.PointingHandCursor)
+ sbx_lay.addWidget(self.ov_sbx_more_btn, 0, Qt.AlignLeft)
+ self._sbx_detail = QWidget()
+ self._sbx_detail.setVisible(False)
+ self.ov_sbx_more_btn.toggled.connect(self._sbx_detail.setVisible)
+ self.ov_sbx_more_btn.toggled.connect(self._sync_sbx_more_label)
+ sbx_lay.addWidget(self._sbx_detail)
+ sbx_lay = QVBoxLayout(self._sbx_detail)
+ sbx_lay.setContentsMargins(0, 4, 0, 0)
def _kv():
row = QHBoxLayout()
@@ -551,10 +1132,13 @@ class MonitoringTab(QWidget):
sbx_lay.addLayout(limits_row)
self.ov_sbx_net_lbl, self.ov_sbx_net_val = _kv()
- right.addWidget(self.ov_sandbox_details_group)
+ # Sandbox and Permissions answer the same question ("what is the agent
+ # allowed to touch?"), so they share one full-width row.
+ root.addWidget(self.ov_sandbox_details_group)
# ---- Permissions -----------------------------------------------
self.ov_permissions_group = QGroupBox()
+ self.ov_permissions_group.setObjectName("monSection")
perm_lay = QVBoxLayout(self.ov_permissions_group)
def _pkv():
@@ -572,11 +1156,19 @@ class MonitoringTab(QWidget):
self.ov_perm_edit_btn = QPushButton()
self.ov_perm_edit_btn.setFlat(True)
self.ov_perm_edit_btn.clicked.connect(self._open_settings_and_refresh)
- perm_lay.addWidget(self.ov_perm_edit_btn, 0, Qt.AlignRight)
- right.addWidget(self.ov_permissions_group)
+ perm_lay.addWidget(self.ov_perm_edit_btn, 0, Qt.AlignLeft)
+ # Inside the same fold as the sandbox rows — one section, one line.
+ self._sbx_detail.layout().addWidget(self.ov_permissions_group)
+
+ # ---- Model pricing — its own section, full width ------------------
+ root.addWidget(self.ov_pricing_group)
+
+ # ---- What actually happened, last ---------------------------------
+ root.addWidget(self.ov_activity_group)
# ---- Audit Log ----------------------------------------------------
self.ov_audit_group = QGroupBox()
+ self.ov_audit_group.setObjectName("monSection")
audit_lay = QVBoxLayout(self.ov_audit_group)
self.ov_audit_lbl = QLabel()
self.ov_audit_lbl.setWordWrap(True)
@@ -616,13 +1208,12 @@ class MonitoringTab(QWidget):
# ---- i18n ------------------------------------------------------------
def _retranslate(self) -> None:
self._title.setText(tr("monitoring.title"))
- self.refresh_btn.setText(tr("monitoring.refresh"))
if self.tabs.count():
self.tabs.setTabText(0, tr("monitoring.tab_overview"))
self._set_tab_text_if_present(self.security_page, tr("monitoring.tab_security"))
- self._set_tab_text_if_present(self.mcp_table, tr("monitoring.tab_mcp"))
+ self._set_tab_text_if_present(self.mcp_page, tr("monitoring.tab_mcp"))
self._set_tab_text_if_present(self.action_page, tr("monitoring.tab_actions"))
- self._set_tab_text_if_present(self.status_table, tr("monitoring.tab_agents"))
+ self._set_tab_text_if_present(self.status_page, tr("monitoring.tab_agents"))
self._set_tab_text_if_present(self.agents_admin_tab, tr("monitoring.tab_agents_admin"))
self._set_tab_text_if_present(self.tools_admin_tab, tr("monitoring.tab_tools"))
self._set_tab_text_if_present(self.icons_admin_tab, tr("monitoring.tab_icons"))
@@ -630,23 +1221,34 @@ class MonitoringTab(QWidget):
self.mcp_table.retranslate()
self.action_table.retranslate()
self.status_table.setHorizontalHeaderLabels([
- tr("monitoring.col_agent"), tr("monitoring.col_active"), tr("monitoring.col_source"),
+ tr("monitoring.col_agent"), tr("monitoring.detail_status"), tr("monitoring.col_source"),
])
- self.ov_usage_group.setTitle(tr("monitoring.overview_usage_title"))
+ # A QGroupBox title treats "&" as a mnemonic marker, so "token & Chi
+ # phí" rendered as "token _Chi phí". Double it to show a literal "&".
+ self.ov_usage_group.setTitle(
+ tr("monitoring.overview_usage_title").upper().replace("&", "&&"))
self.ov_budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
self.ov_budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
- self.ov_activity_group.setTitle(tr("monitoring.overview_activity_title"))
- self.ov_resource_group.setTitle(tr("monitoring.overview_resource_title"))
- self.security_page.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
- self.action_page.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
+ self.ov_activity_group.setTitle(tr("monitoring.overview_activity_title").upper())
+ self.ov_resource_group.setTitle(tr("monitoring.overview_resource_title").upper())
+ for page in (self.security_page, self.mcp_page, self.action_page):
+ page.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
+ page.detail_panel.retranslate()
+ page.title_lbl.setText(tr(page.title_key))
+ page.title_refresh_btn.setText(tr("monitoring.refresh"))
+ # status_page has no search box and no detail panel (with_search=False,
+ # with_detail=False) — just the title + Làm mới header.
+ self.status_page.title_lbl.setText(tr(self.status_page.title_key))
+ self.status_page.title_refresh_btn.setText(tr("monitoring.refresh"))
self.ov_cpu_lbl.setText(tr("monitoring.overview_res_cpu"))
self.ov_mem_lbl.setText(tr("monitoring.overview_res_mem"))
+ self.ov_diskfree_lbl.setText(tr("monitoring.overview_disk_label"))
self.ov_disk_lbl.setText(tr("monitoring.overview_res_disk"))
self.ov_network_lbl.setText(tr("monitoring.overview_res_network"))
# model pricing panel
- self.ov_pricing_group.setTitle(tr("monitoring.pricing_title"))
+ self.ov_pricing_group.setTitle(tr("monitoring.pricing_title").upper())
self.ov_pricing_ccy_lbl.setText(tr("monitoring.pricing_currency"))
self.ov_price_import_btn.setText(tr("monitoring.pricing_import"))
self.ov_price_export_btn.setText(tr("monitoring.pricing_export"))
@@ -658,7 +1260,9 @@ class MonitoringTab(QWidget):
tr("monitoring.pricing_col_maxout"), tr("monitoring.pricing_col_input"),
tr("monitoring.pricing_col_output")])
- self.ov_sandbox_details_group.setTitle(tr("monitoring.overview_sandbox_details_title"))
+ self.ov_sandbox_details_group.setTitle(
+ # "&" is a mnemonic marker in a group-box title — double it.
+ tr("monitoring.overview_sandbox_details_title").upper().replace("&", "&&"))
self.ov_sbx_id_lbl.setText(tr("monitoring.overview_sandbox_id"))
self.ov_sbx_status_lbl.setText(tr("monitoring.overview_status"))
self.ov_sbx_created_lbl.setText(tr("monitoring.overview_created"))
@@ -666,7 +1270,7 @@ class MonitoringTab(QWidget):
self.ov_sbx_edit_btn.setText(tr("monitoring.overview_edit"))
self.ov_sbx_net_lbl.setText(tr("monitoring.overview_network_label"))
- self.ov_permissions_group.setTitle(tr("monitoring.overview_permissions_title"))
+ self.ov_permissions_group.setTitle(tr("monitoring.overview_permissions_title").upper())
self.ov_perm_fs_lbl.setText(tr("monitoring.overview_perm_fs"))
self.ov_perm_fs_val.setText(tr("monitoring.overview_perm_fs_value"))
self.ov_perm_network_lbl.setText(tr("monitoring.overview_perm_network"))
@@ -676,13 +1280,17 @@ class MonitoringTab(QWidget):
self.ov_perm_env_val.setText(tr("monitoring.overview_perm_env_value"))
self.ov_perm_edit_btn.setText(tr("monitoring.overview_edit"))
- self.ov_audit_group.setTitle(tr("monitoring.overview_audit_title"))
+ self.ov_audit_group.setTitle(tr("monitoring.overview_audit_title").upper())
self.ov_view_all_btn.setText(tr("monitoring.overview_view_all"))
self.refresh()
# ---- refresh -----------------------------------------------------------
def refresh(self) -> None:
+ """Full refresh — Overview cards plus every table. Wired to the
+ top-of-page and per-section "Làm mới" buttons, called once at
+ startup/language-change, but NOT to the auto-refresh timer (see
+ ``_auto_refresh``)."""
self._refresh_resource_usage()
events = self._load_events()
self.security_table.set_events([e for e in events if e.get("kind") == "security_block"])
@@ -691,6 +1299,11 @@ class MonitoringTab(QWidget):
self._refresh_agent_status()
self._refresh_overview(events)
+ def _auto_refresh(self) -> None:
+ """3-second timer tick — Overview cards only (see ``refresh``)."""
+ self._refresh_resource_usage()
+ self._refresh_overview(self._load_events())
+
def _load_events(self) -> List[dict]:
shared_dir = self.ctx.config.shared_dir
if shared_dir:
@@ -722,7 +1335,18 @@ class MonitoringTab(QWidget):
except Exception:
mem_pct = 0
self.ov_mem_bar.setValue(min(mem_pct, 100))
- self.ov_mem_val.setText(_fmt_bytes(own_mem))
+ try:
+ self.ov_mem_val.setText(f"{_fmt_bytes(own_mem)}/{_fmt_bytes(total_mem)}")
+ except Exception: # noqa: BLE001
+ self.ov_mem_val.setText(_fmt_bytes(own_mem))
+ # Free disk on the workspace drive — a capacity fact, unlike the I/O
+ # rate that used to sit here, and the one the drawing shows.
+ try:
+ free = psutil.disk_usage(str(self.ctx.config.cowork_output_dir())).free
+ self.ov_diskfree_val.setText(
+ tr("monitoring.overview_disk_free", size=_fmt_bytes(free)))
+ except Exception: # noqa: BLE001
+ self.ov_diskfree_val.setText(tr("monitoring.na"))
now = time.monotonic()
try:
@@ -781,12 +1405,22 @@ class MonitoringTab(QWidget):
]
self.status_table.setRowCount(len(rows))
for row, (role_key, count, source) in enumerate(rows):
- self.status_table.setItem(row, 0, QTableWidgetItem(agent_roles.label_for(role_key)))
+ label = agent_roles.label_for(role_key)
+ name_item = QTableWidgetItem(label)
+ name_item.setIcon(_agent_avatar_icon(label))
+ self.status_table.setItem(row, 0, name_item)
+
if role_key == agent_roles.SECURITY:
- active_text = tr("monitoring.on") if sec_on else tr("monitoring.off")
+ running = sec_on
+ status_text = tr("monitoring.on") if sec_on else tr("monitoring.off")
+ elif count is not None:
+ running = count > 0
+ status_text = tr("monitoring.active_n", n=count) if running else tr("monitoring.idle")
else:
- active_text = tr("monitoring.active_n", n=count) if count is not None else "—"
- self.status_table.setItem(row, 1, QTableWidgetItem(active_text))
+ running = False
+ status_text = "—"
+ badge_tone = "badgeSuccess" if running else "badgeNeutral"
+ self.status_table.setCellWidget(row, 1, badge_pill_widget(status_text, badge_tone))
self.status_table.setItem(row, 2, QTableWidgetItem(source))
def _activity_line(self, event: dict) -> str:
@@ -801,7 +1435,8 @@ class MonitoringTab(QWidget):
mark = f"✗"
name = event.get("name", "") or event.get("kind", "")
rel = _relative_time(event.get("ts", ""))
- suffix = f" — {rel}" if rel else ""
+ muted = current_palette().text_muted
+ suffix = f" — {rel}" if rel else ""
return f"{mark} {name}{suffix}"
def _refresh_usage_cards(self) -> None:
@@ -811,18 +1446,26 @@ class MonitoringTab(QWidget):
events = ut.load_events()
s = ut.summarize(events)
costs = ut.cost_usd_events(events, pricing)
- self.ov_usage_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"]),
- tr("dashboard.card_turns", n=s["turns"]))
+ self.ov_usage_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"]))
+ self.ov_usage_calls.set(tr("monitoring.overview_calls"), str(s["turns"]), "")
self.ov_usage_in.set(tr("dashboard.card_in"), fmt_tokens(s["in"]),
ut.format_cost(costs["in"], pricing))
self.ov_usage_out.set(tr("dashboard.card_out"), fmt_tokens(s["out"]),
ut.format_cost(costs["out"], pricing))
self.ov_usage_cache.set(tr("dashboard.card_cache"), fmt_tokens(s["cache"]),
ut.format_cost(costs["cache"], pricing))
- self.ov_usage_cost.set(tr("dashboard.card_cost"),
- ut.format_cost(sum(costs.values()), pricing, digits=2))
+ # "Tổng chi phí · 57 lượt", exactly as the wireframe labels it.
+ self.ov_usage_cost.set(
+ f'{tr("dashboard.card_cost")} · {s["turns"]} {tr("monitoring.overview_calls")}',
+ ut.format_cost(sum(costs.values()), pricing, digits=2))
self._refresh_budget()
+ def _sync_sbx_more_label(self, *_a) -> None:
+ """Label the fold with what it will do next."""
+ open_ = self.ov_sbx_more_btn.isChecked()
+ self.ov_sbx_more_btn.setText(
+ ("▾ " if open_ else "▸ ") + tr("monitoring.overview_sbx_detail"))
+
def _apply_budget(self) -> None:
"""Persist the spin box's value as the new budget — starts a fresh
remaining-balance window (spend before now is no longer counted)."""
@@ -884,6 +1527,18 @@ class MonitoringTab(QWidget):
tr("monitoring.overview_network_disabled") if net_blocked
else tr("monitoring.overview_network_enabled"))
self._set_badge(self.ov_sbx_net_val, "badgeWarn" if net_blocked else "badgeSuccess")
+ # The one line the wireframe shows; the detail above stays a fold away.
+ self.ov_sbx_summary.setText(" · ".join([
+ f'{tr("monitoring.overview_perm_fs")}: '
+ f'{tr("monitoring.overview_perm_fs_value")}',
+ f'{tr("monitoring.overview_perm_network")}: '
+ f'{tr("monitoring.overview_network_disabled") if net_blocked else tr("monitoring.overview_network_enabled")}',
+ f'{tr("monitoring.overview_perm_process")}: '
+ f'{tr("monitoring.overview_perm_process_value")}',
+ f'{tr("monitoring.overview_resource_limits")}: '
+ f'{", ".join(limit_parts) if limit_parts else tr("monitoring.na")}',
+ ]))
+ self._sync_sbx_more_label()
self.ov_perm_network_val.setText(
tr("monitoring.overview_perm_network_blocked") if net_blocked
diff --git a/ui/schedule_task_tab.py b/ui/schedule_task_tab.py
index 7849ad7..bcc2a58 100644
--- a/ui/schedule_task_tab.py
+++ b/ui/schedule_task_tab.py
@@ -1,716 +1,794 @@
-"""Schedule Task tab — Kanban board for scheduled/automated tasks.
-
-Columns: Backlog / Scheduled / Running / Waiting Input / Done / Failed /
-Paused. Cards drag between columns (dropping = changing status), double-click
-edits, right-click offers Run now / Edit / Duplicate / Pause / Delete / View
-logs / Create-next-from-output. Header has search, a type filter, Add Task
-and AI Create Task (preview first — nothing is created until confirmed).
-"""
-from __future__ import annotations
-
-import copy
-from pathlib import Path
-from typing import Dict, List, Optional
-
-from PySide6.QtCore import Qt, Signal
-from PySide6.QtWidgets import (
- QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout,
- QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox,
- QPlainTextEdit, QPushButton, QScrollArea, QStackedWidget, QTableWidget,
- QTableWidgetItem, QVBoxLayout, QWidget,
-)
-
-from ..core import tasks as taskrepo
-from ..core.projects import list_projects
-from ..core.tasks import STATUSES, chain_error, duplicate_task, new_task
-from ..core.worker import AgentWorker
-from ..i18n import on_language_changed, tr
-from ..state import AppContext
-from .calendar_view import CalendarView
-from .icons import icon
-from .osutil import open_path
-
-_VIEWS = ("kanban", "calendar")
-
-# Priority shown as a plain text tag (no colored-emoji squares). Only the
-# elevated priorities get a visible marker; low/medium stay unmarked as before.
-_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"}
-
-
-class _KanbanColumn(QListWidget):
- """One status lane. Accepts drops from sibling columns; a drop means
- 'move this task to my status'."""
-
- task_dropped = Signal(str, str) # task_id, new_status
-
- def __init__(self, status: str):
- super().__init__()
- self.status = status
- self.setDragDropMode(QAbstractItemView.DragDrop)
- self.setDefaultDropAction(Qt.MoveAction)
- # Shift/Ctrl-click several cards in the SAME column, then right-click
- # → "Delete N selected" to bulk-remove tasks instead of one at a time.
- self.setSelectionMode(QAbstractItemView.ExtendedSelection)
- self.setWordWrap(True)
- self.setMinimumWidth(190)
-
- def dropEvent(self, event): # noqa: N802
- source = event.source()
- if isinstance(source, _KanbanColumn) and source is not self:
- item = source.currentItem()
- tid = item.data(Qt.UserRole) if item else None
- if tid:
- event.acceptProposedAction()
- self.task_dropped.emit(tid, self.status)
- return
- event.ignore()
-
-
-class ScheduleTaskTab(QWidget):
- status_message = Signal(str)
-
- def __init__(self, ctx: AppContext, scheduler=None):
- super().__init__()
- self.ctx = ctx
- self.scheduler = scheduler # TaskScheduler (may be None in tests)
- self._ai_worker: Optional[AgentWorker] = None
- self._tasks_dir: Optional[Path] = None # None → default repo dir
-
- root = QVBoxLayout(self)
-
- # ---- header ----------------------------------------------------
- header = QHBoxLayout()
- self._title = QLabel()
- self._title.setStyleSheet("font-weight:700; font-size:15px;")
- self.counts_lbl = QLabel("")
- self.counts_lbl.setObjectName("hint")
- self.add_btn = QPushButton()
- self.add_btn.setIcon(icon("plus"))
- self.add_btn.setObjectName("primary")
- self.add_btn.clicked.connect(self._add_task)
- self.ai_btn = QPushButton()
- self.ai_btn.setIcon(icon("sparkle"))
- self.ai_btn.clicked.connect(self._ai_create)
- self.view_combo = QComboBox()
- for v in _VIEWS:
- self.view_combo.addItem("", v)
- self.view_combo.currentIndexChanged.connect(self._on_view_changed)
- header.addWidget(self._title)
- header.addWidget(self.counts_lbl, 1)
- header.addWidget(self.view_combo)
- header.addWidget(self.add_btn)
- header.addWidget(self.ai_btn)
- root.addLayout(header)
-
- # ---- board / calendar (two views of the SAME tasks) -----------------
- self._view_stack = QStackedWidget()
- scroll = QScrollArea()
- scroll.setWidgetResizable(True)
- board = QWidget()
- scroll.setWidget(board)
- cols = QHBoxLayout(board)
- cols.setSpacing(8)
- self.columns: Dict[str, _KanbanColumn] = {}
- self.column_headers: Dict[str, QLabel] = {}
- for status in STATUSES:
- box = QVBoxLayout()
- head = QLabel()
- head.setStyleSheet("font-weight:600;")
- col = _KanbanColumn(status)
- col.task_dropped.connect(self._on_task_dropped)
- col.itemDoubleClicked.connect(self._on_double_click)
- col.setContextMenuPolicy(Qt.CustomContextMenu)
- col.customContextMenuRequested.connect(
- lambda pos, c=col: self._context_menu(c, pos))
- box.addWidget(head)
- box.addWidget(col, 1)
- holder = QWidget()
- holder.setLayout(box)
- cols.addWidget(holder)
- self.columns[status] = col
- self.column_headers[status] = head
- self._view_stack.addWidget(scroll)
- self.calendar = CalendarView()
- self.calendar.edit_task.connect(self._edit_task)
- self.calendar.add_task_on_date.connect(self._add_task_on_date)
- self._view_stack.addWidget(self.calendar)
- root.addWidget(self._view_stack, 1)
-
- if self.scheduler is not None:
- self.scheduler.tasks_changed.connect(self.refresh)
- self.scheduler.task_started.connect(lambda _tid: self.refresh())
- self.scheduler.task_finished.connect(lambda _tid, _ok: self.refresh())
-
- # Belt-and-braces: also re-read the board every 10s so a card's lane
- # ALWAYS reflects reality (Scheduled → Running → Done) even if some
- # change slipped past the signals (e.g. task files edited externally).
- from PySide6.QtCore import QTimer
- self._refresh_timer = QTimer(self)
- self._refresh_timer.setInterval(10_000)
- self._refresh_timer.timeout.connect(self.refresh)
- self._refresh_timer.start()
-
- self.refresh()
- on_language_changed(self._retranslate)
-
- # ---- i18n ------------------------------------------------------------
- def _retranslate(self) -> None:
- self._title.setText(tr("schedtask.title"))
- self.add_btn.setText(tr("schedtask.add_btn"))
- self.add_btn.setToolTip(tr("schedtask.add_tooltip"))
- self.ai_btn.setText(tr("schedtask.ai_btn"))
- self.ai_btn.setToolTip(tr("schedtask.ai_tooltip"))
- for i, v in enumerate(_VIEWS):
- self.view_combo.setItemText(i, tr(f"schedtask.view.{v}"))
- for status, col in self.columns.items():
- col.setToolTip(tr(f"schedtask.col_tip.{status}"))
- self.refresh()
-
- # ---- Kanban / Calendar view switch --------------------------------
- def _on_view_changed(self) -> None:
- self._view_stack.setCurrentIndex(self.view_combo.currentIndex())
-
- def _add_task_on_date(self, date_str: str) -> None:
- """Create a task pre-filled with the clicked calendar date (default
- 09:00) — same editor Add Task opens, nothing is saved until confirmed."""
- from .task_editor_dialog import TaskEditorDialog
-
- t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"})
- dlg = TaskEditorDialog(t, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
- if dlg.exec() and dlg.edited_task:
- self._save_and_refresh(dlg.edited_task)
- self.status_message.emit(tr("schedtask.msg_created"))
-
- # ---- board rendering ---------------------------------------------------
- def _card_text(self, t: dict) -> str:
- prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "")
- ai = "[AI] " if t.get("is_ai_generated") else ""
- sched = t.get("schedule", {})
- when = sched.get("run_at") if sched.get("enabled") else None
- when_line = when or tr("schedtask.no_schedule")
- chain = ""
- if t.get("dependency", {}).get("next_task_id") or t.get("dependency", {}).get("previous_task_id"):
- chain = " (linked)"
- last = t.get("logs", {}).get("last_status")
- last_line = {"success": tr("schedtask.last_success"),
- "failed": tr("schedtask.last_failed")}.get(last, tr("schedtask.last_never"))
- # Card shows ONLY the task's own title (plus the [AI] marker and chain
- # note) — no "[Cowork]"/"[Code]" task-type tag cluttering it.
- return (f"{ai}{t.get('title', '')}{chain}\n"
- f"{when_line} {prio}\n{last_line}")
-
- def refresh(self) -> None:
- all_tasks = taskrepo.list_tasks(self._tasks_dir)
- counts = {s: 0 for s in STATUSES}
- for col in self.columns.values():
- col.clear()
- for t in all_tasks:
- status = t.get("status", "backlog")
- if status not in self.columns:
- continue
- counts[status] += 1
- item = QListWidgetItem(self._card_text(t))
- item.setData(Qt.UserRole, t["task_id"])
- self.columns[status].addItem(item)
- for status, col in self.columns.items():
- self.column_headers[status].setText(
- f"{tr(f'schedtask.status.{status}')} ({counts[status]})")
- if col.count() == 0:
- empty = QListWidgetItem(tr("schedtask.no_tasks"))
- empty.setFlags(Qt.NoItemFlags)
- col.addItem(empty)
- self.counts_lbl.setText(" ".join(
- f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s]))
- self.calendar.set_tasks(all_tasks)
-
- # ---- actions --------------------------------------------------------
- def _save_and_refresh(self, task: dict) -> None:
- taskrepo.save_task(task, self._tasks_dir)
- self.refresh()
-
- def _add_task(self) -> None:
- from .task_editor_dialog import TaskEditorDialog
-
- dlg = TaskEditorDialog(None, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
- if dlg.exec() and dlg.edited_task:
- self._save_and_refresh(dlg.edited_task)
- self.status_message.emit(tr("schedtask.msg_created"))
-
- def _edit_task(self, task_id: str) -> None:
- from .task_editor_dialog import TaskEditorDialog
-
- task = taskrepo.load_task(task_id, self._tasks_dir)
- if not task:
- return
- dlg = TaskEditorDialog(task, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
- if dlg.exec() and dlg.edited_task:
- self._save_and_refresh(dlg.edited_task)
-
- def _on_double_click(self, item: QListWidgetItem) -> None:
- tid = item.data(Qt.UserRole)
- if tid:
- self._edit_task(tid)
-
- def _on_task_dropped(self, task_id: str, new_status: str) -> None:
- """Dropping a card into a lane ACTS on the task, not just relabels it:
- → Running actually runs it now; → Done marks it completed; → Scheduled
- puts it on the calendar (opening the editor if no time is set yet)."""
- task = taskrepo.load_task(task_id, self._tasks_dir)
- if not task:
- return
- if task.get("status") == "running":
- self.refresh() # can't drag a running task
- return
- if new_status == "running":
- # Dropping into Running = "run it now" (counts as manual approval).
- self.refresh()
- self._run_now(task)
- return
- if new_status == "done":
- task["status"] = "done"
- task["schedule"]["enabled"] = False # done by hand → don't re-fire
- self._save_and_refresh(task)
- return
- task["status"] = new_status
- if new_status == "scheduled" and not task["schedule"].get("enabled"):
- if task["schedule"].get("run_at"):
- task["schedule"]["enabled"] = True
- else:
- # No time set yet — a silently-disabled "Scheduled" card would
- # never run and look broken. Open the editor so the user sets
- # the schedule right away.
- self._save_and_refresh(task)
- self.status_message.emit(tr("schedtask.msg_set_schedule"))
- self._edit_task(task_id)
- return
- self._save_and_refresh(task)
-
- @staticmethod
- def _is_multi_selection(item, selected) -> bool:
- """True when the right-clicked card is part of an existing multi-item
- selection — pure boolean, kept separate from _context_menu so it's
- testable without ever invoking Qt's (modal, event-loop-blocking) menu."""
- return len(selected) > 1 and item in selected
-
- def _context_menu(self, col: _KanbanColumn, pos) -> None:
- item = col.itemAt(pos)
- if item is None or not item.data(Qt.UserRole):
- return
- selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)]
- if self._is_multi_selection(item, selected):
- self._bulk_delete_menu(col, pos, selected)
- return
- tid = item.data(Qt.UserRole)
- task = taskrepo.load_task(tid, self._tasks_dir)
- if not task:
- return
- menu = QMenu(col)
- run_act = menu.addAction(tr("schedtask.menu_run"))
- edit_act = menu.addAction(tr("schedtask.menu_edit"))
- dup_act = menu.addAction(tr("schedtask.menu_duplicate"))
- paused = task.get("status") == "paused"
- pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause"))
- logs_act = menu.addAction(tr("schedtask.menu_logs"))
- hist_act = menu.addAction(tr("schedtask.menu_history"))
- next_act = menu.addAction(tr("schedtask.menu_create_next"))
- menu.addSeparator()
- del_act = menu.addAction(tr("schedtask.menu_delete"))
- chosen = menu.exec(col.viewport().mapToGlobal(pos))
- if chosen == run_act:
- self._run_now(task)
- elif chosen == edit_act:
- self._edit_task(tid)
- elif chosen == dup_act:
- self._save_and_refresh(duplicate_task(task))
- elif chosen == pause_act:
- task["status"] = "backlog" if paused else "paused"
- self._save_and_refresh(task)
- elif chosen == logs_act:
- self._view_logs(task)
- elif chosen == hist_act:
- _RunHistoryDialog(task, self).exec()
- elif chosen == next_act:
- self._create_next_from_output(task)
- elif chosen == del_act:
- if QMessageBox.question(self, tr("schedtask.menu_delete"),
- tr("schedtask.delete_confirm", title=task.get("title", ""))
- ) == QMessageBox.Yes:
- taskrepo.delete_task(tid, self._tasks_dir)
- self.refresh()
-
- def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None:
- """Right-click on a multi-selection within one column (Shift/Ctrl-click
- several cards first): one action deletes every selected task. The
- popup itself is a thin wrapper — see _confirm_and_delete_selected for
- the actual (independently testable) confirm+delete logic."""
- menu = QMenu(col)
- del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected)))
- chosen = menu.exec(col.viewport().mapToGlobal(pos))
- if chosen == del_act:
- self._confirm_and_delete_selected(selected)
-
- def _confirm_and_delete_selected(self, selected) -> bool:
- """Confirm, then delete every task in ``selected``. Split out of
- _bulk_delete_menu so tests can drive it directly without having to
- fake a real (modal, event-loop-blocking) QMenu popup."""
- if QMessageBox.question(
- self, tr("schedtask.menu_delete"),
- tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
- return False
- for item in selected:
- tid = item.data(Qt.UserRole)
- if tid:
- taskrepo.delete_task(tid, self._tasks_dir)
- self.refresh()
- return True
-
- def _run_now(self, task: dict) -> None:
- if task.get("task_type") == "manual":
- self.status_message.emit(tr("schedtask.msg_manual_norun"))
- return
- if self.scheduler is None:
- self.status_message.emit(tr("schedtask.msg_no_scheduler"))
- return
- if self.scheduler.run_now(task["task_id"]):
- self.status_message.emit(tr("schedtask.msg_running", title=task.get("title", "")))
- self.refresh()
-
- def _view_logs(self, task: dict) -> None:
- run_id = task.get("logs", {}).get("last_run_id")
- if not run_id:
- QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
- return
- folder = taskrepo.ARTIFACTS_DIR / task["task_id"] / run_id
- if folder.exists():
- open_path(str(folder))
- else:
- QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
-
- def _create_next_from_output(self, task: dict) -> None:
- """Scaffold a follow-up task pre-wired to consume this task's output."""
- nxt = new_task(tr("schedtask.next_of", title=task.get("title", "")))
- nxt["task_type"] = "cowork"
- nxt["input"]["mode"] = "previous_task_output"
- nxt["input"]["previous_task_id"] = task["task_id"]
- nxt["dependency"]["previous_task_id"] = task["task_id"]
- err = chain_error(taskrepo.list_tasks(self._tasks_dir) + [nxt],
- task["task_id"], nxt["task_id"])
- if err:
- QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
- return
- taskrepo.save_task(nxt, self._tasks_dir)
- task["dependency"]["next_task_id"] = nxt["task_id"]
- task["dependency"]["pass_output_to_next"] = True
- if task["dependency"].get("run_next_mode", "none") == "none":
- task["dependency"]["run_next_mode"] = "run_after_success"
- taskrepo.save_task(task, self._tasks_dir)
- self.refresh()
- self._edit_task(nxt["task_id"])
-
- # ---- AI create ----------------------------------------------------------
- def _ai_create(self) -> None:
- dlg = _AiCreateDialog(self.ctx, self)
- if dlg.exec() and dlg.created_tasks:
- for t in dlg.created_tasks:
- taskrepo.save_task(t, self._tasks_dir)
- self.refresh()
- self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks)))
-
-
-class _RunHistoryDialog(QDialog):
- """Run history of one task as a table (newest first): time, status, error;
- double-click a row to open that run's artifact folder."""
-
- def __init__(self, task: dict, parent=None):
- super().__init__(parent)
- self._task = task
- self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}")
- self.resize(620, 380)
- root = QVBoxLayout(self)
- hint = QLabel(tr("schedtask.hist_hint"))
- hint.setObjectName("hint")
- root.addWidget(hint)
-
- runs = list(reversed(task.get("runs", []) or []))
- self.table = QTableWidget(len(runs), 4)
- self.table.setHorizontalHeaderLabels([
- tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"),
- tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"),
- ])
- self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
- self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
- for row, run in enumerate(runs):
- ok = run.get("status") == "success"
- cells = (
- run.get("finished_at", ""),
- str(run.get("status", "")),
- run.get("run_id", ""),
- (run.get("error") or "")[:200],
- )
- for col, text in enumerate(cells):
- item = QTableWidgetItem(str(text))
- if col == 0:
- item.setData(Qt.UserRole, run.get("run_id", ""))
- self.table.setItem(row, col, item)
- self.table.resizeColumnsToContents()
- self.table.horizontalHeader().setStretchLastSection(True)
- self.table.itemDoubleClicked.connect(self._open_artifact)
- root.addWidget(self.table, 1)
-
- buttons = QDialogButtonBox(QDialogButtonBox.Close)
- buttons.rejected.connect(self.reject)
- buttons.accepted.connect(self.accept)
- root.addWidget(buttons)
-
- def _open_artifact(self, item: QTableWidgetItem) -> None:
- first = self.table.item(item.row(), 0)
- run_id = first.data(Qt.UserRole) if first else ""
- if not run_id:
- return
- folder = taskrepo.ARTIFACTS_DIR / self._task["task_id"] / run_id
- if folder.exists():
- open_path(str(folder))
-
-
-class _DropZone(QLabel):
- """Drag-an-.xlsx-here area for the Import tab."""
-
- file_dropped = Signal(str)
-
- def __init__(self):
- super().__init__()
- self.setAlignment(Qt.AlignCenter)
- self.setMinimumHeight(70)
- self.setStyleSheet(
- "QLabel { border: 2px dashed rgba(140,146,152,0.6); border-radius: 10px;"
- " color: #8c9298; padding: 10px; }")
- self.setAcceptDrops(True)
-
- def dragEnterEvent(self, event): # noqa: N802
- urls = event.mimeData().urls()
- if urls and urls[0].toLocalFile().lower().endswith(
- (".xlsx", ".xlsm", ".xls", ".csv", ".json")):
- event.acceptProposedAction()
-
- def dropEvent(self, event): # noqa: N802
- urls = event.mimeData().urls()
- if urls:
- self.file_dropped.emit(urls[0].toLocalFile())
-
-
-class _AiCreateDialog(QDialog):
- """Create tasks two ways, one tab each (both preview first — nothing is
- saved until the user confirms): ✨ AI gen from a natural-language
- description, or 📥 Import from a filled Excel template (pick or drag)."""
-
- def __init__(self, ctx: AppContext, parent=None):
- super().__init__(parent)
- from PySide6.QtWidgets import QTabWidget
-
- self.ctx = ctx
- self.created_tasks: List[dict] = []
- self._planned: List[dict] = []
- self._worker: Optional[AgentWorker] = None
- self.setWindowTitle(tr("schedtask.ai_btn"))
- self.resize(600, 520)
-
- root = QVBoxLayout(self)
- ws_row = QHBoxLayout()
- ws_row.addWidget(QLabel(tr("schedtask.f_workspace")))
- self.workspace_combo = QComboBox()
- self.workspace_combo.addItem(tr("schedtask.no_workspace"), "")
- for p in list_projects():
- self.workspace_combo.addItem(p.name, p.project_id)
- self.workspace_combo.setToolTip(tr("schedtask.hint_workspace"))
- ws_row.addWidget(self.workspace_combo, 1)
- root.addLayout(ws_row)
- self.tabs = QTabWidget()
- root.addWidget(self.tabs, 1)
-
- # ---- tab 1: AI gen ------------------------------------------------
- ai_page = QWidget()
- al = QVBoxLayout(ai_page)
- al.addWidget(QLabel(tr("schedtask.ai_desc_label")))
- self.desc_edit = QPlainTextEdit()
- self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph"))
- self.desc_edit.setMaximumHeight(110)
- al.addWidget(self.desc_edit)
- # Attachments (files + links) — merged into every task this generates,
- # AND into the planning prompt so the AI knows they exist.
- attach_row = QHBoxLayout()
- self.ai_files_edit = QLineEdit()
- self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder"))
- ai_pick_btn = QPushButton(tr("schedtask.pick_files"))
- ai_pick_btn.setIcon(icon("folder"))
- ai_pick_btn.clicked.connect(self._ai_pick_files)
- attach_row.addWidget(self.ai_files_edit, 1)
- attach_row.addWidget(ai_pick_btn)
- al.addWidget(QLabel(tr("schedtask.f_files")))
- al.addLayout(attach_row)
- self.ai_links_edit = QLineEdit()
- self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder"))
- al.addWidget(QLabel(tr("schedtask.f_links")))
- al.addWidget(self.ai_links_edit)
- self.gen_btn = QPushButton(tr("schedtask.ai_generate"))
- self.gen_btn.setIcon(icon("sparkle"))
- self.gen_btn.setObjectName("primary")
- self.gen_btn.clicked.connect(self._generate)
- al.addWidget(self.gen_btn)
- al.addWidget(QLabel(tr("schedtask.ai_preview_label")))
- self.preview = QPlainTextEdit()
- self.preview.setReadOnly(True)
- al.addWidget(self.preview, 1)
- self.tabs.addTab(ai_page, tr("schedtask.tab_ai"))
-
- # ---- tab 2: Import from Excel --------------------------------------
- imp_page = QWidget()
- il = QVBoxLayout(imp_page)
- tpl_btn = QPushButton(tr("schedtask.export_template_btn"))
- tpl_btn.setIcon(icon("upload"))
- tpl_btn.clicked.connect(self._export_template)
- il.addWidget(tpl_btn)
- pick_row = QHBoxLayout()
- pick_btn = QPushButton(tr("schedtask.import_pick_btn"))
- pick_btn.setIcon(icon("folder"))
- pick_btn.clicked.connect(self._pick_import_file)
- pick_row.addWidget(pick_btn)
- pick_row.addStretch(1)
- il.addLayout(pick_row)
- self.drop_zone = _DropZone()
- self.drop_zone.setText(tr("schedtask.drop_hint"))
- self.drop_zone.file_dropped.connect(self._load_import_file)
- il.addWidget(self.drop_zone)
- il.addWidget(QLabel(tr("schedtask.ai_preview_label")))
- self.import_preview = QPlainTextEdit()
- self.import_preview.setReadOnly(True)
- il.addWidget(self.import_preview, 1)
- self.tabs.addTab(imp_page, tr("schedtask.tab_import"))
-
- self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
- self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm"))
- self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
- self.buttons.accepted.connect(self._confirm)
- self.buttons.rejected.connect(self.reject)
- root.addWidget(self.buttons)
-
- # ---- Import tab ------------------------------------------------------
- def _export_template(self) -> None:
- from PySide6.QtWidgets import QFileDialog
-
- from ..core.task_excel import export_template
-
- path, _ = QFileDialog.getSaveFileName(
- self, tr("schedtask.export_template_btn"),
- "cowork_tasks_template.xlsx", "Excel (*.xlsx)")
- if not path:
- return
- try:
- export_template(path)
- open_path(str(Path(path).parent))
- except Exception as exc: # noqa: BLE001
- QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc))
-
- def _pick_import_file(self) -> None:
- from PySide6.QtWidgets import QFileDialog
-
- from ..core.task_import import IMPORT_FILTER
-
- path, _ = QFileDialog.getOpenFileName(
- self, tr("schedtask.import_pick_btn"), "", IMPORT_FILTER)
- if path:
- self._load_import_file(path)
-
- def _load_import_file(self, path: str) -> None:
- from ..core.task_import import import_tasks
-
- try:
- self._planned = import_tasks(path)
- except ValueError as exc:
- self.import_preview.setPlainText(str(exc))
- self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
- return
- by_id = {t["task_id"]: t["title"] for t in self._planned}
- lines = []
- for i, t in enumerate(self._planned, 1):
- sched = t.get("schedule", {})
- when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
- deps = t.get("dependency", {}).get("depends_on") or []
- dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else ""
- lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
- f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}")
- self.import_preview.setPlainText("\n\n".join(lines))
- self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned))
-
- def _ai_pick_files(self) -> None:
- from PySide6.QtWidgets import QFileDialog
-
- files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files"))
- if files:
- existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()]
- self.ai_files_edit.setText("; ".join(existing + files))
-
- def _attached_files(self) -> List[str]:
- return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()]
-
- def _attached_links(self) -> List[str]:
- return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()]
-
- def _generate(self) -> None:
- description = self.desc_edit.toPlainText().strip()
- if not description or self._worker is not None:
- return
- files, links = self._attached_files(), self._attached_links()
- self.gen_btn.setEnabled(False)
- self.gen_btn.setText(tr("schedtask.ai_generating"))
-
- def job(worker: AgentWorker):
- from ..core.ai_task_planner import plan_tasks
-
- provider = self.ctx.build_active_provider()
- full_desc = description
- if files or links:
- attach_note = "; ".join(files + links)
- full_desc += f"\n\n(Attached references available: {attach_note})"
- planned = plan_tasks(provider, full_desc, cancel=worker.is_cancelled)
- # Attachments apply to every generated task so they're available
- # at RUN time too, not just visible to the planner.
- for t in planned:
- t["input"]["file_paths"] = list(files)
- t["input"]["links"] = list(links)
- return {"tasks": planned}
-
- w = AgentWorker(job)
- w.finished_ok.connect(self._on_planned)
- w.failed.connect(self._on_failed)
- self._worker = w
- w.start()
-
- def _on_planned(self, result: dict) -> None:
- self._worker = None
- self.gen_btn.setEnabled(True)
- self.gen_btn.setText(tr("schedtask.ai_generate"))
- self._planned = result.get("tasks") or []
- lines = []
- for i, t in enumerate(self._planned, 1):
- sched = t.get("schedule", {})
- when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
- dep = t.get("dependency", {})
- chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else ""
- lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
- f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n"
- f" {t.get('description', '')[:150]}")
- self.preview.setPlainText("\n\n".join(lines))
- self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned))
-
- def _on_failed(self, err: str) -> None:
- self._worker = None
- self.gen_btn.setEnabled(True)
- self.gen_btn.setText(tr("schedtask.ai_generate"))
- self.preview.setPlainText(str(err))
-
- def _confirm(self) -> None:
- project_id = self.workspace_combo.currentData() or ""
- for t in self._planned:
- t["project_id"] = project_id
- self.created_tasks = self._planned
- self.accept()
+"""Schedule Task tab — Kanban board for scheduled/automated tasks.
+
+Columns: Backlog / Scheduled / Running / Waiting Input / Done / Failed /
+Paused. Cards drag between columns (dropping = changing status), double-click
+edits, right-click offers Run now / Edit / Duplicate / Pause / Delete / View
+logs / Create-next-from-output. Header has search, a type filter, Add Task
+and AI Create Task (preview first — nothing is created until confirmed).
+"""
+from __future__ import annotations
+
+import copy
+from pathlib import Path
+from typing import Dict, List, Optional
+
+from PySide6.QtCore import Qt, Signal
+from PySide6.QtWidgets import (
+ QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout,
+ QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox,
+ QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget,
+ QTabBar, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
+)
+
+from ..core import tasks as taskrepo
+from ..core.projects import list_projects
+from ..core.tasks import STATUSES, chain_error, duplicate_task, new_task
+from ..core.worker import AgentWorker
+from ..i18n import on_language_changed, tr
+from ..state import AppContext
+from ..theme import current_palette
+from .calendar_view import CalendarView
+from .icons import icon
+from .osutil import open_path
+
+_VIEWS = ("kanban", "calendar")
+
+# Priority shown as a plain text tag (no colored-emoji squares). Only the
+# elevated priorities get a visible marker; low/medium stay unmarked as before.
+_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"}
+
+
+class _KanbanColumn(QListWidget):
+ """One status lane. Accepts drops from sibling columns; a drop means
+ 'move this task to my status'."""
+
+ task_dropped = Signal(str, str) # task_id, new_status
+
+ def __init__(self, status: str):
+ super().__init__()
+ self.status = status
+ self.setDragDropMode(QAbstractItemView.DragDrop)
+ self.setDefaultDropAction(Qt.MoveAction)
+ # Shift/Ctrl-click several cards in the SAME column, then right-click
+ # → "Delete N selected" to bulk-remove tasks instead of one at a time.
+ self.setSelectionMode(QAbstractItemView.ExtendedSelection)
+ self.setWordWrap(True)
+ # Cards wrap, so there is never anything to reach by scrolling sideways
+ # — but QListWidget's own column hint runs 1-6px past the viewport, and
+ # a lane sprouted a horizontal scrollbar at 36 of 38 window widths I
+ # measured. Which lanes grew one changed with the width, which is why it
+ # looked like it depended on the screen.
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ self.setResizeMode(QListWidget.Adjust) # re-wrap on every resize
+ # No pixel floor here. A fixed one is always wrong on some screen:
+ # 190 lost the seventh lane, 150 still wanted 1242px where a 1280
+ # window leaves 1091 — so the 1280 monitor scrolled sideways and the
+ # 1920 one did not, same app, same build. The board divides whatever
+ # width it has by seven instead; see _fit_lanes().
+
+ def dropEvent(self, event): # noqa: N802
+ source = event.source()
+ if isinstance(source, _KanbanColumn) and source is not self:
+ item = source.currentItem()
+ tid = item.data(Qt.UserRole) if item else None
+ if tid:
+ event.acceptProposedAction()
+ self.task_dropped.emit(tid, self.status)
+ return
+ event.ignore()
+
+
+class ScheduleTaskTab(QWidget):
+ status_message = Signal(str)
+
+ def __init__(self, ctx: AppContext, scheduler=None):
+ super().__init__()
+ self.ctx = ctx
+ self.scheduler = scheduler # TaskScheduler (may be None in tests)
+ self._ai_worker: Optional[AgentWorker] = None
+ self._tasks_dir: Optional[Path] = None # None → default repo dir
+
+ root = QVBoxLayout(self)
+
+ # ---- header ----------------------------------------------------
+ header = QHBoxLayout()
+ self._title = QLabel()
+ self._title.setStyleSheet("font-weight:700; font-size:15px;")
+ self.counts_lbl = QLabel("")
+ self.counts_lbl.setObjectName("hint")
+ # A one-line summary of every lane's count. Left to size itself it
+ # reported a sizeHint wide enough to set the MINIMUM width of the whole
+ # screen — 1285px at 150% scaling, which then became the window's
+ # minimum and stopped the app fitting a 1280px laptop. It is a summary,
+ # and the same numbers are on each lane header, so it gives way first.
+ self.counts_lbl.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
+ self.counts_lbl.setMinimumWidth(0)
+ self.add_btn = QPushButton()
+ self.add_btn.setIcon(icon("plus"))
+ self.add_btn.setObjectName("primary")
+ self.add_btn.clicked.connect(self._add_task)
+ self.ai_btn = QPushButton()
+ self.ai_btn.setIcon(icon("sparkle"))
+ self.ai_btn.clicked.connect(self._ai_create)
+ # Two views of the same tasks, so they read as a pair of tabs rather
+ # than a drop-list you have to open to discover the Calendar exists.
+ self.view_tabs = QTabBar()
+ self.view_tabs.setObjectName("viewTabs")
+ self.view_tabs.setDrawBase(False)
+ self.view_tabs.setExpanding(False)
+ for _v in _VIEWS:
+ self.view_tabs.addTab("")
+ self.view_tabs.currentChanged.connect(self._on_view_changed)
+ header.addWidget(self._title)
+ header.addWidget(self.counts_lbl, 1)
+ header.addWidget(self.view_tabs)
+ header.addWidget(self.add_btn)
+ header.addWidget(self.ai_btn)
+ root.addLayout(header)
+
+ # ---- board / calendar (two views of the SAME tasks) -----------------
+ self._view_stack = QStackedWidget()
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ board = QWidget()
+ scroll.setWidget(board)
+ cols = QHBoxLayout(board)
+ # Gutters wide enough to read as a break between lanes without eating
+ # too much of the seven-way split — they still share the board equally
+ # (see _fit_lanes below), so a wider gutter narrows every lane by the
+ # same share automatically; nothing else to compute here.
+ cols.setSpacing(2)
+ self.columns: Dict[str, _KanbanColumn] = {}
+ self.column_headers: Dict[str, QLabel] = {}
+ for status in STATUSES:
+ box = QVBoxLayout()
+ # The per-lane holder's own margins were the style's default
+ # (~9px a side) on top of the inter-column gap — with seven lanes
+ # that outweighs the gap itself. Zero it out and let the lane's
+ # header/list fill the width _fit_lanes() hands them.
+ box.setContentsMargins(0, 0, 0, 0)
+ box.setSpacing(2)
+ head = QLabel()
+ head.setStyleSheet("font-weight:600;")
+ col = _KanbanColumn(status)
+ col.setObjectName("kanbanLane")
+ col.task_dropped.connect(self._on_task_dropped)
+ col.itemDoubleClicked.connect(self._on_double_click)
+ col.setContextMenuPolicy(Qt.CustomContextMenu)
+ col.customContextMenuRequested.connect(
+ lambda pos, c=col: self._context_menu(c, pos))
+ box.addWidget(head)
+ box.addWidget(col, 1)
+ holder = QWidget()
+ holder.setLayout(box)
+ cols.addWidget(holder)
+ self.columns[status] = col
+ self.column_headers[status] = head
+ self._board_scroll = scroll
+ self._board_gap = cols.spacing()
+ scroll.viewport().installEventFilter(self)
+ self._view_stack.addWidget(scroll)
+ self.calendar = CalendarView()
+ self.calendar.edit_task.connect(self._edit_task)
+ self.calendar.add_task_on_date.connect(self._add_task_on_date)
+ self._view_stack.addWidget(self.calendar)
+ root.addWidget(self._view_stack, 1)
+
+ if self.scheduler is not None:
+ self.scheduler.tasks_changed.connect(self.refresh)
+ self.scheduler.task_started.connect(lambda _tid: self.refresh())
+ self.scheduler.task_finished.connect(lambda _tid, _ok: self.refresh())
+
+ # Belt-and-braces: also re-read the board every 10s so a card's lane
+ # ALWAYS reflects reality (Scheduled → Running → Done) even if some
+ # change slipped past the signals (e.g. task files edited externally).
+ from PySide6.QtCore import QTimer
+ self._refresh_timer = QTimer(self)
+ self._refresh_timer.setInterval(10_000)
+ self._refresh_timer.timeout.connect(self.refresh)
+ self._refresh_timer.start()
+
+ self.refresh()
+ on_language_changed(self._retranslate)
+
+ # ---- i18n ------------------------------------------------------------
+ def _retranslate(self) -> None:
+ self._title.setText(tr("schedtask.title"))
+ self.add_btn.setText(tr("schedtask.add_btn"))
+ self.add_btn.setToolTip(tr("schedtask.add_tooltip"))
+ self.ai_btn.setText(tr("schedtask.ai_btn"))
+ self.ai_btn.setToolTip(tr("schedtask.ai_tooltip"))
+ for i, v in enumerate(_VIEWS):
+ self.view_tabs.setTabText(i, tr(f"schedtask.view.{v}"))
+ for status, col in self.columns.items():
+ col.setToolTip(tr(f"schedtask.col_tip.{status}"))
+ self.refresh()
+
+ # ---- Kanban / Calendar view switch --------------------------------
+ def _on_view_changed(self) -> None:
+ self._view_stack.setCurrentIndex(self.view_tabs.currentIndex())
+
+ def _add_task_on_date(self, date_str: str) -> None:
+ """Create a task pre-filled with the clicked calendar date (default
+ 09:00) — same editor Add Task opens, nothing is saved until confirmed."""
+ from .task_editor_dialog import TaskEditorDialog
+
+ t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"})
+ dlg = TaskEditorDialog(t, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
+ if dlg.exec() and dlg.edited_task:
+ self._save_and_refresh(dlg.edited_task)
+ self.status_message.emit(tr("schedtask.msg_created"))
+
+ # ---- lane widths ------------------------------------------------------
+ #
+ # The seven lanes share the board equally — that is the layout's stretch
+ # doing the work, so the split is a proportion of whatever width there is,
+ # on any monitor. The only pixel question left is how narrow a lane may get
+ # before scrolling sideways beats squeezing, and that is a question about
+ # TEXT: roughly eight characters of a task title plus its padding. Reading
+ # it off the font keeps it right at 125%/150% scaling and at a user's own
+ # font size, where a constant would not be.
+ _LANE_FLOOR_CH = 8
+
+ def eventFilter(self, obj, event): # noqa: N802
+ from PySide6.QtCore import QEvent
+
+ if obj is self._board_scroll.viewport() and event.type() == QEvent.Resize:
+ self._fit_lanes()
+ return super().eventFilter(obj, event)
+
+ def _fit_lanes(self) -> None:
+ floor = self.fontMetrics().averageCharWidth() * self._LANE_FLOOR_CH + 24
+ for col in self.columns.values():
+ if col.minimumWidth() != floor:
+ col.setMinimumWidth(floor)
+
+ # ---- board rendering ---------------------------------------------------
+ def _card_text(self, t: dict) -> str:
+ prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "")
+ ai = "[AI] " if t.get("is_ai_generated") else ""
+ sched = t.get("schedule", {})
+ when = sched.get("run_at") if sched.get("enabled") else None
+ when_line = when or tr("schedtask.no_schedule")
+ chain = ""
+ if t.get("dependency", {}).get("next_task_id") or t.get("dependency", {}).get("previous_task_id"):
+ chain = " (linked)"
+ last = t.get("logs", {}).get("last_status")
+ last_line = {"success": tr("schedtask.last_success"),
+ "failed": tr("schedtask.last_failed")}.get(last, tr("schedtask.last_never"))
+ # Card shows ONLY the task's own title (plus the [AI] marker and chain
+ # note) — no "[Cowork]"/"[Code]" task-type tag cluttering it.
+ return (f"{ai}{t.get('title', '')}{chain}\n"
+ f"{when_line} {prio}\n{last_line}")
+
+ def refresh(self) -> None:
+ all_tasks = taskrepo.list_tasks(self._tasks_dir)
+ counts = {s: 0 for s in STATUSES}
+ for col in self.columns.values():
+ col.clear()
+ for t in all_tasks:
+ status = t.get("status", "backlog")
+ if status not in self.columns:
+ continue
+ counts[status] += 1
+ item = QListWidgetItem(self._card_text(t))
+ item.setData(Qt.UserRole, t["task_id"])
+ self.columns[status].addItem(item)
+ pal = current_palette()
+ for status, col in self.columns.items():
+ self.column_headers[status].setText(
+ f"{tr(f'schedtask.status.{status}')} ({counts[status]})")
+ # Dropping a card into Running STARTS the task for real, so that
+ # lane is outlined while it holds anything — the one column here
+ # with a side effect should not look like the other six.
+ if status == "running" and counts[status]:
+ col.setStyleSheet(
+ f"border: 1px solid {pal.warning}; border-radius: {pal.radius}px;")
+ self.column_headers[status].setStyleSheet(
+ f"font-weight:600; color: {pal.warning};")
+ else:
+ col.setStyleSheet("")
+ self.column_headers[status].setStyleSheet("font-weight:600;")
+ if col.count() == 0:
+ empty = QListWidgetItem(tr("schedtask.no_tasks"))
+ empty.setFlags(Qt.NoItemFlags)
+ col.addItem(empty)
+ summary = " ".join(
+ f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s])
+ self.counts_lbl.setText(summary)
+ self.counts_lbl.setToolTip(summary) # full text stays reachable if clipped
+ self.calendar.set_tasks(all_tasks)
+
+ # ---- actions --------------------------------------------------------
+ def _save_and_refresh(self, task: dict) -> None:
+ taskrepo.save_task(task, self._tasks_dir)
+ self.refresh()
+
+ def _add_task(self) -> None:
+ from .task_editor_dialog import TaskEditorDialog
+
+ dlg = TaskEditorDialog(None, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
+ if dlg.exec() and dlg.edited_task:
+ self._save_and_refresh(dlg.edited_task)
+ self.status_message.emit(tr("schedtask.msg_created"))
+
+ def _edit_task(self, task_id: str) -> None:
+ from .task_editor_dialog import TaskEditorDialog
+
+ task = taskrepo.load_task(task_id, self._tasks_dir)
+ if not task:
+ return
+ dlg = TaskEditorDialog(task, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
+ if dlg.exec() and dlg.edited_task:
+ self._save_and_refresh(dlg.edited_task)
+
+ def _on_double_click(self, item: QListWidgetItem) -> None:
+ tid = item.data(Qt.UserRole)
+ if tid:
+ self._edit_task(tid)
+
+ def _on_task_dropped(self, task_id: str, new_status: str) -> None:
+ """Dropping a card into a lane ACTS on the task, not just relabels it:
+ → Running actually runs it now; → Done marks it completed; → Scheduled
+ puts it on the calendar (opening the editor if no time is set yet)."""
+ task = taskrepo.load_task(task_id, self._tasks_dir)
+ if not task:
+ return
+ if task.get("status") == "running":
+ self.refresh() # can't drag a running task
+ return
+ if new_status == "running":
+ # Dropping into Running = "run it now" (counts as manual approval).
+ self.refresh()
+ self._run_now(task)
+ return
+ if new_status == "done":
+ task["status"] = "done"
+ task["schedule"]["enabled"] = False # done by hand → don't re-fire
+ self._save_and_refresh(task)
+ return
+ task["status"] = new_status
+ if new_status == "scheduled" and not task["schedule"].get("enabled"):
+ if task["schedule"].get("run_at"):
+ task["schedule"]["enabled"] = True
+ else:
+ # No time set yet — a silently-disabled "Scheduled" card would
+ # never run and look broken. Open the editor so the user sets
+ # the schedule right away.
+ self._save_and_refresh(task)
+ self.status_message.emit(tr("schedtask.msg_set_schedule"))
+ self._edit_task(task_id)
+ return
+ self._save_and_refresh(task)
+
+ @staticmethod
+ def _is_multi_selection(item, selected) -> bool:
+ """True when the right-clicked card is part of an existing multi-item
+ selection — pure boolean, kept separate from _context_menu so it's
+ testable without ever invoking Qt's (modal, event-loop-blocking) menu."""
+ return len(selected) > 1 and item in selected
+
+ def _context_menu(self, col: _KanbanColumn, pos) -> None:
+ item = col.itemAt(pos)
+ if item is None or not item.data(Qt.UserRole):
+ return
+ selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)]
+ if self._is_multi_selection(item, selected):
+ self._bulk_delete_menu(col, pos, selected)
+ return
+ tid = item.data(Qt.UserRole)
+ task = taskrepo.load_task(tid, self._tasks_dir)
+ if not task:
+ return
+ menu = QMenu(col)
+ run_act = menu.addAction(tr("schedtask.menu_run"))
+ edit_act = menu.addAction(tr("schedtask.menu_edit"))
+ dup_act = menu.addAction(tr("schedtask.menu_duplicate"))
+ paused = task.get("status") == "paused"
+ pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause"))
+ logs_act = menu.addAction(tr("schedtask.menu_logs"))
+ hist_act = menu.addAction(tr("schedtask.menu_history"))
+ next_act = menu.addAction(tr("schedtask.menu_create_next"))
+ menu.addSeparator()
+ del_act = menu.addAction(tr("schedtask.menu_delete"))
+ chosen = menu.exec(col.viewport().mapToGlobal(pos))
+ if chosen == run_act:
+ self._run_now(task)
+ elif chosen == edit_act:
+ self._edit_task(tid)
+ elif chosen == dup_act:
+ self._save_and_refresh(duplicate_task(task))
+ elif chosen == pause_act:
+ task["status"] = "backlog" if paused else "paused"
+ self._save_and_refresh(task)
+ elif chosen == logs_act:
+ self._view_logs(task)
+ elif chosen == hist_act:
+ _RunHistoryDialog(task, self).exec()
+ elif chosen == next_act:
+ self._create_next_from_output(task)
+ elif chosen == del_act:
+ if QMessageBox.question(self, tr("schedtask.menu_delete"),
+ tr("schedtask.delete_confirm", title=task.get("title", ""))
+ ) == QMessageBox.Yes:
+ taskrepo.delete_task(tid, self._tasks_dir)
+ self.refresh()
+
+ def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None:
+ """Right-click on a multi-selection within one column (Shift/Ctrl-click
+ several cards first): one action deletes every selected task. The
+ popup itself is a thin wrapper — see _confirm_and_delete_selected for
+ the actual (independently testable) confirm+delete logic."""
+ menu = QMenu(col)
+ del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected)))
+ chosen = menu.exec(col.viewport().mapToGlobal(pos))
+ if chosen == del_act:
+ self._confirm_and_delete_selected(selected)
+
+ def _confirm_and_delete_selected(self, selected) -> bool:
+ """Confirm, then delete every task in ``selected``. Split out of
+ _bulk_delete_menu so tests can drive it directly without having to
+ fake a real (modal, event-loop-blocking) QMenu popup."""
+ if QMessageBox.question(
+ self, tr("schedtask.menu_delete"),
+ tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
+ return False
+ for item in selected:
+ tid = item.data(Qt.UserRole)
+ if tid:
+ taskrepo.delete_task(tid, self._tasks_dir)
+ self.refresh()
+ return True
+
+ def _run_now(self, task: dict) -> None:
+ if task.get("task_type") == "manual":
+ self.status_message.emit(tr("schedtask.msg_manual_norun"))
+ return
+ if self.scheduler is None:
+ self.status_message.emit(tr("schedtask.msg_no_scheduler"))
+ return
+ if self.scheduler.run_now(task["task_id"]):
+ self.status_message.emit(tr("schedtask.msg_running", title=task.get("title", "")))
+ self.refresh()
+
+ def _view_logs(self, task: dict) -> None:
+ run_id = task.get("logs", {}).get("last_run_id")
+ if not run_id:
+ QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
+ return
+ folder = taskrepo.ARTIFACTS_DIR / task["task_id"] / run_id
+ if folder.exists():
+ open_path(str(folder))
+ else:
+ QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
+
+ def _create_next_from_output(self, task: dict) -> None:
+ """Scaffold a follow-up task pre-wired to consume this task's output."""
+ nxt = new_task(tr("schedtask.next_of", title=task.get("title", "")))
+ nxt["task_type"] = "cowork"
+ nxt["input"]["mode"] = "previous_task_output"
+ nxt["input"]["previous_task_id"] = task["task_id"]
+ nxt["dependency"]["previous_task_id"] = task["task_id"]
+ err = chain_error(taskrepo.list_tasks(self._tasks_dir) + [nxt],
+ task["task_id"], nxt["task_id"])
+ if err:
+ QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
+ return
+ taskrepo.save_task(nxt, self._tasks_dir)
+ task["dependency"]["next_task_id"] = nxt["task_id"]
+ task["dependency"]["pass_output_to_next"] = True
+ if task["dependency"].get("run_next_mode", "none") == "none":
+ task["dependency"]["run_next_mode"] = "run_after_success"
+ taskrepo.save_task(task, self._tasks_dir)
+ self.refresh()
+ self._edit_task(nxt["task_id"])
+
+ # ---- AI create ----------------------------------------------------------
+ def _ai_create(self) -> None:
+ dlg = _AiCreateDialog(self.ctx, self)
+ if dlg.exec() and dlg.created_tasks:
+ for t in dlg.created_tasks:
+ taskrepo.save_task(t, self._tasks_dir)
+ self.refresh()
+ self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks)))
+
+
+class _RunHistoryDialog(QDialog):
+ """Run history of one task as a table (newest first): time, status, error;
+ double-click a row to open that run's artifact folder."""
+
+ def __init__(self, task: dict, parent=None):
+ super().__init__(parent)
+ self._task = task
+ self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}")
+ self.resize(620, 380)
+ root = QVBoxLayout(self)
+ hint = QLabel(tr("schedtask.hist_hint"))
+ hint.setObjectName("hint")
+ root.addWidget(hint)
+
+ runs = list(reversed(task.get("runs", []) or []))
+ self.table = QTableWidget(len(runs), 4)
+ self.table.setHorizontalHeaderLabels([
+ tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"),
+ tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"),
+ ])
+ self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
+ self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
+ for row, run in enumerate(runs):
+ ok = run.get("status") == "success"
+ cells = (
+ run.get("finished_at", ""),
+ str(run.get("status", "")),
+ run.get("run_id", ""),
+ (run.get("error") or "")[:200],
+ )
+ for col, text in enumerate(cells):
+ item = QTableWidgetItem(str(text))
+ if col == 0:
+ item.setData(Qt.UserRole, run.get("run_id", ""))
+ self.table.setItem(row, col, item)
+ self.table.resizeColumnsToContents()
+ self.table.horizontalHeader().setStretchLastSection(True)
+ self.table.itemDoubleClicked.connect(self._open_artifact)
+ root.addWidget(self.table, 1)
+
+ buttons = QDialogButtonBox(QDialogButtonBox.Close)
+ buttons.rejected.connect(self.reject)
+ buttons.accepted.connect(self.accept)
+ root.addWidget(buttons)
+
+ def _open_artifact(self, item: QTableWidgetItem) -> None:
+ first = self.table.item(item.row(), 0)
+ run_id = first.data(Qt.UserRole) if first else ""
+ if not run_id:
+ return
+ folder = taskrepo.ARTIFACTS_DIR / self._task["task_id"] / run_id
+ if folder.exists():
+ open_path(str(folder))
+
+
+class _DropZone(QLabel):
+ """Drag-an-.xlsx-here area for the Import tab."""
+
+ file_dropped = Signal(str)
+
+ def __init__(self):
+ super().__init__()
+ self.setAlignment(Qt.AlignCenter)
+ self.setMinimumHeight(70)
+ _p = current_palette()
+ self.setStyleSheet(
+ f"QLabel {{ border: 1px dashed {_p.border_strong};"
+ f" border-radius: {_p.radius_lg}px;"
+ f" color: {_p.text_muted}; padding: 10px; }}")
+ self.setAcceptDrops(True)
+
+ def dragEnterEvent(self, event): # noqa: N802
+ urls = event.mimeData().urls()
+ if urls and urls[0].toLocalFile().lower().endswith(
+ (".xlsx", ".xlsm", ".xls", ".csv", ".json")):
+ event.acceptProposedAction()
+
+ def dropEvent(self, event): # noqa: N802
+ urls = event.mimeData().urls()
+ if urls:
+ self.file_dropped.emit(urls[0].toLocalFile())
+
+
+class _AiCreateDialog(QDialog):
+ """Create tasks two ways, one tab each (both preview first — nothing is
+ saved until the user confirms): ✨ AI gen from a natural-language
+ description, or 📥 Import from a filled Excel template (pick or drag)."""
+
+ def __init__(self, ctx: AppContext, parent=None):
+ super().__init__(parent)
+ from PySide6.QtWidgets import QTabWidget
+
+ self.ctx = ctx
+ self.created_tasks: List[dict] = []
+ self._planned: List[dict] = []
+ self._worker: Optional[AgentWorker] = None
+ self.setWindowTitle(tr("schedtask.ai_btn"))
+ self.resize(600, 520)
+
+ root = QVBoxLayout(self)
+ ws_row = QHBoxLayout()
+ ws_row.addWidget(QLabel(tr("schedtask.f_workspace")))
+ self.workspace_combo = QComboBox()
+ self.workspace_combo.addItem(tr("schedtask.no_workspace"), "")
+ for p in list_projects():
+ self.workspace_combo.addItem(p.name, p.project_id)
+ self.workspace_combo.setToolTip(tr("schedtask.hint_workspace"))
+ ws_row.addWidget(self.workspace_combo, 1)
+ root.addLayout(ws_row)
+ self.tabs = QTabWidget()
+ root.addWidget(self.tabs, 1)
+
+ # ---- tab 1: AI gen ------------------------------------------------
+ ai_page = QWidget()
+ al = QVBoxLayout(ai_page)
+ al.addWidget(QLabel(tr("schedtask.ai_desc_label")))
+ self.desc_edit = QPlainTextEdit()
+ self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph"))
+ self.desc_edit.setMaximumHeight(110)
+ al.addWidget(self.desc_edit)
+ # Attachments (files + links) — merged into every task this generates,
+ # AND into the planning prompt so the AI knows they exist.
+ attach_row = QHBoxLayout()
+ self.ai_files_edit = QLineEdit()
+ self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder"))
+ ai_pick_btn = QPushButton(tr("schedtask.pick_files"))
+ ai_pick_btn.setIcon(icon("folder"))
+ ai_pick_btn.clicked.connect(self._ai_pick_files)
+ attach_row.addWidget(self.ai_files_edit, 1)
+ attach_row.addWidget(ai_pick_btn)
+ al.addWidget(QLabel(tr("schedtask.f_files")))
+ al.addLayout(attach_row)
+ self.ai_links_edit = QLineEdit()
+ self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder"))
+ al.addWidget(QLabel(tr("schedtask.f_links")))
+ al.addWidget(self.ai_links_edit)
+ self.gen_btn = QPushButton(tr("schedtask.ai_generate"))
+ self.gen_btn.setIcon(icon("sparkle"))
+ self.gen_btn.setObjectName("primary")
+ self.gen_btn.clicked.connect(self._generate)
+ al.addWidget(self.gen_btn)
+ al.addWidget(QLabel(tr("schedtask.ai_preview_label")))
+ self.preview = QPlainTextEdit()
+ self.preview.setReadOnly(True)
+ al.addWidget(self.preview, 1)
+ self.tabs.addTab(ai_page, tr("schedtask.tab_ai"))
+
+ # ---- tab 2: Import from Excel --------------------------------------
+ imp_page = QWidget()
+ il = QVBoxLayout(imp_page)
+ tpl_btn = QPushButton(tr("schedtask.export_template_btn"))
+ tpl_btn.setIcon(icon("upload"))
+ tpl_btn.clicked.connect(self._export_template)
+ il.addWidget(tpl_btn)
+ pick_row = QHBoxLayout()
+ pick_btn = QPushButton(tr("schedtask.import_pick_btn"))
+ pick_btn.setIcon(icon("folder"))
+ pick_btn.clicked.connect(self._pick_import_file)
+ pick_row.addWidget(pick_btn)
+ pick_row.addStretch(1)
+ il.addLayout(pick_row)
+ self.drop_zone = _DropZone()
+ self.drop_zone.setText(tr("schedtask.drop_hint"))
+ self.drop_zone.file_dropped.connect(self._load_import_file)
+ il.addWidget(self.drop_zone)
+ il.addWidget(QLabel(tr("schedtask.ai_preview_label")))
+ self.import_preview = QPlainTextEdit()
+ self.import_preview.setReadOnly(True)
+ il.addWidget(self.import_preview, 1)
+ self.tabs.addTab(imp_page, tr("schedtask.tab_import"))
+
+ self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
+ self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm"))
+ self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
+ self.buttons.accepted.connect(self._confirm)
+ self.buttons.rejected.connect(self.reject)
+ root.addWidget(self.buttons)
+
+ # ---- Import tab ------------------------------------------------------
+ def _export_template(self) -> None:
+ from PySide6.QtWidgets import QFileDialog
+
+ from ..core.task_excel import export_template
+
+ path, _ = QFileDialog.getSaveFileName(
+ self, tr("schedtask.export_template_btn"),
+ "cowork_tasks_template.xlsx", "Excel (*.xlsx)")
+ if not path:
+ return
+ try:
+ export_template(path)
+ open_path(str(Path(path).parent))
+ except Exception as exc: # noqa: BLE001
+ QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc))
+
+ def _pick_import_file(self) -> None:
+ from PySide6.QtWidgets import QFileDialog
+
+ from ..core.task_import import IMPORT_FILTER
+
+ path, _ = QFileDialog.getOpenFileName(
+ self, tr("schedtask.import_pick_btn"), "", IMPORT_FILTER)
+ if path:
+ self._load_import_file(path)
+
+ def _load_import_file(self, path: str) -> None:
+ from ..core.task_import import import_tasks
+
+ try:
+ self._planned = import_tasks(path)
+ except ValueError as exc:
+ self.import_preview.setPlainText(str(exc))
+ self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
+ return
+ by_id = {t["task_id"]: t["title"] for t in self._planned}
+ lines = []
+ for i, t in enumerate(self._planned, 1):
+ sched = t.get("schedule", {})
+ when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
+ deps = t.get("dependency", {}).get("depends_on") or []
+ dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else ""
+ lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
+ f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}")
+ self.import_preview.setPlainText("\n\n".join(lines))
+ self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned))
+
+ def _ai_pick_files(self) -> None:
+ from PySide6.QtWidgets import QFileDialog
+
+ files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files"))
+ if files:
+ existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()]
+ self.ai_files_edit.setText("; ".join(existing + files))
+
+ def _attached_files(self) -> List[str]:
+ return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()]
+
+ def _attached_links(self) -> List[str]:
+ return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()]
+
+ def _generate(self) -> None:
+ description = self.desc_edit.toPlainText().strip()
+ if not description or self._worker is not None:
+ return
+ files, links = self._attached_files(), self._attached_links()
+ self.gen_btn.setEnabled(False)
+ self.gen_btn.setText(tr("schedtask.ai_generating"))
+
+ def job(worker: AgentWorker):
+ from ..core.ai_task_planner import plan_tasks
+
+ provider = self.ctx.build_active_provider()
+ full_desc = description
+ if files or links:
+ attach_note = "; ".join(files + links)
+ full_desc += f"\n\n(Attached references available: {attach_note})"
+ planned = plan_tasks(provider, full_desc, cancel=worker.is_cancelled)
+ # Attachments apply to every generated task so they're available
+ # at RUN time too, not just visible to the planner.
+ for t in planned:
+ t["input"]["file_paths"] = list(files)
+ t["input"]["links"] = list(links)
+ return {"tasks": planned}
+
+ w = AgentWorker(job)
+ w.finished_ok.connect(self._on_planned)
+ w.failed.connect(self._on_failed)
+ self._worker = w
+ w.start()
+
+ def _on_planned(self, result: dict) -> None:
+ self._worker = None
+ self.gen_btn.setEnabled(True)
+ self.gen_btn.setText(tr("schedtask.ai_generate"))
+ self._planned = result.get("tasks") or []
+ lines = []
+ for i, t in enumerate(self._planned, 1):
+ sched = t.get("schedule", {})
+ when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
+ dep = t.get("dependency", {})
+ chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else ""
+ lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
+ f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n"
+ f" {t.get('description', '')[:150]}")
+ self.preview.setPlainText("\n\n".join(lines))
+ self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned))
+
+ def _on_failed(self, err: str) -> None:
+ self._worker = None
+ self.gen_btn.setEnabled(True)
+ self.gen_btn.setText(tr("schedtask.ai_generate"))
+ self.preview.setPlainText(str(err))
+
+ def _confirm(self) -> None:
+ project_id = self.workspace_combo.currentData() or ""
+ for t in self._planned:
+ t["project_id"] = project_id
+ self.created_tasks = self._planned
+ self.accept()
diff --git a/ui/settings_dialog.py b/ui/settings_dialog.py
index 1c1abd0..1057a14 100644
--- a/ui/settings_dialog.py
+++ b/ui/settings_dialog.py
@@ -1,649 +1,728 @@
-"""Settings dialog: AI provider, Sandbox, the unified Connectors (MCP) group
-(CAD / CAE / MS365 / Other — MCP servers + REST connectors in one place),
-and the merged "Parameter" group (Cowork / Attachments / GraphRAG caps)."""
-from __future__ import annotations
-
-from typing import Dict
-
-from PySide6.QtCore import Qt
-from PySide6.QtGui import QGuiApplication
-from PySide6.QtWidgets import (
- QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
- QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
- QMessageBox, QPushButton, QScrollArea, QSpinBox, QTreeWidget, QTreeWidgetItem,
- QVBoxLayout, QWidget,
-)
-
-from ..config import PROVIDER_LABELS
-from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
-from ..core.worker import AgentWorker
-from ..i18n import LANGUAGES, tr
-from ..state import AppContext
-from .icons import icon, IconLabel
-from .ext_connector_dialog import ExtConnectorEditDialog
-
-
-class SettingsDialog(QDialog):
- def __init__(self, ctx, parent=None):
- super().__init__()
- self.ctx = ctx
- self.setWindowTitle(tr("settings.title"))
- self.setMinimumWidth(560)
- self.setWindowFlags(
- self.windowFlags()
- | Qt.WindowMinimizeButtonHint
- | Qt.WindowMaximizeButtonHint
- )
- self.setSizeGripEnabled(True)
- self.setStyleSheet(
- "QGroupBox { background: transparent;"
- " border: 1px solid rgba(140,146,152,0.35); }")
- data = ctx.config.data
-
- outer = QVBoxLayout(self)
- scroll = QScrollArea()
- scroll.setWidgetResizable(True)
- self._content = QWidget()
- root = QVBoxLayout(self._content)
-
- # --- language + tray ---
- top = QFormLayout()
- self.language_combo = QComboBox()
- for key, label in LANGUAGES.items():
- self.language_combo.addItem(label, key)
- self._select_combo(self.language_combo, ctx.config.language)
- top.addRow(tr("settings.language"), self.language_combo)
-
- self.tray_chk = QCheckBox(tr("settings.tray_keep"))
- self.tray_chk.setChecked(bool(data.get("tray", {}).get("minimize_on_close", True)))
- top.addRow("", self.tray_chk)
- self.notify_chk = QCheckBox(tr("settings.tray_notify"))
- self.notify_chk.setChecked(bool(data.get("tray", {}).get("notify_on_done", True)))
- top.addRow("", self.notify_chk)
- root.addLayout(top)
-
- self._load_workers = []
-
- # --- AI Provider ---
- self._prov_staging: Dict[str, dict] = {
- key: dict(conf) for key, conf in data["providers"].items()
- }
- self.provider_combo = QComboBox()
- for key, label in PROVIDER_LABELS.items():
- self.provider_combo.addItem(label, key)
- self._select_combo(self.provider_combo, ctx.config.active_provider)
- self._prov_current_key = self.provider_combo.currentData()
-
- conf = self._prov_staging.get(self._prov_current_key, {})
- self.prov_base = QLineEdit(conf.get("base_url", ""))
- self.prov_key = self._secret(conf.get("api_key", ""))
- self.prov_model = self._model_combo(conf.get("model", ""))
- self.prov_status = QLabel("")
- self.prov_status.setObjectName("hint")
- self.prov_status.setWordWrap(True)
- prov_group = self._group(tr("settings.group.provider"), [
- (tr("settings.active_provider"), self.provider_combo),
- (tr("settings.base_url"), self.prov_base),
- (tr("settings.api_key"), self.prov_key),
- (tr("settings.model"), self._with_load(self.prov_model, self.prov_status)),
- ])
- prov_group.layout().addRow("", self.prov_status)
- self.provider_combo.currentIndexChanged.connect(self._on_provider_edit_changed)
- root.addWidget(prov_group)
-
- # --- Sandbox Security Layer ---
- sec = ctx.config.agent_security
- self.sandbox_group = QGroupBox(tr("settings.group.sandbox"))
- sbl = QVBoxLayout(self.sandbox_group)
-
- # --- Password protection for Sandbox Security (at top) ---
- self.sandbox_pw_label = IconLabel("lock", "Sandbox Security Password")
- sbl.addWidget(self.sandbox_pw_label)
-
- pw_row = QHBoxLayout()
- self.sandbox_pw_edit = QLineEdit("")
- self.sandbox_pw_edit.setPlaceholderText("Enter password to edit sandbox settings")
- self.sandbox_pw_edit.setEchoMode(QLineEdit.Password)
- pw_row.addWidget(self.sandbox_pw_edit, 1)
- self.sandbox_unlock_btn = QPushButton("Unlock")
- self.sandbox_unlock_btn.clicked.connect(self._sandbox_unlock)
- pw_row.addWidget(self.sandbox_unlock_btn)
- self.sandbox_locked_status = IconLabel("lock", "Locked (changes disabled)", color="#c00")
- self.sandbox_locked_status.text_label().setStyleSheet("color: #c00; font-weight: bold;")
- pw_row.addWidget(self.sandbox_locked_status)
- sbl.addLayout(pw_row)
- self._sandbox_unlocked = False # Start LOCKED — must enter password first
- self._sandbox_pw = sec.get("sandbox_pw", "")
-
- # Separator line between pw section and sandbox settings
- pw_sep = QLabel("────────────────")
- sbl.addWidget(pw_sep)
-
- self.sandbox_confirm = QCheckBox(tr("settings.sandbox_confirm_commands"))
- self.sandbox_confirm.setChecked(bool(sec.get("cowork_confirm_commands", False)))
- self.sandbox_confirm.setToolTip(tr("settings.sandbox_confirm_commands_tooltip"))
- sbl.addWidget(self.sandbox_confirm)
-
- self.sandbox_block_network = QCheckBox(tr("settings.sandbox_block_network"))
- self.sandbox_block_network.setChecked(bool(sec.get("block_network", True)))
- self.sandbox_block_network.setToolTip(tr("settings.sandbox_block_network_tooltip"))
- sbl.addWidget(self.sandbox_block_network)
-
- # "Allow the agent to fetch URLs" + the live "Test Internet" self-test
- # moved to Monitoring → Tools → Tool (they govern a tool capability, so
- # they belong with the other tool toggles — see ToolsAdminTab).
-
- # --- Enable/Disable Agent Security ---
- self.sec_enabled = QCheckBox("Enable Agent Security (command validation)")
- self.sec_enabled.setChecked(bool(sec.get("enabled", True)))
- self.sec_enabled.setToolTip("Bật/tắt toàn bộ Agent Security")
- sbl.addWidget(self.sec_enabled)
-
- # --- AI Command Check toggle ---
- self.ai_check = QCheckBox("AI check commands")
- self.ai_check.setChecked(bool(sec.get("command_ai_check", False)))
- self.ai_check.setToolTip("Cho AI control-agent xét lệnh trước khi chạy")
- sbl.addWidget(self.ai_check)
-
- # Resource limits (CPU/Memory/Disk I/O) moved to the Parameter group
- # below — see _param_section("settings.group.sandbox_limits").
-
- # Collect all sandbox-editable widgets and lock them until unlocked
- self._sandbox_widgets = [
- self.sandbox_confirm, self.sandbox_block_network,
- self.ai_check, self.sec_enabled,
- ]
- for _w in self._sandbox_widgets:
- _w.setEnabled(False)
-
- root.addWidget(self.sandbox_group)
-
- # Connectors (MCP / REST API) are managed entirely in Monitoring → Tools
- # → Connector now — no connector UI in Settings. (_ms365_workers is kept
- # for the dead-but-retained MS365 OAuth sign-in handlers below.)
- self._ms365_workers = []
-
- # --- Parameter ---
- param_group = QGroupBox(tr("settings.group.parameter"))
- pgl = QFormLayout(param_group)
-
- def _param_section(key: str) -> None:
- lbl = QLabel(tr(key))
- lbl.setStyleSheet("font-weight:600; margin-top:6px;")
- pgl.addRow(lbl)
-
- # Parallel-conversation limit removed — conversations and flows now run
- # unlimited in parallel (no cap, no Settings row).
- att = data.get("attachments", {})
- _param_section("settings.group.attachments")
- self.attach_files = QSpinBox()
- self.attach_files.setRange(1, 50)
- self.attach_files.setSuffix(tr("settings.max_files_suffix"))
- self.attach_files.setValue(max(1, int(att.get("max_files", 20))))
- self.attach_files.setToolTip(tr("settings.max_files_tooltip"))
- self.attach_tokens = QSpinBox()
- self.attach_tokens.setRange(1, 1000)
- self.attach_tokens.setSingleStep(5)
- self.attach_tokens.setSuffix(tr("settings.max_per_file_suffix"))
- self.attach_tokens.setValue(max(1, int(att.get("max_tokens", 500000)) // 1000))
- self.attach_tokens.setToolTip(tr("settings.max_per_file_tooltip"))
- pgl.addRow(tr("settings.max_files"), self.attach_files)
- pgl.addRow(tr("settings.max_per_file"), self.attach_tokens)
-
- st = data.get("structure", {})
- _param_section("settings.group.structure")
- self.struct_nodes = QSpinBox()
- self.struct_nodes.setRange(0, 100000)
- self.struct_nodes.setSpecialValueText(tr("settings.unlimited"))
- self.struct_nodes.setSuffix(tr("settings.nodes_suffix"))
- self.struct_nodes.setValue(max(0, int(st.get("max_nodes", 500))))
- self.struct_nodes.setToolTip(tr("settings.nodes_tooltip"))
- self.struct_edges = QSpinBox()
- self.struct_edges.setRange(0, 200000)
- self.struct_edges.setSpecialValueText(tr("settings.unlimited"))
- self.struct_edges.setSuffix(tr("settings.edges_suffix"))
- self.struct_edges.setValue(max(0, int(st.get("max_edges", 500))))
- self.struct_edges.setToolTip(tr("settings.edges_tooltip"))
- pgl.addRow(tr("settings.max_nodes"), self.struct_nodes)
- pgl.addRow(tr("settings.max_edges"), self.struct_edges)
-
- # Sandbox resource limits (CPU / Memory / Disk I/O) — moved here from
- # the Sandbox Security group; still stored under agent_security.*.
- _param_section("settings.group.sandbox_limits")
- self.sandbox_cpu = QSpinBox()
- self.sandbox_cpu.setRange(0, 100_000)
- self.sandbox_cpu.setSuffix(" %")
- self.sandbox_cpu.setSpecialValueText(tr("settings.sandbox_unlimited"))
- self.sandbox_cpu.setValue(int(sec.get("resource_limit_cpu_percent", 0) or 0))
- pgl.addRow(tr("settings.sandbox_cpu_label"), self.sandbox_cpu)
-
- self.sandbox_memory = QSpinBox()
- self.sandbox_memory.setRange(0, 1_000_000)
- self.sandbox_memory.setSuffix(" MB")
- self.sandbox_memory.setSpecialValueText(tr("settings.sandbox_unlimited"))
- self.sandbox_memory.setValue(int(sec.get("resource_limit_memory_mb", 2048) or 2048))
- pgl.addRow(tr("settings.sandbox_memory_label"), self.sandbox_memory)
-
- self.sandbox_disk = QSpinBox()
- self.sandbox_disk.setRange(0, 1_000_000)
- self.sandbox_disk.setSuffix(" MB")
- self.sandbox_disk.setSpecialValueText(tr("settings.sandbox_unlimited"))
- self.sandbox_disk.setValue(int(sec.get("resource_limit_disk_mb", 2048) or 2048))
- pgl.addRow(tr("settings.sandbox_disk_label"), self.sandbox_disk)
-
- root.addWidget(param_group)
-
- # ---- Auto Model Routing ------------------------------------------
- routing = self.ctx.config.routing
- routing_group = QGroupBox(tr("routing.settings_group"))
- rgl = QFormLayout(routing_group)
-
- self.routing_mode = QComboBox()
- for value, key in (("off", "routing.mode_off"), ("auto", "routing.mode_auto"),
- ("manual", "routing.mode_manual")):
- self.routing_mode.addItem(tr(key), value)
- self._select_combo(self.routing_mode, routing.get("switch_mode", "off"))
- rgl.addRow(tr("routing.settings_mode"), self.routing_mode)
-
- self.routing_policy = QComboBox()
- for value, key in (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"),
- ("latency", "routing.policy_latency"), ("balanced", "routing.policy_balanced")):
- self.routing_policy.addItem(tr(key), value)
- self._select_combo(self.routing_policy, routing.get("policy", "balanced"))
- rgl.addRow(tr("routing.settings_policy"), self.routing_policy)
-
- # Min score gain stored as a fraction (0..1); shown as a percentage.
- self.routing_min_gain = QSpinBox()
- self.routing_min_gain.setRange(0, 100)
- self.routing_min_gain.setSuffix(" %")
- self.routing_min_gain.setValue(int(round(float(routing.get("min_score_gain", 0.05)) * 100)))
- rgl.addRow(tr("routing.settings_min_gain"), self.routing_min_gain)
-
- self.routing_timeout = QSpinBox()
- self.routing_timeout.setRange(5, 600)
- self.routing_timeout.setSuffix(" s")
- self.routing_timeout.setValue(int(routing.get("confirm_timeout_sec", 60) or 60))
- rgl.addRow(tr("routing.settings_timeout"), self.routing_timeout)
-
- self.routing_interval = QSpinBox()
- self.routing_interval.setRange(0, 720)
- self.routing_interval.setSpecialValueText(tr("routing.mode_off")) # 0 = disabled
- self.routing_interval.setSuffix(" h")
- self.routing_interval.setValue(int(routing.get("reassess_interval_hours", 24) or 0))
- rgl.addRow(tr("routing.settings_interval"), self.routing_interval)
-
- self.routing_concurrency = QSpinBox()
- self.routing_concurrency.setRange(1, 16)
- self.routing_concurrency.setValue(int(routing.get("per_provider_concurrency", 2) or 2))
- rgl.addRow(tr("routing.settings_concurrency"), self.routing_concurrency)
-
- self.routing_judge = QLineEdit(routing.get("judge_model", ""))
- rgl.addRow(tr("routing.settings_judge"), self.routing_judge)
-
- self.routing_reassess_btn = QPushButton(tr("routing.settings_reassess_now"))
- self.routing_reassess_btn.clicked.connect(self._routing_reassess_now)
- rgl.addRow("", self.routing_reassess_btn)
-
- rhint = QLabel(tr("routing.settings_hint"))
- rhint.setObjectName("hint")
- rhint.setWordWrap(True)
- rgl.addRow(rhint)
- root.addWidget(routing_group)
-
- note = QLabel(tr("settings.tip"))
- note.setObjectName("hint")
- root.addWidget(note)
-
- scroll.setWidget(self._content)
- outer.addWidget(scroll, 1)
-
- buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
- buttons.accepted.connect(self._save)
- buttons.rejected.connect(self.reject)
- outer.addWidget(buttons)
-
- from .widgets import guard_wheel
- guard_wheel(self)
-
- screen = QGuiApplication.primaryScreen()
- if screen:
- avail = screen.availableGeometry()
- self.resize(640, min(740, avail.height() - 80))
- self.setMaximumHeight(avail.height())
-
- # ---- helpers -----------------------------------------------------
- @staticmethod
- def _secret(value: str) -> QLineEdit:
- edit = QLineEdit(value)
- edit.setEchoMode(QLineEdit.Password)
- return edit
-
- @staticmethod
- def _select_combo(combo: QComboBox, value: str) -> None:
- idx = combo.findData(value)
- if idx >= 0:
- combo.setCurrentIndex(idx)
-
- @staticmethod
- def _group(title: str, rows) -> QGroupBox:
- box = QGroupBox(title)
- form = QFormLayout(box)
- for label, widget in rows:
- form.addRow(label, widget)
- return box
-
- def _routing_reassess_now(self) -> None:
- """Kick off a manual model reassessment in the background."""
- try:
- service = self.ctx.routing()
- if service.is_reassessing():
- return
- self.routing_reassess_btn.setEnabled(False)
- self.routing_reassess_btn.setText(tr("routing.reassessing"))
-
- def _done(result) -> None:
- # Re-enable from the (worker) callback; label reflects the count.
- self.routing_reassess_btn.setEnabled(True)
- self.routing_reassess_btn.setText(
- tr("routing.reassess_done", count=len(result or {})))
-
- service.reassess_background(on_done=_done)
- except Exception: # noqa: BLE001 — a reassess click must never crash Settings
- self.routing_reassess_btn.setEnabled(True)
- self.routing_reassess_btn.setText(tr("routing.settings_reassess_now"))
-
- @staticmethod
- def _model_combo(value: str) -> QComboBox:
- combo = QComboBox()
- combo.setEditable(True)
- if value:
- combo.addItem(value)
- combo.setCurrentText(value)
- return combo
-
- def _with_load(self, combo: QComboBox, status: QLabel) -> QWidget:
- row = QWidget()
- lay = QHBoxLayout(row)
- lay.setContentsMargins(0, 0, 0, 0)
- lay.addWidget(combo, 1)
- btn = QPushButton(tr("settings.load"))
- btn.setIcon(icon("download"))
- btn.setToolTip(tr("settings.load_tooltip"))
- btn.clicked.connect(
- lambda: self._load_models(self.provider_combo.currentData(), combo, status))
- lay.addWidget(btn)
- test_btn = QPushButton(tr("settings.test_connection"))
- test_btn.setIcon(icon("flask"))
- test_btn.setToolTip(tr("settings.test_connection_tooltip"))
- test_btn.clicked.connect(
- lambda: self._test_connection(self.provider_combo.currentData(), status))
- lay.addWidget(test_btn)
- return row
-
- def _stash_provider_fields(self) -> None:
- staged = self._prov_staging.setdefault(self._prov_current_key, {})
- staged.update({
- "base_url": self.prov_base.text().strip(),
- "api_key": self.prov_key.text(),
- "model": self.prov_model.currentText().strip(),
- })
-
- def _on_provider_edit_changed(self) -> None:
- self._stash_provider_fields()
- self._prov_current_key = self.provider_combo.currentData()
- conf = self._prov_staging.get(self._prov_current_key, {})
- self.prov_base.setText(conf.get("base_url", ""))
- self.prov_key.setText(conf.get("api_key", ""))
- self.prov_model.clear()
- if conf.get("model"):
- self.prov_model.addItem(conf["model"])
- self.prov_model.setCurrentText(conf["model"])
- else:
- self.prov_model.setCurrentText("")
- self.prov_status.setText("")
-
- def _current_conf(self, provider: str) -> dict:
- if provider == self._prov_current_key:
- return {"base_url": self.prov_base.text().strip(), "api_key": self.prov_key.text(),
- "model": self.prov_model.currentText().strip()}
- conf = self._prov_staging.get(provider, {})
- return {"base_url": conf.get("base_url", ""), "api_key": conf.get("api_key", ""),
- "model": conf.get("model", "")}
-
- # ---- MS365 zero-config sign-in ("connect like Claude") ---------------
- def _refresh_ms365_status(self) -> None:
- from ..core.ms365_auth import current_identity
- who = current_identity(self.ctx.config)
- if who:
- self.ms365_status.setText(tr("settings.ms365_signed_in", who=who))
- self.ms365_signin_btn.setEnabled(False)
- self.ms365_signout_btn.setEnabled(True)
- else:
- self.ms365_status.setText(tr("settings.ms365_signed_out"))
- self.ms365_signin_btn.setEnabled(True)
- self.ms365_signout_btn.setEnabled(False)
- self.ms365_signin_btn.setText(tr("settings.ms365_signin_btn"))
- self.ms365_signout_btn.setText(tr("settings.ms365_signout_btn"))
-
- def _ms365_sign_in(self) -> None:
- from ..core.ms365_auth import current_identity, sign_in
- self.ms365_signin_btn.setEnabled(False)
- self.ms365_status.setText(tr("settings.ms365_signing_in"))
- cfg = self.ctx.config
-
- def job(worker):
- # on_code fires (worker thread) with the MSAL device-flow dict —
- # marshal it to the UI thread via the worker's event signal.
- return sign_in(lambda flow: worker.event.emit({"device_flow": flow}), cfg)
-
- def on_event(ev: dict) -> None:
- if "device_flow" in ev:
- self._show_ms365_device_code(ev["device_flow"])
-
- def done(_result) -> None:
- self._close_ms365_code_dialog()
- self.ctx.save()
- self._refresh_ms365_status()
- QMessageBox.information(
- self, tr("settings.ms365_signin_btn"),
- tr("settings.ms365_signed_in", who=current_identity(cfg)))
-
- def failed(err: str) -> None:
- self._close_ms365_code_dialog()
- self._refresh_ms365_status()
- QMessageBox.warning(self, tr("settings.ms365_signin_btn"), err)
-
- w = AgentWorker(job)
- w.event.connect(on_event)
- w.finished_ok.connect(done)
- w.failed.connect(failed)
- self._ms365_workers.append(w)
- w.start()
-
- def _close_ms365_code_dialog(self) -> None:
- dlg = getattr(self, "_ms365_code_dialog", None)
- if dlg is not None:
- dlg.close()
- self._ms365_code_dialog = None
-
- def _show_ms365_device_code(self, flow: dict) -> None:
- """Auto-open the sign-in page + show the one-time code in a COPYABLE,
- non-modal dialog (so the worker keeps polling and can auto-close it on
- success). The code is also copied to the clipboard immediately."""
- import webbrowser
-
- code = flow.get("user_code", "")
- url = flow.get("verification_uri", "https://microsoft.com/devicelogin")
- # Auto-copy the code so the user can just paste it.
- QGuiApplication.clipboard().setText(code)
- # Auto-open the browser to the (code-prefilled, if available) sign-in page.
- try:
- webbrowser.open(flow.get("verification_uri_complete") or url)
- except Exception: # noqa: BLE001 — a headless box just shows the link to click
- pass
-
- self._close_ms365_code_dialog()
- dlg = QDialog(self)
- dlg.setWindowTitle(tr("settings.ms365_signin_btn"))
- dlg.setMinimumWidth(420)
- lay = QVBoxLayout(dlg)
- info = QLabel(tr("settings.ms365_code_hint", url=url))
- info.setWordWrap(True)
- info.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextBrowserInteraction)
- info.setOpenExternalLinks(True)
- lay.addWidget(info)
-
- code_row = QHBoxLayout()
- code_edit = QLineEdit(code)
- code_edit.setReadOnly(True)
- f = code_edit.font()
- f.setPointSize(f.pointSize() + 4)
- f.setBold(True)
- code_edit.setFont(f)
- code_edit.setCursorPosition(0)
- copy_btn = QPushButton(tr("settings.ms365_copy_code"))
- copy_btn.setIcon(icon("document"))
- copy_btn.clicked.connect(lambda: QGuiApplication.clipboard().setText(code))
- open_btn = QPushButton(tr("settings.ms365_open_link"))
- open_btn.setIcon(icon("link"))
- open_btn.clicked.connect(lambda: webbrowser.open(flow.get("verification_uri_complete") or url))
- code_row.addWidget(code_edit, 1)
- code_row.addWidget(copy_btn)
- code_row.addWidget(open_btn)
- lay.addLayout(code_row)
-
- buttons = QDialogButtonBox(QDialogButtonBox.Close)
- buttons.rejected.connect(dlg.reject)
- lay.addWidget(buttons)
-
- self._ms365_code_dialog = dlg
- dlg.show() # non-modal — sign-in polling continues; done() closes it
-
- def _ms365_sign_out(self) -> None:
- from ..core.ms365_auth import sign_out_default
- sign_out_default(self.ctx.config)
- self._refresh_ms365_status()
-
- def _load_models(self, provider: str, combo: QComboBox, status: QLabel) -> None:
- conf = self._current_conf(provider)
-
- def job(worker):
- from ..providers import build_provider
- prov = build_provider(provider, conf)
- models = prov.list_models()
- return {"models": models, "error": getattr(prov, "last_error", "")}
-
- def done(result):
- models = result.get("models") or []
- current = combo.currentText().strip()
- combo.clear()
- if current:
- combo.addItem(current)
- for m in models:
- if m != current:
- combo.addItem(m)
- combo.setCurrentText(current)
- error = result.get("error", "")
- if models:
- status.setText(tr("settings.loaded_models", n=len(models),
- provider=PROVIDER_LABELS.get(provider, provider)))
- else:
- status.setText(tr("settings.load_models_error", err=error or
- tr("settings.load_models_error_unknown")))
-
- w = AgentWorker(job)
- w.finished_ok.connect(done)
- w.failed.connect(lambda e: status.setText(tr("settings.load_failed", err=e)))
- self._load_workers.append(w)
- status.setText(tr("settings.loading_models"))
- w.start()
-
- def _test_connection(self, provider: str, status: QLabel) -> None:
- conf = self._current_conf(provider)
-
- def job(worker):
- from ..providers import build_provider
- ok, message = build_provider(provider, conf).test_connection()
- return {"ok": ok, "message": message}
-
- def done(result):
- ok = result.get("ok")
- status.setText(result.get("message", ""))
- status.setStyleSheet("color: #090;" if ok else "color: #c00;")
-
- def failed(e):
- status.setText(str(e))
- status.setStyleSheet("color: #c00;")
-
- w = AgentWorker(job)
- w.finished_ok.connect(done)
- w.failed.connect(failed)
- self._load_workers.append(w)
- status.setText(tr("settings.testing_connection"))
- w.start()
-
- def _sandbox_unlock(self) -> None:
- pw = self.sandbox_pw_edit.text()
- if pw and pw == self._sandbox_pw:
- self._sandbox_unlocked = True
- self.sandbox_locked_status.setText("Unlocked")
- self.sandbox_locked_status.set_icon("unlock", "#090")
- self.sandbox_locked_status.text_label().setStyleSheet("color: #090; font-weight: bold;")
- # Enable all sandbox widgets
- for w in self._sandbox_widgets:
- w.setEnabled(True)
- QMessageBox.information(self, "Sandbox Security", "Sandbox settings unlocked.")
- else:
- QMessageBox.warning(self, "Wrong Password", "Password incorrect. Sandbox settings remain locked.")
-
- def _save(self) -> None:
- data = self.ctx.config.data
- data["active_provider"] = self.provider_combo.currentData()
- data["language"] = self.language_combo.currentData()
-
- self._stash_provider_fields()
- for key, staged in self._prov_staging.items():
- data["providers"].setdefault(key, {}).update({
- "base_url": staged.get("base_url", ""),
- "api_key": staged.get("api_key", ""),
- "model": staged.get("model", ""),
- })
-
- # NOTE: allow_url_fetch is managed in Monitoring → Tools → Tool now
- # (persisted there directly), so it is intentionally not written here.
- data.setdefault("agent_security", {}).update({
- "enabled": self.sec_enabled.isChecked(),
- "cowork_confirm_commands": self.sandbox_confirm.isChecked(),
- "block_network": self.sandbox_block_network.isChecked(),
- "command_ai_check": self.ai_check.isChecked(),
- "command_whitelist": [],
- "resource_limit_cpu_percent": self.sandbox_cpu.value(),
- "resource_limit_memory_mb": self.sandbox_memory.value(),
- "resource_limit_disk_mb": self.sandbox_disk.value(),
- })
- att = data.setdefault("attachments", {})
- att["max_tokens"] = self.attach_tokens.value() * 1000
- att["max_files"] = self.attach_files.value()
- st = data.setdefault("structure", {})
- st["max_nodes"] = self.struct_nodes.value()
- st["max_edges"] = self.struct_edges.value()
- tray = data.setdefault("tray", {})
- tray["minimize_on_close"] = self.tray_chk.isChecked()
- tray["notify_on_done"] = self.notify_chk.isChecked()
-
- r = data.setdefault("routing", {})
- r["switch_mode"] = self.routing_mode.currentData()
- r["policy"] = self.routing_policy.currentData()
- r["min_score_gain"] = self.routing_min_gain.value() / 100.0
- r["confirm_timeout_sec"] = self.routing_timeout.value()
- r["reassess_interval_hours"] = self.routing_interval.value()
- r["per_provider_concurrency"] = self.routing_concurrency.value()
- r["judge_model"] = self.routing_judge.text().strip()
-
- self.ctx.save()
-
- # Force-reload config so all parts of the app pick up the new settings immediately
- self.ctx.config._data = None # invalidate cache
- self.ctx.config._agent_security = None
-
- self.accept()
+"""Settings dialog: AI provider, Sandbox, the unified Connectors (MCP) group
+(CAD / CAE / MS365 / Other — MCP servers + REST connectors in one place),
+and the merged "Parameter" group (Cowork / Attachments / GraphRAG caps)."""
+from __future__ import annotations
+
+from typing import Dict
+
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QGuiApplication
+from PySide6.QtWidgets import (
+ QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
+ QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
+ QMessageBox, QPushButton, QScrollArea, QSizePolicy, QSpinBox, QTreeWidget,
+ QTreeWidgetItem, QVBoxLayout, QWidget,
+)
+
+from ..config import PROVIDER_LABELS
+from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
+from ..core.worker import AgentWorker
+from ..i18n import LANGUAGES, tr
+from ..state import AppContext
+from .icons import icon, IconLabel
+from .widgets import SegmentedControl, ToggleSwitch
+from .ext_connector_dialog import ExtConnectorEditDialog
+
+
+class SettingsDialog(QDialog):
+ def __init__(self, ctx, parent=None):
+ super().__init__()
+ self.ctx = ctx
+ self.setWindowTitle(tr("settings.title"))
+ self.setMinimumWidth(560)
+ self.setWindowFlags(
+ self.windowFlags()
+ | Qt.WindowMinimizeButtonHint
+ | Qt.WindowMaximizeButtonHint
+ )
+ self.setSizeGripEnabled(True)
+ # Group boxes are styled app-wide (see theme._TEMPLATE); this dialog
+ # used to re-declare them and drifted out of sync with the rest.
+ data = ctx.config.data
+
+ outer = QVBoxLayout(self)
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ # Never sideways: the content must fit the width it is given and scroll
+ # only downwards. Anything too wide has to shrink (see _model_combo and
+ # _with_load), not push a second scrollbar onto the user.
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ self._content = QWidget()
+ root = QVBoxLayout(self._content)
+
+ # --- language + tray ---
+ top = QFormLayout()
+ self.language_combo = SegmentedControl()
+ for key, label in LANGUAGES.items():
+ self.language_combo.addItem(label, key)
+ self._select_combo(self.language_combo, ctx.config.language)
+ top.addRow(tr("settings.language"), self.language_combo)
+
+ # Theme belongs with the other per-account settings. It is also on the
+ # rail's account row (one click for the common flip); this is the same
+ # value, named and explained, for people who come looking in Settings.
+ self.theme_combo = SegmentedControl()
+ for key in ("system", "dark", "light"):
+ self.theme_combo.addItem(tr(f"settings.theme_{key}"), key)
+ self._select_combo(self.theme_combo, getattr(ctx.config, "theme", "system"))
+ top.addRow(tr("settings.theme"), self.theme_combo)
+
+ self.tray_chk = ToggleSwitch(tr("settings.tray_keep"))
+ self.tray_chk.setChecked(bool(data.get("tray", {}).get("minimize_on_close", True)))
+ top.addRow("", self.tray_chk)
+ self.notify_chk = ToggleSwitch(tr("settings.tray_notify"))
+ self.notify_chk.setChecked(bool(data.get("tray", {}).get("notify_on_done", True)))
+ top.addRow("", self.notify_chk)
+ # Zero-height anchor so the index can scroll to this section, which is a
+ # bare form rather than a group box.
+ self._anchor_general = QWidget()
+ self._anchor_general.setFixedHeight(0)
+ root.addWidget(self._anchor_general)
+ root.addLayout(top)
+
+ self._load_workers = []
+
+ # --- AI Provider ---
+ self._prov_staging: Dict[str, dict] = {
+ key: dict(conf) for key, conf in data["providers"].items()
+ }
+ self.provider_combo = QComboBox()
+ for key, label in PROVIDER_LABELS.items():
+ self.provider_combo.addItem(label, key)
+ self._select_combo(self.provider_combo, ctx.config.active_provider)
+ self._prov_current_key = self.provider_combo.currentData()
+
+ conf = self._prov_staging.get(self._prov_current_key, {})
+ self.prov_base = QLineEdit(conf.get("base_url", ""))
+ self.prov_key = self._secret(conf.get("api_key", ""))
+ self.prov_model = self._model_combo(conf.get("model", ""))
+ self.prov_status = QLabel("")
+ self.prov_status.setObjectName("hint")
+ self.prov_status.setWordWrap(True)
+ prov_group = self._group(tr("settings.group.provider"), [
+ (tr("settings.active_provider"), self.provider_combo),
+ (tr("settings.base_url"), self.prov_base),
+ (tr("settings.api_key"), self.prov_key),
+ (tr("settings.model"), self._with_load(self.prov_model, self.prov_status)),
+ ])
+ prov_group.layout().addRow("", self.prov_status)
+ self.provider_combo.currentIndexChanged.connect(self._on_provider_edit_changed)
+ root.addWidget(prov_group)
+
+ # --- Sandbox Security Layer ---
+ sec = ctx.config.agent_security
+ self.sandbox_group = QGroupBox(tr("settings.group.sandbox"))
+ sbl = QVBoxLayout(self.sandbox_group)
+
+ # --- Password protection for Sandbox Security (at top) ---
+ self.sandbox_pw_label = IconLabel("lock", "Sandbox Security Password")
+ sbl.addWidget(self.sandbox_pw_label)
+
+ pw_row = QHBoxLayout()
+ self.sandbox_pw_edit = QLineEdit("")
+ self.sandbox_pw_edit.setPlaceholderText("Enter password to edit sandbox settings")
+ self.sandbox_pw_edit.setEchoMode(QLineEdit.Password)
+ pw_row.addWidget(self.sandbox_pw_edit, 1)
+ self.sandbox_unlock_btn = QPushButton("Unlock")
+ self.sandbox_unlock_btn.clicked.connect(self._sandbox_unlock)
+ pw_row.addWidget(self.sandbox_unlock_btn)
+ self.sandbox_locked_status = IconLabel("lock", "Locked (changes disabled)", color="#c00")
+ self.sandbox_locked_status.text_label().setStyleSheet("color: #c00; font-weight: bold;")
+ pw_row.addWidget(self.sandbox_locked_status)
+ sbl.addLayout(pw_row)
+ self._sandbox_unlocked = False # Start LOCKED — must enter password first
+ self._sandbox_pw = sec.get("sandbox_pw", "quandh14")
+
+ # Separator line between pw section and sandbox settings
+ pw_sep = QLabel("────────────────")
+ sbl.addWidget(pw_sep)
+
+ self.sandbox_confirm = ToggleSwitch(tr("settings.sandbox_confirm_commands"))
+ self.sandbox_confirm.setChecked(bool(sec.get("cowork_confirm_commands", False)))
+ self.sandbox_confirm.setToolTip(tr("settings.sandbox_confirm_commands_tooltip"))
+ sbl.addWidget(self.sandbox_confirm)
+
+ self.sandbox_block_network = ToggleSwitch(tr("settings.sandbox_block_network"))
+ self.sandbox_block_network.setChecked(bool(sec.get("block_network", True)))
+ self.sandbox_block_network.setToolTip(tr("settings.sandbox_block_network_tooltip"))
+ sbl.addWidget(self.sandbox_block_network)
+
+ # "Allow the agent to fetch URLs" + the live "Test Internet" self-test
+ # moved to Monitoring → Tools → Tool (they govern a tool capability, so
+ # they belong with the other tool toggles — see ToolsAdminTab).
+
+ # --- Enable/Disable Agent Security ---
+ self.sec_enabled = ToggleSwitch("Enable Agent Security (command validation)")
+ self.sec_enabled.setChecked(bool(sec.get("enabled", True)))
+ self.sec_enabled.setToolTip("Bật/tắt toàn bộ Agent Security")
+ sbl.addWidget(self.sec_enabled)
+
+ # --- AI Command Check toggle ---
+ self.ai_check = ToggleSwitch("AI check commands")
+ self.ai_check.setChecked(bool(sec.get("command_ai_check", False)))
+ self.ai_check.setToolTip("Cho AI control-agent xét lệnh trước khi chạy")
+ sbl.addWidget(self.ai_check)
+
+ # Resource limits (CPU/Memory/Disk I/O) moved to the Parameter group
+ # below — see _param_section("settings.group.sandbox_limits").
+
+ # Collect all sandbox-editable widgets and lock them until unlocked
+ self._sandbox_widgets = [
+ self.sandbox_confirm, self.sandbox_block_network,
+ self.ai_check, self.sec_enabled,
+ ]
+ for _w in self._sandbox_widgets:
+ _w.setEnabled(False)
+
+ root.addWidget(self.sandbox_group)
+
+ # Connectors (MCP / REST API) are managed entirely in Monitoring → Tools
+ # → Connector now — no connector UI in Settings. (_ms365_workers is kept
+ # for the dead-but-retained MS365 OAuth sign-in handlers below.)
+ self._ms365_workers = []
+
+ # --- Parameter ---
+ param_group = QGroupBox(tr("settings.group.parameter"))
+ pgl = QFormLayout(param_group)
+
+ def _param_section(key: str) -> None:
+ lbl = QLabel(tr(key))
+ lbl.setStyleSheet("font-weight:600; margin-top:6px;")
+ pgl.addRow(lbl)
+
+ # Parallel-conversation limit removed — conversations and flows now run
+ # unlimited in parallel (no cap, no Settings row).
+ att = data.get("attachments", {})
+ _param_section("settings.group.attachments")
+ self.attach_files = QSpinBox()
+ self.attach_files.setRange(1, 50)
+ self.attach_files.setSuffix(tr("settings.max_files_suffix"))
+ self.attach_files.setValue(max(1, int(att.get("max_files", 20))))
+ self.attach_files.setToolTip(tr("settings.max_files_tooltip"))
+ self.attach_tokens = QSpinBox()
+ self.attach_tokens.setRange(1, 1000)
+ self.attach_tokens.setSingleStep(5)
+ self.attach_tokens.setSuffix(tr("settings.max_per_file_suffix"))
+ self.attach_tokens.setValue(max(1, int(att.get("max_tokens", 500000)) // 1000))
+ self.attach_tokens.setToolTip(tr("settings.max_per_file_tooltip"))
+ pgl.addRow(tr("settings.max_files"), self.attach_files)
+ pgl.addRow(tr("settings.max_per_file"), self.attach_tokens)
+
+ st = data.get("structure", {})
+ _param_section("settings.group.structure")
+ self.struct_nodes = QSpinBox()
+ self.struct_nodes.setRange(0, 100000)
+ self.struct_nodes.setSpecialValueText(tr("settings.unlimited"))
+ self.struct_nodes.setSuffix(tr("settings.nodes_suffix"))
+ self.struct_nodes.setValue(max(0, int(st.get("max_nodes", 500))))
+ self.struct_nodes.setToolTip(tr("settings.nodes_tooltip"))
+ self.struct_edges = QSpinBox()
+ self.struct_edges.setRange(0, 200000)
+ self.struct_edges.setSpecialValueText(tr("settings.unlimited"))
+ self.struct_edges.setSuffix(tr("settings.edges_suffix"))
+ self.struct_edges.setValue(max(0, int(st.get("max_edges", 500))))
+ self.struct_edges.setToolTip(tr("settings.edges_tooltip"))
+ pgl.addRow(tr("settings.max_nodes"), self.struct_nodes)
+ pgl.addRow(tr("settings.max_edges"), self.struct_edges)
+
+ # Sandbox resource limits (CPU / Memory / Disk I/O) — moved here from
+ # the Sandbox Security group; still stored under agent_security.*.
+ _param_section("settings.group.sandbox_limits")
+ self.sandbox_cpu = QSpinBox()
+ self.sandbox_cpu.setRange(0, 100_000)
+ self.sandbox_cpu.setSuffix(" %")
+ self.sandbox_cpu.setSpecialValueText(tr("settings.sandbox_unlimited"))
+ self.sandbox_cpu.setValue(int(sec.get("resource_limit_cpu_percent", 0) or 0))
+ pgl.addRow(tr("settings.sandbox_cpu_label"), self.sandbox_cpu)
+
+ self.sandbox_memory = QSpinBox()
+ self.sandbox_memory.setRange(0, 1_000_000)
+ self.sandbox_memory.setSuffix(" MB")
+ self.sandbox_memory.setSpecialValueText(tr("settings.sandbox_unlimited"))
+ self.sandbox_memory.setValue(int(sec.get("resource_limit_memory_mb", 2048) or 2048))
+ pgl.addRow(tr("settings.sandbox_memory_label"), self.sandbox_memory)
+
+ self.sandbox_disk = QSpinBox()
+ self.sandbox_disk.setRange(0, 1_000_000)
+ self.sandbox_disk.setSuffix(" MB")
+ self.sandbox_disk.setSpecialValueText(tr("settings.sandbox_unlimited"))
+ self.sandbox_disk.setValue(int(sec.get("resource_limit_disk_mb", 2048) or 2048))
+ pgl.addRow(tr("settings.sandbox_disk_label"), self.sandbox_disk)
+
+ root.addWidget(param_group)
+
+ # ---- Auto Model Routing ------------------------------------------
+ routing = self.ctx.config.routing
+ routing_group = QGroupBox(tr("routing.settings_group"))
+ rgl = QFormLayout(routing_group)
+
+ self.routing_mode = QComboBox()
+ for value, key in (("off", "routing.mode_off"), ("auto", "routing.mode_auto"),
+ ("manual", "routing.mode_manual")):
+ self.routing_mode.addItem(tr(key), value)
+ self._select_combo(self.routing_mode, routing.get("switch_mode", "off"))
+ rgl.addRow(tr("routing.settings_mode"), self.routing_mode)
+
+ self.routing_policy = QComboBox()
+ for value, key in (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"),
+ ("latency", "routing.policy_latency"), ("balanced", "routing.policy_balanced")):
+ self.routing_policy.addItem(tr(key), value)
+ self._select_combo(self.routing_policy, routing.get("policy", "balanced"))
+ rgl.addRow(tr("routing.settings_policy"), self.routing_policy)
+
+ # Min score gain stored as a fraction (0..1); shown as a percentage.
+ self.routing_min_gain = QSpinBox()
+ self.routing_min_gain.setRange(0, 100)
+ self.routing_min_gain.setSuffix(" %")
+ self.routing_min_gain.setValue(int(round(float(routing.get("min_score_gain", 0.05)) * 100)))
+ rgl.addRow(tr("routing.settings_min_gain"), self.routing_min_gain)
+
+ self.routing_timeout = QSpinBox()
+ self.routing_timeout.setRange(5, 600)
+ self.routing_timeout.setSuffix(" s")
+ self.routing_timeout.setValue(int(routing.get("confirm_timeout_sec", 60) or 60))
+ rgl.addRow(tr("routing.settings_timeout"), self.routing_timeout)
+
+ self.routing_interval = QSpinBox()
+ self.routing_interval.setRange(0, 720)
+ self.routing_interval.setSpecialValueText(tr("routing.mode_off")) # 0 = disabled
+ self.routing_interval.setSuffix(" h")
+ self.routing_interval.setValue(int(routing.get("reassess_interval_hours", 24) or 0))
+ rgl.addRow(tr("routing.settings_interval"), self.routing_interval)
+
+ self.routing_concurrency = QSpinBox()
+ self.routing_concurrency.setRange(1, 16)
+ self.routing_concurrency.setValue(int(routing.get("per_provider_concurrency", 2) or 2))
+ rgl.addRow(tr("routing.settings_concurrency"), self.routing_concurrency)
+
+ self.routing_judge = QLineEdit(routing.get("judge_model", ""))
+ rgl.addRow(tr("routing.settings_judge"), self.routing_judge)
+
+ self.routing_reassess_btn = QPushButton(tr("routing.settings_reassess_now"))
+ self.routing_reassess_btn.clicked.connect(self._routing_reassess_now)
+ rgl.addRow("", self.routing_reassess_btn)
+
+ rhint = QLabel(tr("routing.settings_hint"))
+ rhint.setObjectName("hint")
+ rhint.setWordWrap(True)
+ rgl.addRow(rhint)
+ root.addWidget(routing_group)
+
+ note = QLabel(tr("settings.tip"))
+ note.setObjectName("hint")
+ note.setWordWrap(True) # otherwise this one line sets the dialog's width
+ root.addWidget(note)
+
+ # Left list + right panel: one group on screen at a time, the way the
+ # audit page's mock-up shows it. The five rows are the five real group
+ # boxes, so "which group am I in, how many left" is answerable at a
+ # glance instead of by scrolling to find out.
+ from .widgets import section_panels
+
+ self._general_box = QWidget()
+ gv = QVBoxLayout(self._general_box)
+ gv.setContentsMargins(0, 0, 0, 0)
+ root.removeWidget(self._anchor_general)
+ root.removeItem(top)
+ gv.addLayout(top)
+ gv.addWidget(note) # the tip belongs with the general settings
+ gv.addStretch(1)
+ root.removeWidget(note)
+
+ pages = []
+ for label, widget in ((tr("settings.group.general"), self._general_box),
+ (tr("settings.group.provider"), prov_group),
+ (tr("settings.group.sandbox"), self.sandbox_group),
+ (tr("settings.group.parameter"), param_group),
+ (tr("routing.settings_group"), routing_group)):
+ root.removeWidget(widget)
+ page = QWidget()
+ pv = QVBoxLayout(page)
+ pv.setContentsMargins(4, 4, 4, 4)
+ pv.addWidget(widget)
+ pv.addStretch(1)
+ wrap = QScrollArea()
+ wrap.setWidgetResizable(True)
+ wrap.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ wrap.setWidget(page)
+ pages.append((label, wrap))
+ self.section_list, self.section_stack = section_panels(pages)
+ scroll.setParent(None)
+ body = QHBoxLayout()
+ body.setSpacing(10)
+ body.addWidget(self.section_list)
+ body.addWidget(self.section_stack, 1)
+ outer.addLayout(body, 1)
+ self._content = self._general_box # kept for other callers
+ # Floor the dialog at the width its WIDEST page needs, at the current
+ # font. On a 125%/150% display everything is wider, and without this the
+ # form was simply cut off instead of the window refusing to get smaller.
+ widest = max(w.widget().sizeHint().width() for _lab, w in pages)
+ self.setMinimumWidth(self.section_list.width() + widest + 60)
+
+ buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
+ buttons.accepted.connect(self._save)
+ buttons.rejected.connect(self.reject)
+ outer.addWidget(buttons)
+
+ from .widgets import guard_wheel
+ guard_wheel(self)
+
+ screen = QGuiApplication.primaryScreen()
+ if screen:
+ avail = screen.availableGeometry()
+ self.resize(640, min(740, avail.height() - 80))
+ self.setMaximumHeight(avail.height())
+
+ # ---- helpers -----------------------------------------------------
+ @staticmethod
+ def _secret(value: str) -> QLineEdit:
+ edit = QLineEdit(value)
+ edit.setEchoMode(QLineEdit.Password)
+ return edit
+
+ @staticmethod
+ def _select_combo(combo: QComboBox, value: str) -> None:
+ idx = combo.findData(value)
+ if idx >= 0:
+ combo.setCurrentIndex(idx)
+
+ @staticmethod
+ def _group(title: str, rows) -> QGroupBox:
+ box = QGroupBox(title)
+ form = QFormLayout(box)
+ for label, widget in rows:
+ form.addRow(label, widget)
+ return box
+
+ def _routing_reassess_now(self) -> None:
+ """Kick off a manual model reassessment in the background."""
+ try:
+ service = self.ctx.routing()
+ if service.is_reassessing():
+ return
+ self.routing_reassess_btn.setEnabled(False)
+ self.routing_reassess_btn.setText(tr("routing.reassessing"))
+
+ def _done(result) -> None:
+ # Re-enable from the (worker) callback; label reflects the count.
+ self.routing_reassess_btn.setEnabled(True)
+ self.routing_reassess_btn.setText(
+ tr("routing.reassess_done", count=len(result or {})))
+
+ service.reassess_background(on_done=_done)
+ except Exception: # noqa: BLE001 — a reassess click must never crash Settings
+ self.routing_reassess_btn.setEnabled(True)
+ self.routing_reassess_btn.setText(tr("routing.settings_reassess_now"))
+
+ @staticmethod
+ def _model_combo(value: str) -> QComboBox:
+ combo = QComboBox()
+ combo.setEditable(True)
+ # A combo sizes itself to its longest entry by default; model ids are
+ # long, so the row grew past the dialog and forced a sideways scrollbar
+ # (worse at 125%/150% display scaling). Let it shrink and use a popup
+ # wider than the closed box instead.
+ combo.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
+ combo.setMinimumContentsLength(8)
+ combo.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
+ if value:
+ combo.addItem(value)
+ combo.setCurrentText(value)
+ return combo
+
+ def _with_load(self, combo: QComboBox, status: QLabel) -> QWidget:
+ row = QWidget()
+ lay = QHBoxLayout(row)
+ lay.setContentsMargins(0, 0, 0, 0)
+ lay.addWidget(combo, 1)
+ btn = QPushButton(tr("settings.load"))
+ btn.setIcon(icon("download"))
+ btn.setToolTip(tr("settings.load_tooltip"))
+ btn.clicked.connect(
+ lambda: self._load_models(self.provider_combo.currentData(), combo, status))
+ lay.addWidget(btn)
+ test_btn = QPushButton(tr("settings.test_connection"))
+ test_btn.setIcon(icon("flask"))
+ test_btn.setToolTip(tr("settings.test_connection_tooltip"))
+ test_btn.clicked.connect(
+ lambda: self._test_connection(self.provider_combo.currentData(), status))
+ lay.addWidget(test_btn)
+ # The two buttons keep their natural size; the combo gives way. Without
+ # this the row's minimum was combo + both buttons and nothing could
+ # shrink, so the dialog scrolled sideways instead.
+ for b in (btn, test_btn):
+ b.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
+ row.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
+ return row
+
+ def _stash_provider_fields(self) -> None:
+ staged = self._prov_staging.setdefault(self._prov_current_key, {})
+ staged.update({
+ "base_url": self.prov_base.text().strip(),
+ "api_key": self.prov_key.text(),
+ "model": self.prov_model.currentText().strip(),
+ })
+
+ def _on_provider_edit_changed(self) -> None:
+ self._stash_provider_fields()
+ self._prov_current_key = self.provider_combo.currentData()
+ conf = self._prov_staging.get(self._prov_current_key, {})
+ self.prov_base.setText(conf.get("base_url", ""))
+ self.prov_key.setText(conf.get("api_key", ""))
+ self.prov_model.clear()
+ if conf.get("model"):
+ self.prov_model.addItem(conf["model"])
+ self.prov_model.setCurrentText(conf["model"])
+ else:
+ self.prov_model.setCurrentText("")
+ self.prov_status.setText("")
+
+ def _current_conf(self, provider: str) -> dict:
+ if provider == self._prov_current_key:
+ return {"base_url": self.prov_base.text().strip(), "api_key": self.prov_key.text(),
+ "model": self.prov_model.currentText().strip()}
+ conf = self._prov_staging.get(provider, {})
+ return {"base_url": conf.get("base_url", ""), "api_key": conf.get("api_key", ""),
+ "model": conf.get("model", "")}
+
+ # ---- MS365 zero-config sign-in ("connect like Claude") ---------------
+ def _refresh_ms365_status(self) -> None:
+ from ..core.ms365_auth import current_identity
+ who = current_identity(self.ctx.config)
+ if who:
+ self.ms365_status.setText(tr("settings.ms365_signed_in", who=who))
+ self.ms365_signin_btn.setEnabled(False)
+ self.ms365_signout_btn.setEnabled(True)
+ else:
+ self.ms365_status.setText(tr("settings.ms365_signed_out"))
+ self.ms365_signin_btn.setEnabled(True)
+ self.ms365_signout_btn.setEnabled(False)
+ self.ms365_signin_btn.setText(tr("settings.ms365_signin_btn"))
+ self.ms365_signout_btn.setText(tr("settings.ms365_signout_btn"))
+
+ def _ms365_sign_in(self) -> None:
+ from ..core.ms365_auth import current_identity, sign_in
+ self.ms365_signin_btn.setEnabled(False)
+ self.ms365_status.setText(tr("settings.ms365_signing_in"))
+ cfg = self.ctx.config
+
+ def job(worker):
+ # on_code fires (worker thread) with the MSAL device-flow dict —
+ # marshal it to the UI thread via the worker's event signal.
+ return sign_in(lambda flow: worker.event.emit({"device_flow": flow}), cfg)
+
+ def on_event(ev: dict) -> None:
+ if "device_flow" in ev:
+ self._show_ms365_device_code(ev["device_flow"])
+
+ def done(_result) -> None:
+ self._close_ms365_code_dialog()
+ self.ctx.save()
+ self._refresh_ms365_status()
+ QMessageBox.information(
+ self, tr("settings.ms365_signin_btn"),
+ tr("settings.ms365_signed_in", who=current_identity(cfg)))
+
+ def failed(err: str) -> None:
+ self._close_ms365_code_dialog()
+ self._refresh_ms365_status()
+ QMessageBox.warning(self, tr("settings.ms365_signin_btn"), err)
+
+ w = AgentWorker(job)
+ w.event.connect(on_event)
+ w.finished_ok.connect(done)
+ w.failed.connect(failed)
+ self._ms365_workers.append(w)
+ w.start()
+
+ def _close_ms365_code_dialog(self) -> None:
+ dlg = getattr(self, "_ms365_code_dialog", None)
+ if dlg is not None:
+ dlg.close()
+ self._ms365_code_dialog = None
+
+ def _show_ms365_device_code(self, flow: dict) -> None:
+ """Auto-open the sign-in page + show the one-time code in a COPYABLE,
+ non-modal dialog (so the worker keeps polling and can auto-close it on
+ success). The code is also copied to the clipboard immediately."""
+ import webbrowser
+
+ code = flow.get("user_code", "")
+ url = flow.get("verification_uri", "https://microsoft.com/devicelogin")
+ # Auto-copy the code so the user can just paste it.
+ QGuiApplication.clipboard().setText(code)
+ # Auto-open the browser to the (code-prefilled, if available) sign-in page.
+ try:
+ webbrowser.open(flow.get("verification_uri_complete") or url)
+ except Exception: # noqa: BLE001 — a headless box just shows the link to click
+ pass
+
+ self._close_ms365_code_dialog()
+ dlg = QDialog(self)
+ dlg.setWindowTitle(tr("settings.ms365_signin_btn"))
+ dlg.setMinimumWidth(420)
+ lay = QVBoxLayout(dlg)
+ info = QLabel(tr("settings.ms365_code_hint", url=url))
+ info.setWordWrap(True)
+ info.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextBrowserInteraction)
+ info.setOpenExternalLinks(True)
+ lay.addWidget(info)
+
+ code_row = QHBoxLayout()
+ code_edit = QLineEdit(code)
+ code_edit.setReadOnly(True)
+ f = code_edit.font()
+ f.setPointSize(f.pointSize() + 4)
+ f.setBold(True)
+ code_edit.setFont(f)
+ code_edit.setCursorPosition(0)
+ copy_btn = QPushButton(tr("settings.ms365_copy_code"))
+ copy_btn.setIcon(icon("document"))
+ copy_btn.clicked.connect(lambda: QGuiApplication.clipboard().setText(code))
+ open_btn = QPushButton(tr("settings.ms365_open_link"))
+ open_btn.setIcon(icon("link"))
+ open_btn.clicked.connect(lambda: webbrowser.open(flow.get("verification_uri_complete") or url))
+ code_row.addWidget(code_edit, 1)
+ code_row.addWidget(copy_btn)
+ code_row.addWidget(open_btn)
+ lay.addLayout(code_row)
+
+ buttons = QDialogButtonBox(QDialogButtonBox.Close)
+ buttons.rejected.connect(dlg.reject)
+ lay.addWidget(buttons)
+
+ self._ms365_code_dialog = dlg
+ dlg.show() # non-modal — sign-in polling continues; done() closes it
+
+ def _ms365_sign_out(self) -> None:
+ from ..core.ms365_auth import sign_out_default
+ sign_out_default(self.ctx.config)
+ self._refresh_ms365_status()
+
+ def _load_models(self, provider: str, combo: QComboBox, status: QLabel) -> None:
+ conf = self._current_conf(provider)
+
+ def job(worker):
+ from ..providers import build_provider
+ prov = build_provider(provider, conf)
+ models = prov.list_models()
+ return {"models": models, "error": getattr(prov, "last_error", "")}
+
+ def done(result):
+ models = result.get("models") or []
+ current = combo.currentText().strip()
+ combo.clear()
+ if current:
+ combo.addItem(current)
+ for m in models:
+ if m != current:
+ combo.addItem(m)
+ combo.setCurrentText(current)
+ error = result.get("error", "")
+ if models:
+ status.setText(tr("settings.loaded_models", n=len(models),
+ provider=PROVIDER_LABELS.get(provider, provider)))
+ else:
+ status.setText(tr("settings.load_models_error", err=error or
+ tr("settings.load_models_error_unknown")))
+
+ w = AgentWorker(job)
+ w.finished_ok.connect(done)
+ w.failed.connect(lambda e: status.setText(tr("settings.load_failed", err=e)))
+ self._load_workers.append(w)
+ status.setText(tr("settings.loading_models"))
+ w.start()
+
+ def _test_connection(self, provider: str, status: QLabel) -> None:
+ conf = self._current_conf(provider)
+
+ def job(worker):
+ from ..providers import build_provider
+ ok, message = build_provider(provider, conf).test_connection()
+ return {"ok": ok, "message": message}
+
+ def done(result):
+ ok = result.get("ok")
+ status.setText(result.get("message", ""))
+ status.setStyleSheet("color: #090;" if ok else "color: #c00;")
+
+ def failed(e):
+ status.setText(str(e))
+ status.setStyleSheet("color: #c00;")
+
+ w = AgentWorker(job)
+ w.finished_ok.connect(done)
+ w.failed.connect(failed)
+ self._load_workers.append(w)
+ status.setText(tr("settings.testing_connection"))
+ w.start()
+
+ def _sandbox_unlock(self) -> None:
+ pw = self.sandbox_pw_edit.text()
+ if pw == self._sandbox_pw:
+ self._sandbox_unlocked = True
+ self.sandbox_locked_status.setText("Unlocked")
+ self.sandbox_locked_status.set_icon("unlock", "#090")
+ self.sandbox_locked_status.text_label().setStyleSheet("color: #090; font-weight: bold;")
+ # Enable all sandbox widgets
+ for w in self._sandbox_widgets:
+ w.setEnabled(True)
+ QMessageBox.information(self, "Sandbox Security", "Sandbox settings unlocked.")
+ else:
+ QMessageBox.warning(self, "Wrong Password", "Password incorrect. Sandbox settings remain locked.")
+
+ def _save(self) -> None:
+ data = self.ctx.config.data
+ data["active_provider"] = self.provider_combo.currentData()
+ data["language"] = self.language_combo.currentData()
+ # MainWindow._open_settings re-applies the theme after this returns, so
+ # writing the value here is enough to make it take effect.
+ data["theme"] = self.theme_combo.currentData()
+
+ self._stash_provider_fields()
+ for key, staged in self._prov_staging.items():
+ data["providers"].setdefault(key, {}).update({
+ "base_url": staged.get("base_url", ""),
+ "api_key": staged.get("api_key", ""),
+ "model": staged.get("model", ""),
+ })
+
+ # NOTE: allow_url_fetch is managed in Monitoring → Tools → Tool now
+ # (persisted there directly), so it is intentionally not written here.
+ data.setdefault("agent_security", {}).update({
+ "enabled": self.sec_enabled.isChecked(),
+ "cowork_confirm_commands": self.sandbox_confirm.isChecked(),
+ "block_network": self.sandbox_block_network.isChecked(),
+ "command_ai_check": self.ai_check.isChecked(),
+ "command_whitelist": [],
+ "resource_limit_cpu_percent": self.sandbox_cpu.value(),
+ "resource_limit_memory_mb": self.sandbox_memory.value(),
+ "resource_limit_disk_mb": self.sandbox_disk.value(),
+ })
+ att = data.setdefault("attachments", {})
+ att["max_tokens"] = self.attach_tokens.value() * 1000
+ att["max_files"] = self.attach_files.value()
+ st = data.setdefault("structure", {})
+ st["max_nodes"] = self.struct_nodes.value()
+ st["max_edges"] = self.struct_edges.value()
+ tray = data.setdefault("tray", {})
+ tray["minimize_on_close"] = self.tray_chk.isChecked()
+ tray["notify_on_done"] = self.notify_chk.isChecked()
+
+ r = data.setdefault("routing", {})
+ r["switch_mode"] = self.routing_mode.currentData()
+ r["policy"] = self.routing_policy.currentData()
+ r["min_score_gain"] = self.routing_min_gain.value() / 100.0
+ r["confirm_timeout_sec"] = self.routing_timeout.value()
+ r["reassess_interval_hours"] = self.routing_interval.value()
+ r["per_provider_concurrency"] = self.routing_concurrency.value()
+ r["judge_model"] = self.routing_judge.text().strip()
+
+ self.ctx.save()
+
+ # Force-reload config so all parts of the app pick up the new settings immediately
+ self.ctx.config._data = None # invalidate cache
+ self.ctx.config._agent_security = None
+
+ self.accept()
\ No newline at end of file
diff --git a/ui/sidebar.py b/ui/sidebar.py
index eabe062..4c04f72 100644
--- a/ui/sidebar.py
+++ b/ui/sidebar.py
@@ -164,6 +164,9 @@ class HistorySidebar(QWidget):
self._refresh_btn.setToolTip(tr("sidebar.refresh_tooltip"))
self.refresh() # re-render group headers / running suffix in the new language
+ def is_collapsed(self) -> bool:
+ return self._strip.isVisible()
+
def set_collapsed(self, collapsed: bool) -> None:
"""Collapse to a thin line (kept visible) or restore the full panel."""
self._content.setVisible(not collapsed)
diff --git a/ui/spline_chart.py b/ui/spline_chart.py
index 60e6d8e..6f0ce4a 100644
--- a/ui/spline_chart.py
+++ b/ui/spline_chart.py
@@ -13,7 +13,7 @@ from PySide6.QtCore import QPointF, Qt
from PySide6.QtGui import QBrush, QColor, QLinearGradient, QPainter, QPainterPath, QPen
from PySide6.QtWidgets import QWidget
-from ..theme import ACCENT
+from ..theme import current_palette
def _endpoint_label_rect(point_x: float, point_y: float, text_width: float,
@@ -75,17 +75,13 @@ class SplineChart(QWidget):
self._refs = list(refs or [])
self.update()
- def _dark(self) -> bool:
- from .chat_view import _app_theme
- return _app_theme() == "dark"
-
def paintEvent(self, _e): # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
- dark = self._dark()
- grid = QColor("#1A2D4A" if dark else "#C7DEEE")
- text = QColor("#8FB2D4" if dark else "#5C7A94")
- accent = QColor(ACCENT)
+ tok = current_palette()
+ grid = QColor(tok.chart_grid)
+ text = QColor(tok.chart_label)
+ accent = QColor(tok.accent)
w, h = self.width(), self.height()
pts = self._points
diff --git a/ui/structure_graph_view.py b/ui/structure_graph_view.py
index d361134..b195bf0 100644
--- a/ui/structure_graph_view.py
+++ b/ui/structure_graph_view.py
@@ -1,993 +1,1034 @@
-"""Structure (RAG) tab — knowledge graph of code / document structure.
-
-Primary view is the D3 knowledge-graph (WebEngine) which gently auto-rotates
-when idle and opens a node's storage folder on click. If WebEngine isn't
-available (e.g. the standalone .exe), a native draggable QGraphicsView is the
-in-app fallback. The graph auto-updates when the Code agent produces output,
-and an Agent box on the right answers questions over the graph (Graph-RAG).
-"""
-from __future__ import annotations
-
-import math
-import re
-import sys
-from pathlib import Path
-
-from PySide6.QtCore import QObject, QPointF, Qt, QTimer, QUrl, Signal, Slot
-from PySide6.QtGui import QBrush, QColor, QFont, QPen
-from PySide6.QtWidgets import (
- QComboBox, QFileDialog, QGraphicsEllipseItem, QGraphicsLineItem,
- QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout,
- QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget,
- QTextBrowser, QVBoxLayout, QWidget,
-)
-
-def _frozen_onefile() -> bool:
- """True only for a PyInstaller ONEFILE build. Onefile extracts itself to a
- temp dir (sys._MEIPASS under %TEMP%), where the QtWebEngine helper process
- can't run — creating a QWebEngineView hard-crashes the app (reported as
- "click the graph tab → app closes"). A ONEDIR build keeps _MEIPASS as the
- ``_internal`` folder right next to the exe, where WebEngine works fine, so
- it keeps the full embedded D3 view."""
- if not getattr(sys, "frozen", False):
- return False
- meipass = getattr(sys, "_MEIPASS", "")
- if not meipass:
- return False
- try:
- return Path(meipass).resolve().parent != Path(sys.executable).resolve().parent
- except OSError: # can't tell → play safe: use the native fallback
- return True
-
-
-try: # WebEngine + WebChannel are optional PySide6 add-ons
- from PySide6.QtWebEngineWidgets import QWebEngineView
- from PySide6.QtWebChannel import QWebChannel
- _HAS_WEB = not _frozen_onefile()
-except Exception: # pragma: no cover
- _HAS_WEB = False
-
-from ..core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS
-from ..core.worker import AgentWorker
-from ..i18n import on_language_changed, tr
-from ..state import AppContext
-from .icons import collapse_right_icon, icon
-from .osutil import open_folder, open_location
-from .widgets import CollapseStrip
-
-try:
- from PySide6.QtWidgets import QGraphicsItem # noqa: F401 — ensure available
-except Exception:
- pass
-
-
-class _Bridge(QObject):
- """Exposed to the D3 page so a Shift+click on a node can open its
- storage folder/link (local path or URL — see osutil.open_location)."""
-
- @Slot(str)
- def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name
- if path:
- open_location(path)
-
-
-class _Edge(QGraphicsLineItem):
- def __init__(self, a: "_Node", b: "_Node", type_: str = ""):
- super().__init__()
- self.a, self.b = a, b
- self.type = type_
- # Colour the edge by its RELATIONSHIP type (contains/defines/method/…),
- # so the graph shows what each connection MEANS — falling back to the
- # source node's tint for any untyped edge.
- color = QColor(EDGE_KIND_COLORS.get(type_, "")) if type_ else QColor()
- if not color.isValid():
- color = a.brush().color().lighter(130)
- self._color = color
- self.setPen(QPen(color, 1.4))
- self.setZValue(-1)
- # A small label naming the relationship, shown at the edge midpoint.
- self._label = None
- if type_:
- self._label = QGraphicsSimpleTextItem(type_, self)
- self._label.setBrush(QBrush(color.lighter(140)))
- f = QFont()
- f.setPointSize(7)
- self._label.setFont(f)
- self._label.setZValue(0)
- a.edges.append(self)
- b.edges.append(self)
- self.adjust()
-
- def adjust(self) -> None:
- pa, pb = self.a.scenePos(), self.b.scenePos()
- self.setLine(pa.x(), pa.y(), pb.x(), pb.y())
- if self._label is not None:
- br = self._label.boundingRect()
- self._label.setPos((pa.x() + pb.x()) / 2 - br.width() / 2,
- (pa.y() + pb.y()) / 2 - br.height() / 2)
-
-
-class _Node(QGraphicsEllipseItem):
- def __init__(self, data, radius: int):
- super().__init__(-radius, -radius, 2 * radius, 2 * radius)
- self.data = data
- self.edges = []
- color = QColor(NODE_KIND_COLORS.get(data.kind, "#888888"))
- self.setBrush(QBrush(color))
- self.setPen(QPen(color.darker(160), 1.5))
- self.setFlags(
- QGraphicsEllipseItem.ItemIsMovable
- | QGraphicsEllipseItem.ItemIsSelectable
- | QGraphicsEllipseItem.ItemSendsGeometryChanges
- )
- self.setZValue(1)
- label = QGraphicsSimpleTextItem(data.label, self)
- label.setBrush(QBrush(QColor("#e6e6e6")))
- label.setPos(radius + 3, -8)
-
- def itemChange(self, change, value): # noqa: N802
- if change == QGraphicsEllipseItem.ItemPositionHasChanged:
- for edge in self.edges:
- edge.adjust()
- return super().itemChange(change, value)
-
-
-class _GraphView(QGraphicsView):
- def __init__(self, scene):
- super().__init__(scene)
- self.setDragMode(QGraphicsView.NoDrag)
- self._panning = False
- self._pan_start = QPointF()
-
- def wheelEvent(self, e): # noqa: N802
- self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15,
- 1.15 if e.angleDelta().y() > 0 else 1 / 1.15)
-
- def mousePressEvent(self, e): # noqa: N802
- if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None:
- self._panning = True
- self._pan_start = e.position()
- self.setCursor(Qt.ClosedHandCursor)
- e.accept()
- return
- super().mousePressEvent(e)
-
- def mouseMoveEvent(self, e): # noqa: N802
- if self._panning:
- delta = e.position() - self._pan_start
- self._pan_start = e.position()
- self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().value() - delta.x()))
- self.verticalScrollBar().setValue(int(self.verticalScrollBar().value() - delta.y()))
- e.accept()
- return
- super().mouseMoveEvent(e)
-
- def mouseReleaseEvent(self, e): # noqa: N802
- if self._panning:
- self._panning = False
- self.setCursor(Qt.ArrowCursor)
- e.accept()
- return
- super().mouseReleaseEvent(e)
-
- def mouseDoubleClickEvent(self, e): # noqa: N802
- """Double-click or Ctrl+click on a node opens its storage folder."""
- item = self.itemAt(e.pos())
- if isinstance(item, _Node) and getattr(item.data, "path", ""):
- open_folder(item.data.path)
- e.accept()
- return
- super().mouseDoubleClickEvent(e)
-
-
-class StructureGraphView(QWidget):
- status_message = Signal(str)
-
- def __init__(self, ctx: AppContext):
- super().__init__()
- self.ctx = ctx
- self._worker: AgentWorker | None = None
- self._node_items: list[_Node] = []
- self._edge_items: list[_Edge] = []
- self._centroid = QPointF(0, 0)
- self._link = 120
- self._graph = None
- self._needs_scan = False
- self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite)
- self._ask_worker: AgentWorker | None = None
- self._answer = ""
- self._detail_mode = "idle" # "answer" | "node" | "idle" — what self.detail shows
- # TEMPORARY extracted file content for Q&A (real content, not just the
- # graph structure). Kept only while this tab is shown — cleared on leaving
- # the tab or switching project/root (see _clear_extracts / hideEvent).
- self._extract_cache: dict = {} # path -> extracted text
- self._extract_dir = None # temp folder for md/json dumps
- self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox
-
- self._rescan_timer = QTimer(self)
- self._rescan_timer.setSingleShot(True)
- self._rescan_timer.setInterval(1500)
- self._rescan_timer.timeout.connect(self._scan)
-
- root = QVBoxLayout(self)
-
- bar = QHBoxLayout()
- self.path_edit = QLineEdit(str(ctx.config.cowork_output_dir()))
- self.path_edit.setPlaceholderText(tr("structure.path_placeholder"))
- self._pick_btn = QPushButton()
- self._pick_btn.setIcon(icon("folder"))
- self._pick_btn.setObjectName("primary")
- self._pick_btn.clicked.connect(self._pick)
- self.project_combo = QComboBox()
- self.project_combo.currentIndexChanged.connect(self._on_project_changed)
- self._scan_btn = QPushButton()
- self._scan_btn.setIcon(icon("search"))
- self._scan_btn.setObjectName("primary")
- self._scan_btn.clicked.connect(self._scan)
- bar.addWidget(self.path_edit, 1)
- bar.addWidget(self._pick_btn)
- bar.addWidget(self.project_combo)
- bar.addWidget(self._scan_btn)
- root.addLayout(bar)
- self._refresh_project_combo()
-
- # Toolbar: messages toggle + export
- bar2 = QHBoxLayout()
- bar2.addStretch(1)
- self._msgs_toggle_btn = QPushButton()
- self._msgs_toggle_btn.setIcon(icon("message"))
- self._msgs_toggle_btn.setToolTip(tr("structure.msgs_tooltip"))
- self._msgs_toggle_btn.clicked.connect(self._toggle_messages)
- bar2.addWidget(self._msgs_toggle_btn)
-
- self._export_btn = QPushButton()
- self._export_btn.setIcon(icon("upload"))
- self._export_btn.setObjectName("primary")
- self._export_btn.clicked.connect(self._export)
- bar2.addWidget(self._export_btn)
-
- root.addLayout(bar2)
-
- split = QSplitter(Qt.Horizontal)
- self.scene = QGraphicsScene()
- self.scene.setBackgroundBrush(QColor("#0D1F35")) # deep ocean dark bg
- self.scene.selectionChanged.connect(self._on_selection)
- self.view = _GraphView(self.scene)
-
- self._stack = QStackedWidget()
- self._stack.addWidget(self.view)
- # A "Messages" view: all conversation messages grouped BY DAY, shown as
- # JSON — a plain tree switched in via setCurrentWidget (never touches the
- # D3/WebEngine graph). Populated from the (project-scoped) history store.
- from PySide6.QtWidgets import QTreeWidget
- self._msgs_view = QTreeWidget()
- self._msgs_view.setHeaderHidden(True)
- self._msgs_view.itemClicked.connect(self._show_msg_json)
- self._stack.addWidget(self._msgs_view)
- self.web = None
- self._bridge = None
- self._channel = None
-
- # The legend + Show-relationship control live INSIDE the D3 graph
- # template now (assets/graph_template.html) — the graph column is just
- # the stack (native view / D3 web / messages).
- split.addWidget(self._stack)
-
- # Right-side agent panel (GraphRAG Q&A)
- right = QWidget()
- rl = QVBoxLayout(right)
- rl.setContentsMargins(0, 0, 0, 0)
-
- # Agent panel header with collapse button
- ag_hdr = QHBoxLayout()
- self._ag_collapse = QPushButton()
- self._ag_collapse.setIcon(collapse_right_icon())
- self._ag_collapse.setFixedWidth(28)
- self._ag_collapse.clicked.connect(lambda: self._set_agent_collapsed(True))
- self._ag_label = QLabel()
- ag_hdr.addWidget(self._ag_collapse)
- ag_hdr.addWidget(self._ag_label, 1)
- rl.addLayout(ag_hdr)
-
- # Ask row
- ask_row = QHBoxLayout()
- self.ask_edit = QLineEdit()
- self.ask_edit.returnPressed.connect(self._ask)
- self._ask_btn = QPushButton()
- self._ask_btn.setIcon(icon("chat"))
- self._ask_btn.setObjectName("primary")
- self._ask_btn.clicked.connect(self._ask)
- ask_row.addWidget(self.ask_edit, 1)
- ask_row.addWidget(self._ask_btn)
- rl.addLayout(ask_row)
-
- # Detail browser
- self.detail = QTextBrowser()
- self.detail.setReadOnly(True)
- self.detail.setOpenLinks(False)
- self.detail.anchorClicked.connect(self._on_detail_link)
- rl.addWidget(self.detail, 1)
-
- self._agent_panel = right
-
- self._agent_strip = CollapseStrip(tr("structure.expand_agent_tooltip"), expand_dir="left")
- self._agent_strip.clicked.connect(lambda: self._set_agent_collapsed(False))
- self._agent_strip.setVisible(False)
- self._agent_pane = QWidget()
- apl = QHBoxLayout(self._agent_pane)
- apl.setContentsMargins(0, 0, 0, 0)
- apl.setSpacing(0)
- apl.addWidget(self._agent_strip)
- apl.addWidget(right, 1)
-
- self._split = split
- split.addWidget(self._agent_pane)
- split.setChildrenCollapsible(False)
- split.setSizes([840, 320])
- root.addWidget(split, 1)
- on_language_changed(self._retranslate)
-
- def _retranslate(self) -> None:
- self.path_edit.setPlaceholderText(tr("structure.path_placeholder"))
- self._pick_btn.setText(tr("structure.browse"))
- self._scan_btn.setText(tr("structure.scan"))
- self._export_btn.setText(tr("structure.export_png"))
- showing = self._stack.currentWidget() is getattr(self, "_msgs_view", None)
- self._msgs_toggle_btn.setText(tr("structure.graph_btn") if showing else tr("structure.msgs_btn"))
- self._ag_collapse.setToolTip(tr("structure.collapse_agent_tooltip"))
- self._ag_label.setText(tr("structure.agent_header"))
- self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder"))
- self._ask_btn.setText(tr("structure.ask"))
- if self._detail_mode == "idle":
- self.detail.setPlaceholderText(tr("structure.detail_placeholder"))
- self._agent_strip.setToolTip(tr("structure.expand_agent_tooltip"))
- self.project_combo.setToolTip(tr("structure.project_tooltip"))
- self._refresh_project_combo()
-
- # ---- project sandbox lock -----------------------------------------
- def _refresh_project_combo(self) -> None:
- from ..core.projects import list_projects
-
- keep = self._active_project_id
- self.project_combo.blockSignals(True)
- self.project_combo.clear()
- self.project_combo.addItem(tr("structure.project_none"), "")
- row_to_select = 0
- for i, p in enumerate(list_projects(), start=1):
- self.project_combo.addItem(p.name, p.project_id)
- if p.project_id == keep:
- row_to_select = i
- self.project_combo.setCurrentIndex(row_to_select)
- self.project_combo.blockSignals(False)
-
- def set_project(self, project_id: str) -> None:
- pid = project_id or ""
- self._refresh_project_combo()
- target = self.project_combo.findData(pid)
- if target < 0:
- target = 0
- if self.project_combo.currentIndex() == target:
- self._on_project_changed(target)
- else:
- self.project_combo.setCurrentIndex(target)
-
- def _on_project_changed(self, _idx: int) -> None:
- from ..core.projects import load_project
-
- pid = self.project_combo.currentData() or ""
- project_changed = pid != self._active_project_id
- if project_changed:
- self._clear_extracts() # different workspace → drop temp extraction
- self._active_project_id = pid
- locked = bool(pid)
- self.path_edit.setReadOnly(locked)
- # Also disable the folder-pick button — otherwise the scan path is only
- # "locked" against typing, but the picker could still repoint it outside
- # the selected project's sandbox, breaking GraphRAG scope isolation.
- self._pick_btn.setEnabled(not locked)
- if locked:
- project = load_project(pid)
- if project is not None:
- self.path_edit.setText(str(project.workspace_dir()))
- if project_changed:
- self._needs_scan = True
- if self.web is not None:
- self._needs_scan = False
- self._scan()
-
- # ---- helpers -----------------------------------------------------
- def _pick(self) -> None:
- chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text())
- if chosen:
- self.path_edit.setText(chosen)
-
- def schedule_rescan(self, path: str = "") -> None:
- if self._graph is None:
- self._needs_scan = True
- return
- self._rescan_timer.start()
-
- # ---- Messages (by day, as JSON) --------------------------------------
- def _toggle_messages(self) -> None:
- """Switch between the knowledge graph and the Messages-by-day view."""
- showing = self._stack.currentWidget() is self._msgs_view
- if showing:
- self._stack.setCurrentWidget(self.web if self.web is not None else self.view)
- else:
- self._reload_messages()
- self._stack.setCurrentWidget(self._msgs_view)
- self._msgs_toggle_btn.setText(
- tr("structure.graph_btn") if not showing else tr("structure.msgs_btn"))
-
- def _reload_messages(self) -> None:
- """Build the tree: day → conversation. Click a conversation to see its
- messages as JSON. Scoped to the current project (its history folder)."""
- from collections import OrderedDict
-
- from PySide6.QtCore import Qt
- from PySide6.QtWidgets import QTreeWidgetItem
-
- from ..core.history import list_conversations
- self._msgs_view.clear()
- pid = self._active_project_id or ""
- by_day: "OrderedDict[str, list]" = OrderedDict()
- try:
- convs = list_conversations(self.ctx.config.history_dir())
- except Exception: # noqa: BLE001
- convs = []
- for conv in convs:
- if pid and conv.get("project_id", "default") != pid:
- continue
- day = (conv.get("created") or "")[:10] or "—"
- by_day.setdefault(day, []).append(conv)
- if not by_day:
- self._msgs_view.addTopLevelItem(QTreeWidgetItem([tr("structure.msgs_none")]))
- return
- for day in sorted(by_day, reverse=True):
- convs_d = by_day[day]
- day_item = QTreeWidgetItem([f"{day} ({len(convs_d)})"])
- for conv in convs_d:
- it = QTreeWidgetItem([conv.get("title", "(untitled)")])
- it.setData(0, Qt.UserRole, str(conv.get("path", "")))
- day_item.addChild(it)
- self._msgs_view.addTopLevelItem(day_item)
- day_item.setExpanded(True)
-
- def _show_msg_json(self, item, _col: int = 0) -> None:
- import html
- import json
-
- from PySide6.QtCore import Qt
-
- from ..core.history import load_conversation
- path = item.data(0, Qt.UserRole)
- if not path:
- return
- try:
- conv = load_conversation(path)
- payload = {"title": conv.get("title", ""), "created": conv.get("created", ""),
- "kind": conv.get("kind", ""), "project_id": conv.get("project_id", ""),
- "messages": conv.get("messages", [])}
- text = json.dumps(payload, ensure_ascii=False, indent=2)
- except Exception as exc: # noqa: BLE001
- text = f"(could not read: {exc})"
- self.detail.setHtml(
- f'{html.escape(text)}')
-
- def _ensure_web(self) -> None:
- if self.web is not None or not _HAS_WEB:
- return
- self.web = QWebEngineView()
- self._bridge = _Bridge()
- self._channel = QWebChannel()
- self._channel.registerObject("py", self._bridge)
- self.web.page().setWebChannel(self._channel)
- self._stack.addWidget(self.web)
- self._stack.setCurrentWidget(self.web)
- if self._graph is not None:
- self._render_d3()
-
- def auto_scan_and_fit(self) -> None:
- self._ensure_web()
- if not self.path_edit.text().strip():
- return
- if getattr(self, "_worker", None) is not None and self._worker.isRunning():
- self._fit()
- self._preserve_answer()
- return
- if self._graph is not None and not self._needs_scan:
- self._fit()
- self._preserve_answer()
- return
- self._needs_scan = False
- self._scan()
-
- # ---- scan --------------------------------------------------------
- def _scan(self) -> None:
- path = self.path_edit.text().strip() or str(Path.cwd())
- mode = "files" # default: scan all files (filter removed)
- use_cmem = bool(self.ctx.config.codebase_memory.get("enabled"))
- cmem_bin = self.ctx.config.codebase_memory.get("binary_path", "")
- st = self.ctx.config.structure
- max_nodes = int(st.get("max_nodes", 500) or 0)
- max_edges = int(st.get("max_edges", 500) or 0)
- self._scan_seq += 1
- seq = self._scan_seq
- self.status_message.emit(tr("structure.scanning"))
-
- def job(worker: AgentWorker):
- from ..core.structure_graph import (
- build_from_codebase_memory, build_from_directory, force_layout,
- )
- if use_cmem:
- from ..core.codebase_memory import CodebaseMemory
- mem = CodebaseMemory(cmem_bin)
- graph = (build_from_codebase_memory(mem, path, mode, max_nodes, max_edges)
- if mem.available else build_from_directory(path, mode, max_nodes, max_edges))
- else:
- graph = build_from_directory(path, mode, max_nodes, max_edges)
- pos = force_layout(graph)
- return {"graph": graph, "pos": pos, "seq": seq}
-
- w = AgentWorker(job)
- w.finished_ok.connect(self._render)
- w.failed.connect(lambda e: self.status_message.emit(tr("structure.scan_error", err=e)))
- self._worker = w
- w.start()
-
- def _render(self, result: dict) -> None:
- if result.get("seq") is not None and result["seq"] != self._scan_seq:
- return
- graph = result.get("graph")
- pos = result.get("pos", {})
- if graph is None:
- return
- self._graph = graph
-
- self.scene.clear()
- self.scene.setBackgroundBrush(QColor("#0D1F35")) # restore deep ocean bg after clear
- self._node_items = []
- self._edge_items = []
- degree = {n.id: 0 for n in graph.nodes}
- for e in graph.edges:
- if e.source in degree:
- degree[e.source] += 1
- if e.target in degree:
- degree[e.target] += 1
- items = {}
- sx = sy = 0.0
- for node in graph.nodes:
- radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0))))
- item = _Node(node, radius)
- x, y = pos.get(node.id, (0, 0))
- item.setPos(x, y)
- self.scene.addItem(item)
- items[node.id] = item
- self._node_items.append(item)
- sx += x
- sy += y
- for edge in graph.edges:
- a, b = items.get(edge.source), items.get(edge.target)
- if a and b:
- e = _Edge(a, b, getattr(edge, "type", ""))
- self.scene.addItem(e)
- self._edge_items.append(e)
- n = max(1, len(self._node_items))
- self._centroid = QPointF(sx / n, sy / n)
- self._fit()
-
- if self.web is not None:
- self._render_d3()
-
- note = tr("structure.truncated_note") if getattr(graph, "truncated", False) else ""
- self.status_message.emit(tr(
- "structure.graph_summary", nodes=len(graph.nodes), edges=len(graph.edges), note=note))
- self._preserve_answer()
-
- def _render_d3(self) -> None:
- if self.web is None or self._graph is None:
- return
- from ..core.d3_graph import build_html
- try:
- self.web.setHtml(build_html(self._graph), QUrl("https://cowork.local/"))
- except Exception as exc:
- self.status_message.emit(f"D3 view error: {exc}")
-
- # ---- native interactions ----------------------------------------
- def _on_selection(self) -> None:
- for item in self.scene.selectedItems():
- if isinstance(item, _Node):
- d = item.data
- self.detail.setPlainText(f"[{d.kind.upper()}] {d.label}\n\n{d.detail}")
- self._detail_mode = "node"
- return
-
- def _preserve_answer(self) -> None:
- if self._detail_mode == "answer" and self._answer.strip():
- self._render_answer()
-
- def _set_agent_collapsed(self, collapsed: bool) -> None:
- strip_w = CollapseStrip.WIDTH + 2
- self._agent_panel.setVisible(not collapsed)
- self._agent_strip.setVisible(collapsed)
- if collapsed:
- self._agent_pane.setMaximumWidth(strip_w)
- sizes = self._split.sizes()
- if len(sizes) == 2:
- self._split.setSizes([max(1, sum(sizes) - strip_w), strip_w])
- else:
- self._agent_pane.setMaximumWidth(16777215)
- self._split.setSizes([840, 320])
-
- def _fit(self) -> None:
- if self.web is not None and self._stack.currentWidget() is self.web:
- self.web.page().runJavaScript("window.fitGraph && window.fitGraph();")
- return
- rect = self.scene.itemsBoundingRect()
- if not rect.isNull():
- self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
-
- def _export(self) -> None:
- path, _ = QFileDialog.getSaveFileName(
- self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)")
- if not path:
- return
- showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web)
- if showing_d3:
- self._export_d3_png(path)
- else:
- self._export_widget_grab(path)
-
- def _export_d3_png(self, path: str) -> None:
- def on_result(data_url) -> None:
- if not isinstance(data_url, str) or "," not in data_url:
- self._export_widget_grab(path)
- return
- import base64
- try:
- with open(path, "wb") as f:
- f.write(base64.b64decode(data_url.split(",", 1)[1]))
- self.status_message.emit(tr("structure.export_done", path=path))
- except (OSError, ValueError) as exc:
- self.status_message.emit(tr("structure.export_failed", err=str(exc)))
- self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result)
-
- def _export_widget_grab(self, path: str) -> None:
- ok = self._stack.currentWidget().grab().save(path, "PNG")
- if ok:
- self.status_message.emit(tr("structure.export_done", path=path))
- else:
- self.status_message.emit(tr("structure.export_failed", err="grab() returned no image"))
-
- # ---- agent Q&A over the graph -----------------------------------
- @staticmethod
- def _graph_context(graph) -> str:
- from collections import defaultdict
- by_kind = defaultdict(list)
- for n in graph.nodes:
- by_kind[n.kind].append(n.label)
- lines = []
- for kind in ("file", "class", "function", "method", "module", "section"):
- items = by_kind.get(kind, [])
- if items:
- lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60]))
- id2label = {n.id: n.label for n in graph.nodes}
- rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}"
- for e in graph.edges[:140]]
- if rels:
- lines.append("Relationships (sample):\n" + "\n".join(rels))
- return "\n".join(lines)[:7000]
-
- def _matched_sources(self, text: str):
- if self._graph is None or not text:
- return []
- found: dict[str, tuple[str, str, str]] = {}
- for n in self._graph.nodes:
- if not n.path:
- continue
- label = n.label.rstrip("()")
- if len(label) < 3:
- continue
- if n.path not in found and re.search(rf"\b{re.escape(label)}\b", text):
- found[n.path] = (n.kind, n.label, n.detail or n.path)
- return sorted(found.items(), key=lambda kv: kv[1][1].lower())[:12]
-
- def _linkify_files(self, text: str, sources) -> str:
- """Turn file/entity NAMES mentioned in the answer into clickable links that
- open the file — so the user can click a name in the answer to view it."""
- for path, (kind, label, rel) in sources:
- href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
- tokens = []
- base = Path(path).name
- if base and len(base) >= 3:
- tokens.append(base)
- lab = (label or "").rstrip("()").strip()
- if lab and lab != base and len(lab) >= 3:
- tokens.append(lab)
- for tok in tokens:
- esc = re.escape(tok)
- # `tok` (code span) → keep the code style but make it a link
- text = re.sub(rf"`{esc}`", f"[`{tok}`]({href})", text)
- # bare tok, not already inside a link / path / code span
- text = re.sub(rf"(? None:
- text = self._answer
- sources = self._matched_sources(text)
- if sources:
- # 1) Make the file/entity names IN THE ANSWER clickable (open on click).
- text = self._linkify_files(text, sources)
- # 2) Append a clickable "Related sources" section listing each file.
- lines = [text, "", "---", f"**{tr('structure.related_sources')}**"]
- for path, (kind, label, rel) in sources:
- href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
- # kind badge for context (file/function/section/json_key)
- kind_badge = f" [{kind.upper()}]" if kind not in ("file",) else ""
- lines.append(f"- **[{label}⧉]({href})**{kind_badge} — `{rel}`")
- text = "\n".join(lines)
- self.detail.setMarkdown(text)
-
- def _on_detail_link(self, url: QUrl) -> None:
- if url.isLocalFile():
- p = url.toLocalFile()
- # Open the FILE itself for viewing (fall back to its folder for a dir).
- if Path(p).is_file():
- open_location(p)
- else:
- open_folder(p)
-
- def _ask(self) -> None:
- question = self.ask_edit.text().strip()
- if not question:
- return
- from ..core.skills import parse_skill_command
- skill_prefix, question, info = parse_skill_command(question)
- if info is not None:
- self.detail.setMarkdown(info)
- self._detail_mode = "answer"
- self.ask_edit.clear()
- return
- if self._graph is None:
- self.status_message.emit(tr("structure.scan_first"))
- return
- context = self._graph_context(self._graph)
- # Real file CONTENT to answer from (extracted temporarily in the worker):
- file_paths = self._candidate_file_paths()
- extract_cache = dict(self._extract_cache)
- extract_dir = str(self._extract_tmp_dir())
- self._answer = ""
- self._detail_mode = "answer"
- self.detail.setPlainText("…")
- self.ask_edit.clear()
-
- active_project_id = self._active_project_id
-
- # Collect selected node context for auto-filtering
- selected_nodes = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
- selected_context = ""
- if selected_nodes:
- node_lines = []
- for nd in selected_nodes:
- node_lines.append(f"- {nd.label} (kind: {nd.kind}, path: {getattr(nd, 'path', '')})")
- if nd.detail:
- node_lines.append(f" detail: {nd.detail}")
- # Also gather connected nodes
- connected_ids = set()
- for nd in selected_nodes:
- for edge in self._graph.edges:
- if edge.source == nd.id:
- connected_ids.add(edge.target)
- elif edge.target == nd.id:
- connected_ids.add(edge.source)
- connected_nodes = [n for n in self._graph.nodes if n.id in connected_ids]
- if connected_nodes:
- node_lines.append("\nConnected nodes:")
- for cn in connected_nodes:
- node_lines.append(f"- {cn.label} (kind: {cn.kind})")
- selected_context = "\n".join(node_lines)
-
- def job(worker: AgentWorker):
- provider = self.ctx.build_active_provider()
- system = ("You answer questions about a code/document knowledge graph. Use the provided "
- "graph context AND the extracted file contents to retrieve, synthesize and "
- "explain the answer. Be concise. Answer ONLY from what is provided (graph "
- "context + extracted contents) — never invent files, functions, or facts that "
- "aren't in it.\n\n"
- "EACH answer MUST include source citations so the user can verify where "
- "information came from. For every factual claim, file reference, or code "
- "element you mention, add a citation using this format:\n\n"
- " [source: filename.ext, line/section: XXX]\n\n"
- "Rules for citations:\n"
- " 1. Cite the EXACT file path from the graph context (use the path field).\n"
- " 2. For Python files: cite the function/class name and approximate line "
- " if available, or the module name.\n"
- " 3. For document files (.md, .txt): cite the section heading.\n"
- " 4. For JSON files: cite the key path (e.g. settings > database > host).\n"
- " 5. Place citations inline after the relevant sentence or fact.\n"
- " 6. At the end of your answer, add a '---' separator followed by a "
- " numbered **Sources cited:** section listing each unique source with "
- " its full path so the user can click to open it.\n\n"
- "Example citation format in text:\n"
- " The `process_data()` function handles CSV parsing "
- "[source: src/utils/parser.py, function: process_data].\n\n"
- "Example end-of-answer source list:\n"
- " ---\n"
- " **Sources cited:**\n"
- " 1. `src/utils/parser.py` — process_data function\n"
- " 2. `docs/api.md` — Section: Authentication\n")
- if skill_prefix:
- system += "\n\nFollow this skill:\n" + skill_prefix
- if active_project_id:
- from ..core.projects import load_project, project_context_text
- proj_ctx = project_context_text(load_project(active_project_id))
- if proj_ctx:
- system += "\n\n" + proj_ctx
- user_content = f"Graph context:\n{context}"
- if selected_context:
- user_content += f"\n\nSelected node(s) context (focus your answer on these):\n{selected_context}"
- # Auto-extract the actual file contents (temporary) so the answer is
- # synthesized from real content, not just the graph structure.
- content_block, new_cache = _extract_file_contents(file_paths, extract_cache, extract_dir)
- if content_block:
- user_content += ("\n\nExtracted file contents (read these to answer about file "
- "details/data; cite the file path):\n" + content_block)
- user_content += f"\n\nQuestion: {question}"
- messages = [
- {"role": "system", "content": system},
- {"role": "user", "content": user_content},
- ]
- from ..core import agent_roles, audit_log
- ok = True
- try:
- provider.chat(messages, on_text=lambda t: worker.emit_event({"type": "text", "delta": t}),
- cancel=worker.is_cancelled)
- except Exception:
- ok = False
- raise
- finally:
- audit_log.record("tool_call", "graphrag_ask", ok, question[:500],
- agent_role=agent_roles.KNOWLEDGE)
- return {"extracted": new_cache}
-
- w = AgentWorker(job)
- w.event.connect(self._on_ask_event)
- w.finished_ok.connect(self._on_ask_done)
- w.failed.connect(lambda e: self.detail.setPlainText(f"Error: {e}"))
- self._ask_worker = w
- w.start()
-
- def _on_ask_event(self, ev: dict) -> None:
- if ev.get("type") == "text":
- if self._answer == "":
- self.detail.clear()
- self._answer += ev.get("delta", "")
- self.detail.setPlainText(self._answer)
-
- def _on_ask_done(self, result: dict) -> None:
- # Keep the (temporary) extracted content so repeated questions reuse it
- # without re-extracting — dropped when leaving the tab (_clear_extracts).
- if isinstance(result, dict):
- self._extract_cache.update(result.get("extracted", {}) or {})
- self._render_answer()
-
- # ---- temporary file-content extraction for Q&A ------------------------
- def _candidate_file_paths(self) -> list:
- """File paths to read for a question: the SELECTED file nodes if any, else
- every file node in the graph (capped downstream)."""
- from pathlib import Path as _P
- if self._graph is None:
- return []
- sel = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
- nodes = sel or list(self._graph.nodes)
- out, seen = [], set()
- for nd in nodes:
- p = (getattr(nd, "path", "") or "").strip()
- if p and p not in seen and _P(p).is_file():
- seen.add(p)
- out.append(p)
- return out
-
- def _extract_tmp_dir(self):
- from pathlib import Path as _P
- if self._extract_dir is None:
- import tempfile
- from ..config import CONFIG_DIR
- base = CONFIG_DIR / "tmp" / "graphrag_extract"
- base.mkdir(parents=True, exist_ok=True)
- self._extract_dir = _P(tempfile.mkdtemp(dir=str(base)))
- return self._extract_dir
-
- def _clear_extracts(self) -> None:
- """Discard the temporary extracted content (on leaving the tab / switching
- project). The extraction is a scratch aid, never persisted."""
- self._extract_cache = {}
- d, self._extract_dir = self._extract_dir, None
- if d is not None:
- import shutil
- shutil.rmtree(d, ignore_errors=True)
-
- def hideEvent(self, e): # noqa: N802
- # Leaving the GraphRAG tab → drop the temporary extracted info.
- self._clear_extracts()
- super().hideEvent(e)
-
-
-
-
-
-# --------------------------------------------------------------------------
-# Temporary file-content extraction for Graph-RAG Q&A (runs in the ask worker)
-# --------------------------------------------------------------------------
-def _pdf_to_markdown(pdf_path, out_dir) -> str | None:
- """Convert a PDF to Markdown with opendataloader-pdf when available (richer
- structure than a plain text dump). Best-effort — returns None if the package
- isn't installed or the call fails, so the caller falls back to doc_extract."""
- from pathlib import Path as _P
- try:
- import opendataloader_pdf # optional; auto-installed elsewhere if present
- except Exception: # noqa: BLE001
- try:
- from ..core.deps import ensure_module
- if ensure_module("opendataloader_pdf", "opendataloader-pdf") is None:
- return None
- import opendataloader_pdf # noqa: F811
- except Exception: # noqa: BLE001
- return None
- out = _P(out_dir)
- out.mkdir(parents=True, exist_ok=True)
- for call in (
- lambda: opendataloader_pdf.convert(input_path=[str(pdf_path)], output_dir=str(out),
- generate_markdown=True),
- lambda: opendataloader_pdf.convert(input_path=str(pdf_path), output_dir=str(out)),
- lambda: opendataloader_pdf.convert(str(pdf_path), str(out)),
- ):
- try:
- call()
- break
- except TypeError:
- continue
- except Exception: # noqa: BLE001
- return None
- mds = list(out.rglob(_P(pdf_path).stem + "*.md")) or list(out.rglob("*.md"))
- for md in mds:
- try:
- return md.read_text(encoding="utf-8", errors="replace")
- except OSError:
- continue
- return None
-
-
-def _extract_file_contents(paths, cache: dict, tmp_dir,
- max_files: int = 15, max_total: int = 120_000):
- """Read the ACTUAL content of ``paths`` (PDF→markdown via opendataloader when
- available, else doc_extract for office/pdf/text). Returns ``(block, cache)``
- — ``block`` is the concatenated content for the prompt (bounded), ``cache``
- maps path→text for reuse. Never raises."""
- from pathlib import Path as _P
- from ..core import doc_extract
- cache = dict(cache or {})
- parts, total = [], 0
- for p in paths[:max_files]:
- if total >= max_total:
- break
- text = cache.get(p)
- if text is None:
- try:
- if _P(p).suffix.lower() == ".pdf":
- text = _pdf_to_markdown(p, tmp_dir)
- if not text:
- text, _n = doc_extract.extract_text(p)
- else:
- text, _n = doc_extract.extract_text(p)
- except Exception: # noqa: BLE001
- text = ""
- cache[p] = text or ""
- text = cache.get(p) or ""
- if not text:
- continue
- chunk = text[: max(0, max_total - total)]
- total += len(chunk)
- parts.append(f'--- {_P(p).name} ({p}) ---\n{chunk}')
- return ("\n\n".join(parts), cache)
+"""Structure (RAG) tab — knowledge graph of code / document structure.
+
+Primary view is the D3 knowledge-graph (WebEngine) which gently auto-rotates
+when idle and opens a node's storage folder on click. If WebEngine isn't
+available (e.g. the standalone .exe), a native draggable QGraphicsView is the
+in-app fallback. The graph auto-updates when the Code agent produces output,
+and an Agent box on the right answers questions over the graph (Graph-RAG).
+"""
+from __future__ import annotations
+
+import math
+import re
+import sys
+from pathlib import Path
+
+from PySide6.QtCore import QObject, QPointF, Qt, QTimer, QUrl, Signal, Slot
+from PySide6.QtGui import QBrush, QColor, QFont, QPen
+from PySide6.QtWidgets import (
+ QComboBox, QFileDialog, QGraphicsEllipseItem, QGraphicsLineItem,
+ QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout,
+ QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, QTabBar,
+ QTextBrowser, QVBoxLayout, QWidget,
+)
+
+def _frozen_onefile() -> bool:
+ """True only for a PyInstaller ONEFILE build. Onefile extracts itself to a
+ temp dir (sys._MEIPASS under %TEMP%), where the QtWebEngine helper process
+ can't run — creating a QWebEngineView hard-crashes the app (reported as
+ "click the graph tab → app closes"). A ONEDIR build keeps _MEIPASS as the
+ ``_internal`` folder right next to the exe, where WebEngine works fine, so
+ it keeps the full embedded D3 view."""
+ if not getattr(sys, "frozen", False):
+ return False
+ meipass = getattr(sys, "_MEIPASS", "")
+ if not meipass:
+ return False
+ try:
+ return Path(meipass).resolve().parent != Path(sys.executable).resolve().parent
+ except OSError: # can't tell → play safe: use the native fallback
+ return True
+
+
+try: # WebEngine + WebChannel are optional PySide6 add-ons
+ from PySide6.QtWebEngineWidgets import QWebEngineView
+ from PySide6.QtWebChannel import QWebChannel
+ _HAS_WEB = not _frozen_onefile()
+except Exception: # pragma: no cover
+ _HAS_WEB = False
+
+from ..core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS
+from ..theme import current_palette
+from ..core.worker import AgentWorker
+from ..i18n import on_language_changed, tr
+from ..state import AppContext
+from .icons import collapse_right_icon, icon
+from .osutil import open_folder, open_location
+from .widgets import CollapseStrip
+
+try:
+ from PySide6.QtWidgets import QGraphicsItem # noqa: F401 — ensure available
+except Exception:
+ pass
+
+
+class _Bridge(QObject):
+ """Exposed to the D3 page so a Shift+click on a node can open its
+ storage folder/link (local path or URL — see osutil.open_location)."""
+
+ @Slot(str)
+ def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name
+ if path:
+ open_location(path)
+
+
+class _Edge(QGraphicsLineItem):
+ def __init__(self, a: "_Node", b: "_Node", type_: str = ""):
+ super().__init__()
+ self.a, self.b = a, b
+ self.type = type_
+ # Colour the edge by its RELATIONSHIP type (contains/defines/method/…),
+ # so the graph shows what each connection MEANS — falling back to the
+ # source node's tint for any untyped edge.
+ color = QColor(EDGE_KIND_COLORS.get(type_, "")) if type_ else QColor()
+ if not color.isValid():
+ color = a.brush().color().lighter(130)
+ self._color = color
+ self.setPen(QPen(color, 1.4))
+ self.setZValue(-1)
+ # A small label naming the relationship, shown at the edge midpoint.
+ self._label = None
+ if type_:
+ self._label = QGraphicsSimpleTextItem(type_, self)
+ self._label.setBrush(QBrush(color.lighter(140)))
+ f = QFont()
+ f.setPointSize(7)
+ self._label.setFont(f)
+ self._label.setZValue(0)
+ a.edges.append(self)
+ b.edges.append(self)
+ self.adjust()
+
+ def adjust(self) -> None:
+ pa, pb = self.a.scenePos(), self.b.scenePos()
+ self.setLine(pa.x(), pa.y(), pb.x(), pb.y())
+ if self._label is not None:
+ br = self._label.boundingRect()
+ self._label.setPos((pa.x() + pb.x()) / 2 - br.width() / 2,
+ (pa.y() + pb.y()) / 2 - br.height() / 2)
+
+
+class _Node(QGraphicsEllipseItem):
+ def __init__(self, data, radius: int):
+ super().__init__(-radius, -radius, 2 * radius, 2 * radius)
+ self.data = data
+ self.edges = []
+ tok = current_palette()
+ # NODE_KIND_COLORS is a categorical data encoding (one hue per node
+ # kind), not UI chrome — it stays fixed across themes on purpose so a
+ # given kind is always the same colour. Only the chrome follows tokens.
+ color = QColor(NODE_KIND_COLORS.get(data.kind, tok.text_muted))
+ self.setBrush(QBrush(color))
+ self.setPen(QPen(color.darker(160), 1.5))
+ self.setFlags(
+ QGraphicsEllipseItem.ItemIsMovable
+ | QGraphicsEllipseItem.ItemIsSelectable
+ | QGraphicsEllipseItem.ItemSendsGeometryChanges
+ )
+ self.setZValue(1)
+ label = QGraphicsSimpleTextItem(data.label, self)
+ label.setBrush(QBrush(QColor(tok.text)))
+ label.setPos(radius + 3, -8)
+
+ def itemChange(self, change, value): # noqa: N802
+ if change == QGraphicsEllipseItem.ItemPositionHasChanged:
+ for edge in self.edges:
+ edge.adjust()
+ return super().itemChange(change, value)
+
+
+class _GraphView(QGraphicsView):
+ def __init__(self, scene):
+ super().__init__(scene)
+ self.setDragMode(QGraphicsView.NoDrag)
+ self._panning = False
+ self._pan_start = QPointF()
+
+ def wheelEvent(self, e): # noqa: N802
+ self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15,
+ 1.15 if e.angleDelta().y() > 0 else 1 / 1.15)
+
+ def mousePressEvent(self, e): # noqa: N802
+ if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None:
+ self._panning = True
+ self._pan_start = e.position()
+ self.setCursor(Qt.ClosedHandCursor)
+ e.accept()
+ return
+ super().mousePressEvent(e)
+
+ def mouseMoveEvent(self, e): # noqa: N802
+ if self._panning:
+ delta = e.position() - self._pan_start
+ self._pan_start = e.position()
+ self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().value() - delta.x()))
+ self.verticalScrollBar().setValue(int(self.verticalScrollBar().value() - delta.y()))
+ e.accept()
+ return
+ super().mouseMoveEvent(e)
+
+ def mouseReleaseEvent(self, e): # noqa: N802
+ if self._panning:
+ self._panning = False
+ self.setCursor(Qt.ArrowCursor)
+ e.accept()
+ return
+ super().mouseReleaseEvent(e)
+
+ def mouseDoubleClickEvent(self, e): # noqa: N802
+ """Double-click or Ctrl+click on a node opens its storage folder."""
+ item = self.itemAt(e.pos())
+ if isinstance(item, _Node) and getattr(item.data, "path", ""):
+ open_folder(item.data.path)
+ e.accept()
+ return
+ super().mouseDoubleClickEvent(e)
+
+
+class StructureGraphView(QWidget):
+ status_message = Signal(str)
+
+ def __init__(self, ctx: AppContext):
+ super().__init__()
+ self.ctx = ctx
+ self._worker: AgentWorker | None = None
+ self._node_items: list[_Node] = []
+ self._edge_items: list[_Edge] = []
+ self._centroid = QPointF(0, 0)
+ self._link = 120
+ self._graph = None
+ self._needs_scan = False
+ self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite)
+ self._ask_worker: AgentWorker | None = None
+ self._answer = ""
+ self._detail_mode = "idle" # "answer" | "node" | "idle" — what self.detail shows
+ # TEMPORARY extracted file content for Q&A (real content, not just the
+ # graph structure). Kept only while this tab is shown — cleared on leaving
+ # the tab or switching project/root (see _clear_extracts / hideEvent).
+ self._extract_cache: dict = {} # path -> extracted text
+ self._extract_dir = None # temp folder for md/json dumps
+ self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox
+
+ self._rescan_timer = QTimer(self)
+ self._rescan_timer.setSingleShot(True)
+ self._rescan_timer.setInterval(1500)
+ self._rescan_timer.timeout.connect(self._scan)
+
+ root = QVBoxLayout(self)
+
+ bar = QHBoxLayout()
+ self.path_edit = QLineEdit(str(ctx.config.cowork_output_dir()))
+ self.path_edit.setPlaceholderText(tr("structure.path_placeholder"))
+ self._pick_btn = QPushButton()
+ self._pick_btn.setIcon(icon("folder"))
+ self._pick_btn.setObjectName("primary")
+ self._pick_btn.clicked.connect(self._pick)
+ self.project_combo = QComboBox()
+ self.project_combo.currentIndexChanged.connect(self._on_project_changed)
+ self._scan_btn = QPushButton()
+ self._scan_btn.setIcon(icon("search"))
+ self._scan_btn.setObjectName("primary")
+ self._scan_btn.clicked.connect(self._scan)
+ # ONE toolbar row. There used to be a second row holding just the
+ # messages toggle and Export, which cost a whole row of height to carry
+ # two buttons.
+ self._export_btn = QPushButton()
+ self._export_btn.setIcon(icon("upload"))
+ self._export_btn.setObjectName("primary")
+ self._export_btn.clicked.connect(self._export)
+ bar.addWidget(self.path_edit, 1)
+ bar.addWidget(self._pick_btn)
+ bar.addWidget(self.project_combo)
+ bar.addWidget(self._scan_btn)
+ bar.addWidget(self._export_btn)
+ root.addLayout(bar)
+ self._refresh_project_combo()
+
+ # Đồ thị | Tin nhắn as a real pair of tabs: the old single button
+ # relabelled itself, so the view you were NOT looking at was the only
+ # one named on screen.
+ self.view_tabs = QTabBar()
+ self.view_tabs.setObjectName("viewTabs")
+ self.view_tabs.setDrawBase(False)
+ self.view_tabs.setExpanding(False)
+ self.view_tabs.addTab(icon("graph"), "")
+ self.view_tabs.addTab(icon("message"), "")
+ self.view_tabs.currentChanged.connect(self._on_view_tab)
+ tab_row = QHBoxLayout()
+ tab_row.setContentsMargins(0, 0, 0, 0)
+ tab_row.addWidget(self.view_tabs)
+ tab_row.addStretch(1)
+ root.addLayout(tab_row)
+
+ split = QSplitter(Qt.Horizontal)
+ self.scene = QGraphicsScene()
+ self.scene.setBackgroundBrush(QColor(current_palette().bg))
+ self.scene.selectionChanged.connect(self._on_selection)
+ self.view = _GraphView(self.scene)
+
+ self._stack = QStackedWidget()
+ self._stack.addWidget(self.view)
+ # A "Messages" view: all conversation messages grouped BY DAY, shown as
+ # JSON — a plain tree switched in via setCurrentWidget (never touches the
+ # D3/WebEngine graph). Populated from the (project-scoped) history store.
+ from PySide6.QtWidgets import QTreeWidget
+ self._msgs_view = QTreeWidget()
+ self._msgs_view.setHeaderHidden(True)
+ self._msgs_view.itemClicked.connect(self._show_msg_json)
+ self._stack.addWidget(self._msgs_view)
+ self.web = None
+ self._bridge = None
+ self._channel = None
+
+ # The legend + Show-relationship control live INSIDE the D3 graph
+ # template now (assets/graph_template.html) — the graph column is just
+ # the stack (native view / D3 web / messages).
+ split.addWidget(self._stack)
+
+ # Right-side agent panel (GraphRAG Q&A)
+ right = QWidget()
+ rl = QVBoxLayout(right)
+ rl.setContentsMargins(0, 0, 0, 0)
+
+ # Agent panel header with collapse button
+ ag_hdr = QHBoxLayout()
+ self._ag_collapse = QPushButton()
+ self._ag_collapse.setIcon(collapse_right_icon())
+ self._ag_collapse.setFixedWidth(28)
+ self._ag_collapse.clicked.connect(lambda: self._set_agent_collapsed(True))
+ self._ag_label = QLabel()
+ ag_hdr.addWidget(self._ag_collapse)
+ ag_hdr.addWidget(self._ag_label, 1)
+ rl.addLayout(ag_hdr)
+
+ # Ask row
+ ask_row = QHBoxLayout()
+ self.ask_edit = QLineEdit()
+ self.ask_edit.returnPressed.connect(self._ask)
+ self._ask_btn = QPushButton()
+ self._ask_btn.setIcon(icon("chat"))
+ self._ask_btn.setObjectName("primary")
+ self._ask_btn.clicked.connect(self._ask)
+ ask_row.addWidget(self.ask_edit, 1)
+ ask_row.addWidget(self._ask_btn)
+ rl.addLayout(ask_row)
+
+ # Detail browser
+ self.detail = QTextBrowser()
+ self.detail.setReadOnly(True)
+ self.detail.setOpenLinks(False)
+ self.detail.anchorClicked.connect(self._on_detail_link)
+ rl.addWidget(self.detail, 1)
+
+ self._agent_panel = right
+
+ self._agent_strip = CollapseStrip(tr("structure.expand_agent_tooltip"), expand_dir="left")
+ self._agent_strip.clicked.connect(lambda: self._set_agent_collapsed(False))
+ self._agent_strip.setVisible(False)
+ self._agent_pane = QWidget()
+ apl = QHBoxLayout(self._agent_pane)
+ apl.setContentsMargins(0, 0, 0, 0)
+ apl.setSpacing(0)
+ apl.addWidget(self._agent_strip)
+ apl.addWidget(right, 1)
+
+ self._split = split
+ split.addWidget(self._agent_pane)
+ split.setChildrenCollapsible(False)
+ split.setSizes([840, 320])
+ root.addWidget(split, 1)
+ on_language_changed(self._retranslate)
+
+ def _retranslate(self) -> None:
+ self.path_edit.setPlaceholderText(tr("structure.path_placeholder"))
+ self._pick_btn.setText(tr("structure.browse"))
+ self._scan_btn.setText(tr("structure.scan"))
+ self._export_btn.setText(tr("structure.export_png"))
+ # Both views are named at once now, so neither label depends on state.
+ self.view_tabs.setTabText(0, tr("structure.graph_btn"))
+ self.view_tabs.setTabText(1, tr("structure.msgs_btn"))
+ self.view_tabs.setTabToolTip(1, tr("structure.msgs_tooltip"))
+ self._ag_collapse.setToolTip(tr("structure.collapse_agent_tooltip"))
+ self._ag_label.setText(tr("structure.agent_header"))
+ self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder"))
+ self._ask_btn.setText(tr("structure.ask"))
+ if self._detail_mode == "idle":
+ self.detail.setPlaceholderText(tr("structure.detail_placeholder"))
+ self._agent_strip.setToolTip(tr("structure.expand_agent_tooltip"))
+ self.project_combo.setToolTip(tr("structure.project_tooltip"))
+ self._refresh_project_combo()
+
+ # ---- project sandbox lock -----------------------------------------
+ def _refresh_project_combo(self) -> None:
+ from ..core.projects import list_projects
+
+ keep = self._active_project_id
+ self.project_combo.blockSignals(True)
+ self.project_combo.clear()
+ self.project_combo.addItem(tr("structure.project_none"), "")
+ row_to_select = 0
+ for i, p in enumerate(list_projects(), start=1):
+ self.project_combo.addItem(p.name, p.project_id)
+ if p.project_id == keep:
+ row_to_select = i
+ self.project_combo.setCurrentIndex(row_to_select)
+ self.project_combo.blockSignals(False)
+
+ def set_project(self, project_id: str) -> None:
+ pid = project_id or ""
+ self._refresh_project_combo()
+ target = self.project_combo.findData(pid)
+ if target < 0:
+ target = 0
+ if self.project_combo.currentIndex() == target:
+ self._on_project_changed(target)
+ else:
+ self.project_combo.setCurrentIndex(target)
+
+ def _on_project_changed(self, _idx: int) -> None:
+ from ..core.projects import load_project
+
+ pid = self.project_combo.currentData() or ""
+ project_changed = pid != self._active_project_id
+ if project_changed:
+ self._clear_extracts() # different workspace → drop temp extraction
+ self._active_project_id = pid
+ locked = bool(pid)
+ self.path_edit.setReadOnly(locked)
+ # Also disable the folder-pick button — otherwise the scan path is only
+ # "locked" against typing, but the picker could still repoint it outside
+ # the selected project's sandbox, breaking GraphRAG scope isolation.
+ self._pick_btn.setEnabled(not locked)
+ if locked:
+ project = load_project(pid)
+ if project is not None:
+ self.path_edit.setText(str(project.workspace_dir()))
+ if project_changed:
+ # Mark it and scan on the next visit rather than now. The rail's
+ # project picker made switching a one-click thing from any screen,
+ # and each switch rebuilt this graph — a folder walk plus a force
+ # layout plus a full setHtml of the D3 page — for a tab that was
+ # usually not even on screen. auto_scan_and_fit() picks the flag up
+ # when GraphRAG is actually opened.
+ self._needs_scan = True
+
+ # ---- helpers -----------------------------------------------------
+ def _pick(self) -> None:
+ chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text())
+ if chosen:
+ self.path_edit.setText(chosen)
+
+ def schedule_rescan(self, path: str = "") -> None:
+ if self._graph is None:
+ self._needs_scan = True
+ return
+ self._rescan_timer.start()
+
+ # ---- Messages (by day, as JSON) --------------------------------------
+ def _on_view_tab(self, index: int) -> None:
+ """Tab 0 = graph, tab 1 = messages. Same two views as before, now named
+ on screen instead of hidden behind one button's changing label."""
+ if index == 1:
+ self._reload_messages()
+ self._stack.setCurrentWidget(self._msgs_view)
+ else:
+ self._stack.setCurrentWidget(self.web if self.web is not None else self.view)
+
+ def _toggle_messages(self) -> None:
+ """Kept for callers that still ask for a flip (e.g. keyboard paths)."""
+ showing = self._stack.currentWidget() is self._msgs_view
+ self.view_tabs.setCurrentIndex(0 if showing else 1)
+
+ def _reload_messages(self) -> None:
+ """Build the tree: day → conversation. Click a conversation to see its
+ messages as JSON. Scoped to the current project (its history folder)."""
+ from collections import OrderedDict
+
+ from PySide6.QtCore import Qt
+ from PySide6.QtWidgets import QTreeWidgetItem
+
+ from ..core.history import list_conversations
+ self._msgs_view.clear()
+ pid = self._active_project_id or ""
+ by_day: "OrderedDict[str, list]" = OrderedDict()
+ try:
+ convs = list_conversations(self.ctx.config.history_dir())
+ except Exception: # noqa: BLE001
+ convs = []
+ for conv in convs:
+ if pid and conv.get("project_id", "default") != pid:
+ continue
+ day = (conv.get("created") or "")[:10] or "—"
+ by_day.setdefault(day, []).append(conv)
+ if not by_day:
+ self._msgs_view.addTopLevelItem(QTreeWidgetItem([tr("structure.msgs_none")]))
+ return
+ for day in sorted(by_day, reverse=True):
+ convs_d = by_day[day]
+ day_item = QTreeWidgetItem([f"{day} ({len(convs_d)})"])
+ for conv in convs_d:
+ it = QTreeWidgetItem([conv.get("title", "(untitled)")])
+ it.setData(0, Qt.UserRole, str(conv.get("path", "")))
+ day_item.addChild(it)
+ self._msgs_view.addTopLevelItem(day_item)
+ day_item.setExpanded(True)
+
+ def _show_msg_json(self, item, _col: int = 0) -> None:
+ import html
+ import json
+
+ from PySide6.QtCore import Qt
+
+ from ..core.history import load_conversation
+ path = item.data(0, Qt.UserRole)
+ if not path:
+ return
+ try:
+ conv = load_conversation(path)
+ payload = {"title": conv.get("title", ""), "created": conv.get("created", ""),
+ "kind": conv.get("kind", ""), "project_id": conv.get("project_id", ""),
+ "messages": conv.get("messages", [])}
+ text = json.dumps(payload, ensure_ascii=False, indent=2)
+ except Exception as exc: # noqa: BLE001
+ text = f"(could not read: {exc})"
+ self.detail.setHtml(
+ f'{html.escape(text)}')
+
+ def prewarm(self) -> None:
+ """Pay for the graph view before it is clicked on, not during.
+
+ Opening GraphRAG built a QWebEngineView (~140ms) and scanned the project
+ (~485ms) while an empty browser sat on screen — long enough, and white
+ enough, to read as the app restarting itself. Called from an idle timer
+ after the window is up, so startup itself is unaffected; the memory the
+ lazy construction was saving is spent a few seconds later instead.
+ """
+ if not _HAS_WEB or self.web is not None:
+ return
+ self._ensure_web()
+ if self._graph is None and self.path_edit.text().strip():
+ self._needs_scan = False
+ self._scan() # runs on a worker thread
+
+ def _ensure_web(self) -> None:
+ if self.web is not None or not _HAS_WEB:
+ return
+ self.web = QWebEngineView()
+ # Blank the page in the app's own background first. A fresh
+ # QWebEngineView paints white, and on a dark theme that white rectangle
+ # WAS the flash — it showed for as long as the first scan took.
+ self.web.setHtml(
+ f"")
+ self._bridge = _Bridge()
+ self._channel = QWebChannel()
+ self._channel.registerObject("py", self._bridge)
+ self.web.page().setWebChannel(self._channel)
+ self._stack.addWidget(self.web)
+ self._stack.setCurrentWidget(self.web)
+ if self._graph is not None:
+ self._render_d3()
+
+ def auto_scan_and_fit(self) -> None:
+ self._ensure_web()
+ if not self.path_edit.text().strip():
+ return
+ if getattr(self, "_worker", None) is not None and self._worker.isRunning():
+ self._fit()
+ self._preserve_answer()
+ return
+ if self._graph is not None and not self._needs_scan:
+ self._fit()
+ self._preserve_answer()
+ return
+ self._needs_scan = False
+ self._scan()
+
+ # ---- scan --------------------------------------------------------
+ def _scan(self) -> None:
+ path = self.path_edit.text().strip() or str(Path.cwd())
+ mode = "files" # default: scan all files (filter removed)
+ use_cmem = bool(self.ctx.config.codebase_memory.get("enabled"))
+ cmem_bin = self.ctx.config.codebase_memory.get("binary_path", "")
+ st = self.ctx.config.structure
+ max_nodes = int(st.get("max_nodes", 500) or 0)
+ max_edges = int(st.get("max_edges", 500) or 0)
+ self._scan_seq += 1
+ seq = self._scan_seq
+ self.status_message.emit(tr("structure.scanning"))
+
+ def job(worker: AgentWorker):
+ from ..core.structure_graph import (
+ build_from_codebase_memory, build_from_directory, force_layout,
+ )
+ if use_cmem:
+ from ..core.codebase_memory import CodebaseMemory
+ mem = CodebaseMemory(cmem_bin)
+ graph = (build_from_codebase_memory(mem, path, mode, max_nodes, max_edges)
+ if mem.available else build_from_directory(path, mode, max_nodes, max_edges))
+ else:
+ graph = build_from_directory(path, mode, max_nodes, max_edges)
+ pos = force_layout(graph)
+ return {"graph": graph, "pos": pos, "seq": seq}
+
+ w = AgentWorker(job)
+ w.finished_ok.connect(self._render)
+ w.failed.connect(lambda e: self.status_message.emit(tr("structure.scan_error", err=e)))
+ self._worker = w
+ w.start()
+
+ def _render(self, result: dict) -> None:
+ if result.get("seq") is not None and result["seq"] != self._scan_seq:
+ return
+ graph = result.get("graph")
+ pos = result.get("pos", {})
+ if graph is None:
+ return
+ self._graph = graph
+
+ self.scene.clear()
+ self.scene.setBackgroundBrush(QColor(current_palette().bg)) # restore after clear
+ self._node_items = []
+ self._edge_items = []
+ degree = {n.id: 0 for n in graph.nodes}
+ for e in graph.edges:
+ if e.source in degree:
+ degree[e.source] += 1
+ if e.target in degree:
+ degree[e.target] += 1
+ items = {}
+ sx = sy = 0.0
+ for node in graph.nodes:
+ radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0))))
+ item = _Node(node, radius)
+ x, y = pos.get(node.id, (0, 0))
+ item.setPos(x, y)
+ self.scene.addItem(item)
+ items[node.id] = item
+ self._node_items.append(item)
+ sx += x
+ sy += y
+ for edge in graph.edges:
+ a, b = items.get(edge.source), items.get(edge.target)
+ if a and b:
+ e = _Edge(a, b, getattr(edge, "type", ""))
+ self.scene.addItem(e)
+ self._edge_items.append(e)
+ n = max(1, len(self._node_items))
+ self._centroid = QPointF(sx / n, sy / n)
+ self._fit()
+
+ if self.web is not None:
+ self._render_d3()
+
+ note = tr("structure.truncated_note") if getattr(graph, "truncated", False) else ""
+ self.status_message.emit(tr(
+ "structure.graph_summary", nodes=len(graph.nodes), edges=len(graph.edges), note=note))
+ self._preserve_answer()
+
+ def _render_d3(self) -> None:
+ if self.web is None or self._graph is None:
+ return
+ from ..core.d3_graph import build_html
+ try:
+ self.web.setHtml(build_html(self._graph), QUrl("https://cowork.local/"))
+ except Exception as exc:
+ self.status_message.emit(f"D3 view error: {exc}")
+
+ # ---- native interactions ----------------------------------------
+ def _on_selection(self) -> None:
+ for item in self.scene.selectedItems():
+ if isinstance(item, _Node):
+ d = item.data
+ self.detail.setPlainText(f"[{d.kind.upper()}] {d.label}\n\n{d.detail}")
+ self._detail_mode = "node"
+ return
+
+ def _preserve_answer(self) -> None:
+ if self._detail_mode == "answer" and self._answer.strip():
+ self._render_answer()
+
+ def _set_agent_collapsed(self, collapsed: bool) -> None:
+ strip_w = CollapseStrip.WIDTH + 2
+ self._agent_panel.setVisible(not collapsed)
+ self._agent_strip.setVisible(collapsed)
+ if collapsed:
+ self._agent_pane.setMaximumWidth(strip_w)
+ sizes = self._split.sizes()
+ if len(sizes) == 2:
+ self._split.setSizes([max(1, sum(sizes) - strip_w), strip_w])
+ else:
+ self._agent_pane.setMaximumWidth(16777215)
+ self._split.setSizes([840, 320])
+
+ def _fit(self) -> None:
+ if self.web is not None and self._stack.currentWidget() is self.web:
+ self.web.page().runJavaScript("window.fitGraph && window.fitGraph();")
+ return
+ rect = self.scene.itemsBoundingRect()
+ if not rect.isNull():
+ self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
+
+ def _export(self) -> None:
+ path, _ = QFileDialog.getSaveFileName(
+ self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)")
+ if not path:
+ return
+ showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web)
+ if showing_d3:
+ self._export_d3_png(path)
+ else:
+ self._export_widget_grab(path)
+
+ def _export_d3_png(self, path: str) -> None:
+ def on_result(data_url) -> None:
+ if not isinstance(data_url, str) or "," not in data_url:
+ self._export_widget_grab(path)
+ return
+ import base64
+ try:
+ with open(path, "wb") as f:
+ f.write(base64.b64decode(data_url.split(",", 1)[1]))
+ self.status_message.emit(tr("structure.export_done", path=path))
+ except (OSError, ValueError) as exc:
+ self.status_message.emit(tr("structure.export_failed", err=str(exc)))
+ self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result)
+
+ def _export_widget_grab(self, path: str) -> None:
+ ok = self._stack.currentWidget().grab().save(path, "PNG")
+ if ok:
+ self.status_message.emit(tr("structure.export_done", path=path))
+ else:
+ self.status_message.emit(tr("structure.export_failed", err="grab() returned no image"))
+
+ # ---- agent Q&A over the graph -----------------------------------
+ @staticmethod
+ def _graph_context(graph) -> str:
+ from collections import defaultdict
+ by_kind = defaultdict(list)
+ for n in graph.nodes:
+ by_kind[n.kind].append(n.label)
+ lines = []
+ for kind in ("file", "class", "function", "method", "module", "section"):
+ items = by_kind.get(kind, [])
+ if items:
+ lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60]))
+ id2label = {n.id: n.label for n in graph.nodes}
+ rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}"
+ for e in graph.edges[:140]]
+ if rels:
+ lines.append("Relationships (sample):\n" + "\n".join(rels))
+ return "\n".join(lines)[:7000]
+
+ def _matched_sources(self, text: str):
+ if self._graph is None or not text:
+ return []
+ found: dict[str, tuple[str, str, str]] = {}
+ for n in self._graph.nodes:
+ if not n.path:
+ continue
+ label = n.label.rstrip("()")
+ if len(label) < 3:
+ continue
+ if n.path not in found and re.search(rf"\b{re.escape(label)}\b", text):
+ found[n.path] = (n.kind, n.label, n.detail or n.path)
+ return sorted(found.items(), key=lambda kv: kv[1][1].lower())[:12]
+
+ def _linkify_files(self, text: str, sources) -> str:
+ """Turn file/entity NAMES mentioned in the answer into clickable links that
+ open the file — so the user can click a name in the answer to view it."""
+ for path, (kind, label, rel) in sources:
+ href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
+ tokens = []
+ base = Path(path).name
+ if base and len(base) >= 3:
+ tokens.append(base)
+ lab = (label or "").rstrip("()").strip()
+ if lab and lab != base and len(lab) >= 3:
+ tokens.append(lab)
+ for tok in tokens:
+ esc = re.escape(tok)
+ # `tok` (code span) → keep the code style but make it a link
+ text = re.sub(rf"`{esc}`", f"[`{tok}`]({href})", text)
+ # bare tok, not already inside a link / path / code span
+ text = re.sub(rf"(? None:
+ text = self._answer
+ sources = self._matched_sources(text)
+ if sources:
+ # 1) Make the file/entity names IN THE ANSWER clickable (open on click).
+ text = self._linkify_files(text, sources)
+ # 2) Append a clickable "Related sources" section listing each file.
+ lines = [text, "", "---", f"**{tr('structure.related_sources')}**"]
+ for path, (kind, label, rel) in sources:
+ href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
+ # kind badge for context (file/function/section/json_key)
+ kind_badge = f" [{kind.upper()}]" if kind not in ("file",) else ""
+ lines.append(f"- **[{label}⧉]({href})**{kind_badge} — `{rel}`")
+ text = "\n".join(lines)
+ self.detail.setMarkdown(text)
+
+ def _on_detail_link(self, url: QUrl) -> None:
+ if url.isLocalFile():
+ p = url.toLocalFile()
+ # Open the FILE itself for viewing (fall back to its folder for a dir).
+ if Path(p).is_file():
+ open_location(p)
+ else:
+ open_folder(p)
+
+ def _ask(self) -> None:
+ question = self.ask_edit.text().strip()
+ if not question:
+ return
+ from ..core.skills import parse_skill_command
+ skill_prefix, question, info = parse_skill_command(question)
+ if info is not None:
+ self.detail.setMarkdown(info)
+ self._detail_mode = "answer"
+ self.ask_edit.clear()
+ return
+ if self._graph is None:
+ self.status_message.emit(tr("structure.scan_first"))
+ return
+ context = self._graph_context(self._graph)
+ # Real file CONTENT to answer from (extracted temporarily in the worker):
+ file_paths = self._candidate_file_paths()
+ extract_cache = dict(self._extract_cache)
+ extract_dir = str(self._extract_tmp_dir())
+ self._answer = ""
+ self._detail_mode = "answer"
+ self.detail.setPlainText("…")
+ self.ask_edit.clear()
+
+ active_project_id = self._active_project_id
+
+ # Collect selected node context for auto-filtering
+ selected_nodes = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
+ selected_context = ""
+ if selected_nodes:
+ node_lines = []
+ for nd in selected_nodes:
+ node_lines.append(f"- {nd.label} (kind: {nd.kind}, path: {getattr(nd, 'path', '')})")
+ if nd.detail:
+ node_lines.append(f" detail: {nd.detail}")
+ # Also gather connected nodes
+ connected_ids = set()
+ for nd in selected_nodes:
+ for edge in self._graph.edges:
+ if edge.source == nd.id:
+ connected_ids.add(edge.target)
+ elif edge.target == nd.id:
+ connected_ids.add(edge.source)
+ connected_nodes = [n for n in self._graph.nodes if n.id in connected_ids]
+ if connected_nodes:
+ node_lines.append("\nConnected nodes:")
+ for cn in connected_nodes:
+ node_lines.append(f"- {cn.label} (kind: {cn.kind})")
+ selected_context = "\n".join(node_lines)
+
+ def job(worker: AgentWorker):
+ provider = self.ctx.build_active_provider()
+ system = ("You answer questions about a code/document knowledge graph. Use the provided "
+ "graph context AND the extracted file contents to retrieve, synthesize and "
+ "explain the answer. Be concise. Answer ONLY from what is provided (graph "
+ "context + extracted contents) — never invent files, functions, or facts that "
+ "aren't in it.\n\n"
+ "EACH answer MUST include source citations so the user can verify where "
+ "information came from. For every factual claim, file reference, or code "
+ "element you mention, add a citation using this format:\n\n"
+ " [source: filename.ext, line/section: XXX]\n\n"
+ "Rules for citations:\n"
+ " 1. Cite the EXACT file path from the graph context (use the path field).\n"
+ " 2. For Python files: cite the function/class name and approximate line "
+ " if available, or the module name.\n"
+ " 3. For document files (.md, .txt): cite the section heading.\n"
+ " 4. For JSON files: cite the key path (e.g. settings > database > host).\n"
+ " 5. Place citations inline after the relevant sentence or fact.\n"
+ " 6. At the end of your answer, add a '---' separator followed by a "
+ " numbered **Sources cited:** section listing each unique source with "
+ " its full path so the user can click to open it.\n\n"
+ "Example citation format in text:\n"
+ " The `process_data()` function handles CSV parsing "
+ "[source: src/utils/parser.py, function: process_data].\n\n"
+ "Example end-of-answer source list:\n"
+ " ---\n"
+ " **Sources cited:**\n"
+ " 1. `src/utils/parser.py` — process_data function\n"
+ " 2. `docs/api.md` — Section: Authentication\n")
+ if skill_prefix:
+ system += "\n\nFollow this skill:\n" + skill_prefix
+ if active_project_id:
+ from ..core.projects import load_project, project_context_text
+ proj_ctx = project_context_text(load_project(active_project_id))
+ if proj_ctx:
+ system += "\n\n" + proj_ctx
+ user_content = f"Graph context:\n{context}"
+ if selected_context:
+ user_content += f"\n\nSelected node(s) context (focus your answer on these):\n{selected_context}"
+ # Auto-extract the actual file contents (temporary) so the answer is
+ # synthesized from real content, not just the graph structure.
+ content_block, new_cache = _extract_file_contents(file_paths, extract_cache, extract_dir)
+ if content_block:
+ user_content += ("\n\nExtracted file contents (read these to answer about file "
+ "details/data; cite the file path):\n" + content_block)
+ user_content += f"\n\nQuestion: {question}"
+ messages = [
+ {"role": "system", "content": system},
+ {"role": "user", "content": user_content},
+ ]
+ from ..core import agent_roles, audit_log
+ ok = True
+ try:
+ provider.chat(messages, on_text=lambda t: worker.emit_event({"type": "text", "delta": t}),
+ cancel=worker.is_cancelled)
+ except Exception:
+ ok = False
+ raise
+ finally:
+ audit_log.record("tool_call", "graphrag_ask", ok, question[:500],
+ agent_role=agent_roles.KNOWLEDGE)
+ return {"extracted": new_cache}
+
+ w = AgentWorker(job)
+ w.event.connect(self._on_ask_event)
+ w.finished_ok.connect(self._on_ask_done)
+ w.failed.connect(lambda e: self.detail.setPlainText(f"Error: {e}"))
+ self._ask_worker = w
+ w.start()
+
+ def _on_ask_event(self, ev: dict) -> None:
+ if ev.get("type") == "text":
+ if self._answer == "":
+ self.detail.clear()
+ self._answer += ev.get("delta", "")
+ self.detail.setPlainText(self._answer)
+
+ def _on_ask_done(self, result: dict) -> None:
+ # Keep the (temporary) extracted content so repeated questions reuse it
+ # without re-extracting — dropped when leaving the tab (_clear_extracts).
+ if isinstance(result, dict):
+ self._extract_cache.update(result.get("extracted", {}) or {})
+ self._render_answer()
+
+ # ---- temporary file-content extraction for Q&A ------------------------
+ def _candidate_file_paths(self) -> list:
+ """File paths to read for a question: the SELECTED file nodes if any, else
+ every file node in the graph (capped downstream)."""
+ from pathlib import Path as _P
+ if self._graph is None:
+ return []
+ sel = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
+ nodes = sel or list(self._graph.nodes)
+ out, seen = [], set()
+ for nd in nodes:
+ p = (getattr(nd, "path", "") or "").strip()
+ if p and p not in seen and _P(p).is_file():
+ seen.add(p)
+ out.append(p)
+ return out
+
+ def _extract_tmp_dir(self):
+ from pathlib import Path as _P
+ if self._extract_dir is None:
+ import tempfile
+ from ..config import CONFIG_DIR
+ base = CONFIG_DIR / "tmp" / "graphrag_extract"
+ base.mkdir(parents=True, exist_ok=True)
+ self._extract_dir = _P(tempfile.mkdtemp(dir=str(base)))
+ return self._extract_dir
+
+ def _clear_extracts(self) -> None:
+ """Discard the temporary extracted content (on leaving the tab / switching
+ project). The extraction is a scratch aid, never persisted."""
+ self._extract_cache = {}
+ d, self._extract_dir = self._extract_dir, None
+ if d is not None:
+ import shutil
+ shutil.rmtree(d, ignore_errors=True)
+
+ def hideEvent(self, e): # noqa: N802
+ # Leaving the GraphRAG tab → drop the temporary extracted info.
+ self._clear_extracts()
+ super().hideEvent(e)
+
+
+
+
+
+# --------------------------------------------------------------------------
+# Temporary file-content extraction for Graph-RAG Q&A (runs in the ask worker)
+# --------------------------------------------------------------------------
+def _pdf_to_markdown(pdf_path, out_dir) -> str | None:
+ """Convert a PDF to Markdown with opendataloader-pdf when available (richer
+ structure than a plain text dump). Best-effort — returns None if the package
+ isn't installed or the call fails, so the caller falls back to doc_extract."""
+ from pathlib import Path as _P
+ try:
+ import opendataloader_pdf # optional; auto-installed elsewhere if present
+ except Exception: # noqa: BLE001
+ try:
+ from ..core.deps import ensure_module
+ if ensure_module("opendataloader_pdf", "opendataloader-pdf") is None:
+ return None
+ import opendataloader_pdf # noqa: F811
+ except Exception: # noqa: BLE001
+ return None
+ out = _P(out_dir)
+ out.mkdir(parents=True, exist_ok=True)
+ for call in (
+ lambda: opendataloader_pdf.convert(input_path=[str(pdf_path)], output_dir=str(out),
+ generate_markdown=True),
+ lambda: opendataloader_pdf.convert(input_path=str(pdf_path), output_dir=str(out)),
+ lambda: opendataloader_pdf.convert(str(pdf_path), str(out)),
+ ):
+ try:
+ call()
+ break
+ except TypeError:
+ continue
+ except Exception: # noqa: BLE001
+ return None
+ mds = list(out.rglob(_P(pdf_path).stem + "*.md")) or list(out.rglob("*.md"))
+ for md in mds:
+ try:
+ return md.read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ continue
+ return None
+
+
+def _extract_file_contents(paths, cache: dict, tmp_dir,
+ max_files: int = 15, max_total: int = 120_000):
+ """Read the ACTUAL content of ``paths`` (PDF→markdown via opendataloader when
+ available, else doc_extract for office/pdf/text). Returns ``(block, cache)``
+ — ``block`` is the concatenated content for the prompt (bounded), ``cache``
+ maps path→text for reuse. Never raises."""
+ from pathlib import Path as _P
+ from ..core import doc_extract
+ cache = dict(cache or {})
+ parts, total = [], 0
+ for p in paths[:max_files]:
+ if total >= max_total:
+ break
+ text = cache.get(p)
+ if text is None:
+ try:
+ if _P(p).suffix.lower() == ".pdf":
+ text = _pdf_to_markdown(p, tmp_dir)
+ if not text:
+ text, _n = doc_extract.extract_text(p)
+ else:
+ text, _n = doc_extract.extract_text(p)
+ except Exception: # noqa: BLE001
+ text = ""
+ cache[p] = text or ""
+ text = cache.get(p) or ""
+ if not text:
+ continue
+ chunk = text[: max(0, max_total - total)]
+ total += len(chunk)
+ parts.append(f'--- {_P(p).name} ({p}) ---\n{chunk}')
+ return ("\n\n".join(parts), cache)
diff --git a/ui/task_editor_dialog.py b/ui/task_editor_dialog.py
index 9d135d8..ba608fa 100644
--- a/ui/task_editor_dialog.py
+++ b/ui/task_editor_dialog.py
@@ -19,7 +19,7 @@ from PySide6.QtWidgets import (
QCheckBox, QComboBox, QDateTimeEdit, QDialog, QDialogButtonBox, QFileDialog,
QFormLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit,
QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton,
- QScrollArea, QSpinBox, QVBoxLayout, QWidget,
+ QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
)
from ..config import PROVIDER_LABELS
@@ -70,28 +70,36 @@ class TaskEditorDialog(QDialog):
self.edited_task: Optional[dict] = None
self.setWindowTitle(tr("schedtask.editor_title_edit" if task else "schedtask.editor_title_new"))
self.resize(560, 680)
- # Flat inputs: every field (text, list, combo, spin, date) is transparent
- # so it shows the page background (the app theme otherwise fills inputs
- # with a lighter box) — just a light outline, consistent with the rest of
- # the app. The combo drop-down popup keeps a solid dark background so its
- # items stay readable.
- self.setStyleSheet(
- "QLineEdit, QPlainTextEdit, QListWidget, QComboBox, QAbstractSpinBox {"
- " background: transparent; border: 1px solid rgba(140,146,152,0.45);"
- " border-radius: 6px; }"
- "QListWidget::item { background: transparent; }"
- "QComboBox QAbstractItemView { background: #111D32; color: #E0F0FF; }")
+ # Only the files/links/depends-on lists go flat (transparent, no boxed
+ # panel) — they sit right next to their own +/trash buttons, which is
+ # enough affordance without a filled background. Title/Description/
+ # Prompt/combos etc. keep the app's normal raised-surface + border
+ # look (theme.py's default for these widget types): a transparent
+ # single/multi-line box with only a 1px border was tried here and
+ # turned out too faint against the group's own background to read as
+ # an editable field at all ("không thể nhận ra ô textbox của prompt").
+ # Scoped to #flatList, not bare QListWidget — that would also blank
+ # out the sectionIndex sidebar's :selected highlight (set by the app
+ # theme), since a stylesheet set directly on this dialog overrides the
+ # app-wide one for every descendant it matches, regardless of the
+ # theme rule's own selector specificity.
+ self.setStyleSheet("QListWidget#flatList, QListWidget#flatList::item { background: transparent; }")
outer = QVBoxLayout(self)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # down only
content = QWidget()
scroll.setWidget(content)
outer.addWidget(scroll, 1)
root = QVBoxLayout(content)
# ---- basics ----------------------------------------------------
- form = QFormLayout()
+ # A QGroupBox like the other four step pages (Schedule/Input/
+ # Dependency/Execution), so this one isn't the odd one out once it's
+ # moved into its own step page below (bare background, no title).
+ self._basic_box = QGroupBox(tr("schedtask.g_basic"))
+ form = QFormLayout(self._basic_box)
self.title_edit = QLineEdit(self.task.get("title", ""))
# Description is the source of truth. Its ✨ button GENERATES the Prompt
# (Input) FROM the description — the title is just the task's label and
@@ -204,7 +212,7 @@ class TaskEditorDialog(QDialog):
form.addRow(tr("schedtask.f_skill"), self.skill_combo)
form.addRow(tr("schedtask.f_priority"), self.priority_combo)
form.addRow(tr("schedtask.f_status"), self.status_combo)
- root.addLayout(form)
+ root.addWidget(self._basic_box)
self._main_form = form
self._model_box = model_box
self._on_run_kind_changed() # apply agent/flow row visibility
@@ -316,6 +324,7 @@ class TaskEditorDialog(QDialog):
# multi-select file dialog and APPENDS (never wipes what's already
# there), the trash button removes just the selected row(s).
self.files_list = QListWidget()
+ self.files_list.setObjectName("flatList")
self.files_list.setMaximumHeight(90)
self.files_list.setSelectionMode(QListWidget.ExtendedSelection)
for p in inp.get("file_paths", []) or []:
@@ -341,6 +350,7 @@ class TaskEditorDialog(QDialog):
# Links — same "+"-list pattern; "+" prompts for one URL at a time
# (fetched best-effort and inlined as context, same as file attachments).
self.links_list = QListWidget()
+ self.links_list.setObjectName("flatList")
self.links_list.setMaximumHeight(90)
self.links_list.setSelectionMode(QListWidget.ExtendedSelection)
for u in inp.get("links", []) or []:
@@ -390,6 +400,7 @@ class TaskEditorDialog(QDialog):
# Fan-in: tick every task this one must WAIT for — it won't run until
# ALL of them are Done (parallel predecessors feeding one successor).
self.depends_list = QListWidget()
+ self.depends_list.setObjectName("flatList")
self.depends_list.setMaximumHeight(96)
current_deps = set(dep.get("depends_on") or [])
for t in self.all_tasks:
@@ -426,6 +437,44 @@ class TaskEditorDialog(QDialog):
eform.addRow("", self.approval_chk)
root.addWidget(eg)
+ # Three steps, as tabs: Nội dung → Lịch chạy → Liên kết. The five group
+ # boxes are re-parented into three pages — none is dropped, they are
+ # grouped by the question being answered rather than stacked in one
+ # scroll where the later ones are out of sight.
+ # Left list + right panel, navigated exactly like Settings — five rows
+ # matching the five real group boxes, so you always see which group you
+ # are in and how many are left. (Not tabs: the audit page asks for this
+ # shape specifically, for consistency with Settings.)
+ from .widgets import section_panels
+
+ self._step_keys = ["schedtask.g_basic", "schedtask.g_schedule",
+ "schedtask.g_input", "schedtask.g_dependency",
+ "schedtask.g_execution"]
+ pages = []
+ for key, group in zip(self._step_keys, [self._basic_box, sg, ig, dg, eg]):
+ page = QWidget()
+ pv = QVBoxLayout(page)
+ pv.setContentsMargins(4, 4, 4, 4)
+ root.removeWidget(group)
+ pv.addWidget(group)
+ pv.addStretch(1)
+ wrap = QScrollArea()
+ wrap.setWidgetResizable(True)
+ wrap.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ wrap.setWidget(page)
+ pages.append((tr(key), wrap))
+ self.section_list, self.section_stack = section_panels(pages)
+ outer.removeWidget(scroll)
+ scroll.setParent(None)
+ body = QHBoxLayout()
+ body.setSpacing(10)
+ body.addWidget(self.section_list)
+ body.addWidget(self.section_stack, 1)
+ outer.insertLayout(0, body, 1)
+ # Floor the width at what the widest page needs, at the font in use.
+ widest = max(w.widget().sizeHint().width() for _lab, w in pages)
+ self.setMinimumWidth(self.section_list.width() + widest + 60)
+
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.button(QDialogButtonBox.Save).setIcon(icon("save"))
buttons.button(QDialogButtonBox.Cancel).setIcon(icon("close"))
@@ -438,6 +487,11 @@ class TaskEditorDialog(QDialog):
from .widgets import guard_wheel
guard_wheel(self)
+ def _retranslate_steps(self) -> None:
+ """Re-label the five section rows for the current language."""
+ for i, key in enumerate(self._step_keys):
+ self.section_list.item(i).setText(tr(key))
+
def _apply_hints(self) -> None:
"""Tooltip hints on every non-obvious control, so each option explains
itself on hover."""
diff --git a/ui/terminal_panel.py b/ui/terminal_panel.py
index dede3cf..2d7a728 100644
--- a/ui/terminal_panel.py
+++ b/ui/terminal_panel.py
@@ -27,6 +27,7 @@ from PySide6.QtWidgets import (
)
from ..i18n import on_language_changed, tr
+from ..theme import current_palette
from .icons import icon
_IS_WIN = sys.platform == "win32"
@@ -75,8 +76,10 @@ class TerminalPanel(QWidget):
# ---- header (always visible; click to expand/collapse) --------------
self._header = QFrame()
self._header.setObjectName("termHeader")
+ _tp = current_palette()
self._header.setStyleSheet(
- "#termHeader { background: rgba(0,0,0,0.06); border-radius: 6px; }")
+ f"#termHeader {{ background: {_tp.surface};"
+ f" border-radius: {_tp.radius}px; }}")
hb = QHBoxLayout(self._header)
hb.setContentsMargins(8, 4, 8, 4)
self._toggle_btn = QPushButton()
@@ -107,8 +110,7 @@ class TerminalPanel(QWidget):
mono.setStyleHint(QFont.Monospace)
mono.setPointSize(10)
self.output.setFont(mono)
- self.output.setStyleSheet(
- "#termOutput { background: #1e1e1e; color: #d4d4d4; border: none; }")
+ # Surface comes from the central style sheet (#termOutput) — see theme.py.
self.output.setMinimumHeight(160)
bl.addWidget(self.output, 1)
@@ -119,9 +121,7 @@ class TerminalPanel(QWidget):
self.input = _TermInput()
self.input.setObjectName("termInput")
self.input.setFont(mono)
- self.input.setStyleSheet(
- "#termInput { background: #1e1e1e; color: #d4d4d4; border: 1px solid #3c3c3c; "
- "border-radius: 6px; padding: 4px 8px; }")
+ # Surface comes from the central style sheet (#termInput) — see theme.py.
self.input.returnPressed.connect(self._run_current)
self.input.complete_requested.connect(self._complete)
self.input.history_prev.connect(lambda: self._history_move(-1))
@@ -282,11 +282,12 @@ class TerminalPanel(QWidget):
if not text:
return
from PySide6.QtGui import QColor, QTextCursor
- colors = {"cmd": "#4ec9b0", "err": "#f48771", "ok": "#6a9955", "out": "#d4d4d4"}
+ p = current_palette()
+ colors = {"cmd": p.code_type, "err": p.code_error, "ok": p.code_comment, "out": p.code_fg}
cursor = self.output.textCursor()
cursor.movePosition(QTextCursor.End)
fmt = cursor.charFormat()
- fmt.setForeground(QColor(colors.get(role, "#d4d4d4")))
+ fmt.setForeground(QColor(colors.get(role, p.code_fg)))
cursor.setCharFormat(fmt)
cursor.insertText(text)
self.output.setTextCursor(cursor)
diff --git a/ui/tools_admin_tab.py b/ui/tools_admin_tab.py
index d939057..226008f 100644
--- a/ui/tools_admin_tab.py
+++ b/ui/tools_admin_tab.py
@@ -2,8 +2,9 @@
Two sub-tabs:
* "Tool" — built-in agent tools (read/write/edit files, run commands,
- install packages, fetch URLs); toggling one OFF removes it
- from the agent's toolset (persisted in ``config.tools_disabled``).
+ install packages, fetch URLs) as a left-aligned card grid;
+ toggling one OFF removes it from the agent's toolset
+ (persisted in ``config.tools_disabled``).
* "Connector" — the full Connectors (MCP / REST API) setup, moved here from
Settings: add/edit/delete CAD/CAE/MS365/Other connectors and
enable/disable each (``ConnectorsPanel``).
@@ -11,9 +12,10 @@ Two sub-tabs:
from __future__ import annotations
from PySide6.QtCore import Qt
+from PySide6.QtGui import QColor, QPainter, QPixmap
from PySide6.QtWidgets import (
- QCheckBox, QHBoxLayout, QHeaderView, QLabel, QPushButton,
- QTableWidget, QTableWidgetItem, QTabWidget, QVBoxLayout, QWidget,
+ QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTabWidget,
+ QVBoxLayout, QWidget,
)
from ..core.tools import TOOL_SPECS
@@ -22,18 +24,46 @@ from ..i18n import on_language_changed, tr
from ..state import AppContext
from .connectors_panel import ConnectorsPanel
from .icons import icon
+from .widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_card
+
+# Identity colour + icon per built-in tool — same "fixed colour regardless of
+# theme" convention as monitoring_tab.py's agent avatars / agents_admin_tab.py's
+# kind avatars, grouped by what the tool actually touches (file i/o, shell,
+# packages, network, Jira).
+_TOOL_COLOUR = {
+ "read_file": "#0078D4", "list_dir": "#0078D4", "write_file": "#0078D4",
+ "edit_file": "#0078D4", "run_command": "#107C10", "install_package": "#8764B8",
+ "fetch_url": "#FFB900", "jira_search": "#8764B8", "jira_get_issue": "#8764B8",
+}
+_TOOL_ICON_NAME = {
+ "read_file": "document", "list_dir": "folder", "write_file": "new",
+ "edit_file": "edit", "run_command": "terminal", "install_package": "download",
+ "fetch_url": "globe", "jira_search": "search", "jira_get_issue": "link",
+}
-def _center_checkbox(checked: bool, on_toggle) -> QWidget:
- box = QWidget()
- lay = QHBoxLayout(box)
- lay.setContentsMargins(0, 0, 0, 0)
- lay.setAlignment(Qt.AlignCenter)
- chk = QCheckBox()
- chk.setChecked(checked)
- chk.toggled.connect(on_toggle)
- lay.addWidget(chk)
- return box
+def _tool_icon_pixmap(name: str, size: int = 28) -> QPixmap:
+ pm = QPixmap(size, size)
+ pm.fill(Qt.transparent)
+ p = QPainter(pm)
+ p.setRenderHint(QPainter.Antialiasing)
+ p.setPen(Qt.NoPen)
+ p.setBrush(QColor(_TOOL_COLOUR.get(name, "#0078D4")))
+ r = size * 0.28
+ p.drawRoundedRect(0, 0, size, size, r, r)
+ inner = int(size * 0.58)
+ glyph = icon(_TOOL_ICON_NAME.get(name, "puzzle"), size=inner, color="#FFFFFF").pixmap(inner, inner)
+ p.drawPixmap((size - inner) // 2, (size - inner) // 2, glyph)
+ p.end()
+ return pm
+
+
+def _clear_flow(flow: FlowLayout) -> None:
+ while flow.count():
+ item = flow.takeAt(0)
+ w = item.widget()
+ if w is not None:
+ w.deleteLater()
class ToolsAdminTab(QWidget):
@@ -54,21 +84,20 @@ class ToolsAdminTab(QWidget):
self._hint.setWordWrap(True)
tl.addWidget(self._hint)
- self.table = QTableWidget(0, 3)
- self.table.setEditTriggers(QTableWidget.NoEditTriggers)
- self.table.verticalHeader().setVisible(False)
- # Description is the long column — IT stretches to fill remaining
- # width (was Name, leaving Description squeezed into whatever was
- # left over); Name/Enabled size to their own content.
- self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
- self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
- self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
- self.table.setWordWrap(True)
- tl.addWidget(self.table, 1)
+ # A left-aligned, wrapping card grid — one card per built-in tool
+ # (colour-coded icon + name + toggle switch + description), replacing
+ # the old flat Name/Description/Enabled table.
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QScrollArea.NoFrame)
+ cards_host = QWidget()
+ self._tool_flow = FlowLayout(cards_host, margin=0, h_spacing=10, v_spacing=10)
+ scroll.setWidget(cards_host)
+ tl.addWidget(scroll, 1)
- # "Test Internet" self-test lives INSIDE the fetch_url tool row now (see
- # refresh) instead of a separate boxed section — persistent widgets so
- # they survive table rebuilds.
+ # "Test Internet" self-test lives INSIDE the fetch_url tool's card now
+ # (see refresh) instead of a separate boxed section — persistent
+ # widgets so they survive card rebuilds.
self.test_internet_btn = QPushButton(tr("settings.test_internet"))
self.test_internet_btn.setIcon(icon("globe"))
self.test_internet_btn.setToolTip(tr("settings.test_internet_tooltip"))
@@ -94,51 +123,71 @@ class ToolsAdminTab(QWidget):
self.connectors_panel = ConnectorsPanel(ctx)
self.subtabs.addTab(self.connectors_panel, "")
+ # on_language_changed() already invokes _retranslate() once immediately
+ # (see i18n.py) — a second explicit call here double-populates the
+ # card grid back-to-back with no event-loop turn in between, so the
+ # first pass's cards are only queued for deleteLater() (not yet gone)
+ # when the second pass adds new ones on top (see connectors_panel.py's
+ # ConnectorsPanel, which hit the exact same bug this same way).
on_language_changed(self._retranslate)
- self._retranslate()
- # ---- built-in tools table -------------------------------------------------
+ # ---- built-in tools card grid ---------------------------------------------
def refresh(self) -> None:
disabled = set(self.ctx.config.tools_disabled)
- specs = list(TOOL_SPECS)
- self.table.setRowCount(len(specs))
- for r, spec in enumerate(specs):
- self.table.setItem(r, 0, QTableWidgetItem(spec.name))
- # Full description (was truncated to 80 chars, hiding the rest) —
- # word-wraps inside the stretched column; resizeRowToContents
- # below grows the row to fit however many lines that takes.
- if spec.name == "fetch_url":
- # This tool's row carries the live "Test Internet" self-test
- # right below its description — no separate boxed section.
- self.table.setItem(r, 1, None)
- self.table.setCellWidget(r, 1, self._fetch_url_desc_cell(spec))
- else:
- desc_item = QTableWidgetItem(spec.description)
- desc_item.setToolTip(spec.description)
- self.table.setItem(r, 1, desc_item)
- self.table.setCellWidget(
- r, 2, _center_checkbox(spec.name not in disabled,
- lambda on, n=spec.name: self._toggle_builtin(n, on)))
- # Once ALL rows/columns are populated (so the stretched Description
- # column has its real width), grow each row to fit its wrapped text.
- self.table.resizeRowsToContents()
+ _clear_flow(self._tool_flow)
+ for spec in TOOL_SPECS:
+ self._tool_flow.addWidget(self._tool_card(spec, spec.name not in disabled))
+
+ def _tool_card(self, spec, enabled: bool) -> QWidget:
+ card = QFrame()
+ card.setFrameShape(QFrame.NoFrame)
+ style_card(card)
+ card.setFixedWidth(220)
+ # The description below wraps to a variable number of lines at this
+ # fixed width, so the card's own height depends on its width — without
+ # this, the outer FlowLayout's QWidgetItem queries card.sizePolicy()
+ # (not the description label's), gets a too-short sizeHint, and
+ # squeezes the card into less height than its QVBoxLayout needs,
+ # which is what overlapped the header onto the description text.
+ enable_height_for_width(card)
+ lay = QVBoxLayout(card)
+ lay.setContentsMargins(10, 8, 10, 8)
+ lay.setSpacing(4)
+
+ hdr = QHBoxLayout()
+ icon_lbl = QLabel()
+ icon_lbl.setPixmap(_tool_icon_pixmap(spec.name))
+ icon_lbl.setStyleSheet("border: none;")
+ hdr.addWidget(icon_lbl)
+ name_lbl = QLabel(spec.name)
+ name_lbl.setStyleSheet("font-weight:700; border: none;")
+ hdr.addWidget(name_lbl)
+ hdr.addStretch(1)
+ sw = ToggleSwitch()
+ sw.setChecked(enabled)
+ sw.toggled.connect(lambda on, n=spec.name: self._toggle_builtin(n, on))
+ hdr.addWidget(sw)
+ lay.addLayout(hdr)
- def _fetch_url_desc_cell(self, spec) -> QWidget:
- cell = QWidget()
- cl = QVBoxLayout(cell)
- cl.setContentsMargins(6, 4, 6, 4)
- cl.setSpacing(4)
desc = QLabel(spec.description)
desc.setWordWrap(True)
desc.setToolTip(spec.description)
- cl.addWidget(desc)
- net = QWidget()
- nl = QHBoxLayout(net)
- nl.setContentsMargins(0, 0, 0, 0)
- nl.addWidget(self.test_internet_btn)
- nl.addWidget(self.test_internet_status, 1)
- cl.addWidget(net)
- return cell
+ desc.setObjectName("hint")
+ desc.setStyleSheet("border: none;")
+ lay.addWidget(desc)
+
+ if spec.name == "fetch_url":
+ # The live "Test Internet" self-test lives inside fetch_url's own
+ # card — it tests THIS capability, not the tab as a whole.
+ net = QWidget()
+ net.setStyleSheet("border: none;")
+ nl = QHBoxLayout(net)
+ nl.setContentsMargins(0, 2, 0, 0)
+ nl.addWidget(self.test_internet_btn)
+ nl.addWidget(self.test_internet_status, 1)
+ lay.addWidget(net)
+
+ return card
def _toggle_builtin(self, name: str, enabled: bool) -> None:
self.ctx.config.set_tool_enabled(name, enabled)
@@ -192,9 +241,5 @@ class ToolsAdminTab(QWidget):
self.test_internet_btn.setText(tr("settings.test_internet"))
self.test_internet_btn.setToolTip(tr("settings.test_internet_tooltip"))
self.refresh_btn.setText(tr("tools_admin.refresh"))
- self.table.setHorizontalHeaderLabels([
- tr("tools_admin.col_name"), tr("tools_admin.col_desc"),
- tr("tools_admin.col_enabled"),
- ])
self.jira_note.setText(tr("tools_admin.jira_note"))
self.refresh()
diff --git a/ui/widgets.py b/ui/widgets.py
index fa85d14..c980d00 100644
--- a/ui/widgets.py
+++ b/ui/widgets.py
@@ -5,39 +5,150 @@ from __future__ import annotations
from pathlib import Path
-from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, Qt, Signal
+from PySide6.QtCore import (
+ QEvent, QObject, QPoint, QPointF, QRect, QRectF, QSize, Qt, Signal,
+)
from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtWidgets import (
- QAbstractSpinBox, QComboBox, QDoubleSpinBox, QFrame, QGraphicsDropShadowEffect,
- QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSizePolicy,
- QVBoxLayout, QWidget,
+ QAbstractSpinBox, QCheckBox, QComboBox, QDoubleSpinBox, QFrame,
+ QHBoxLayout, QLabel, QLayout, QListWidget, QListWidgetItem, QPushButton,
+ QSizePolicy, QVBoxLayout, QWidget,
)
from ..core.flows import STEP_DONE, STEP_ERROR, STEP_PENDING, STEP_RUNNING
-from ..theme import ACCENT
+from ..theme import current_palette
from .icons import DOT_BLUE, DOT_GREEN, DOT_GREY, DOT_RED, dot_icon, icon
+def badge_pill_widget(text: str, object_name: str) -> QWidget:
+ """A small rounded pill for a table cell — a coloured role/status tag
+ (``object_name`` is one of theme.py's ``badge*`` QLabel names). Wrapped in
+ a transparent container rather than passed as a bare label: a
+ ``setCellWidget()`` widget is stretched to fill the whole cell, and
+ without the container's own ``background: transparent`` the app-wide
+ ``QWidget { background: $bg }`` rule (theme.py) paints that stretched
+ area opaque, hiding the pill inside a solid block instead of a snug tag."""
+ container = QWidget()
+ container.setStyleSheet("background: transparent;")
+ lay = QHBoxLayout(container)
+ lay.setContentsMargins(4, 2, 4, 2)
+ lbl = QLabel(text)
+ lbl.setObjectName(object_name)
+ lay.addWidget(lbl, 0, Qt.AlignVCenter)
+ lay.addStretch(1)
+ return container
+
+
+def enable_height_for_width(widget: QWidget) -> None:
+ """Flag ``widget`` as height-for-width so a PARENT layout reserves the
+ right amount of vertical space for it — needed at every widget boundary
+ between a :class:`FlowLayout` and the outermost layout, since each
+ ``addWidget()`` hop asks the WIDGET's own sizePolicy, not its layout's
+ (see FlowLayout's docstring)."""
+ policy = widget.sizePolicy()
+ policy.setHeightForWidth(True)
+ widget.setSizePolicy(policy)
+
+
+class FlowLayout(QLayout):
+ """A left-aligned layout that wraps its children onto new lines as the
+ container narrows, each item kept at its own natural size — the
+ ``.card`` grids in ui-audit_v2.html ("không kéo giãn lấp đầy hàng": cards
+ stay sized to their own content, never stretched to fill a row). Qt has
+ no built-in equivalent; this is the standard recipe (Qt's own C++
+ FlowLayout example, ported)."""
+
+ def __init__(self, parent=None, margin: int = 0, h_spacing: int = 8, v_spacing: int = 8):
+ super().__init__(parent)
+ self._h_spacing = h_spacing
+ self._v_spacing = v_spacing
+ self._items: list = []
+ self.setContentsMargins(margin, margin, margin, margin)
+ if parent is not None:
+ enable_height_for_width(parent)
+
+ def addItem(self, item) -> None: # noqa: N802 - Qt override
+ self._items.append(item)
+
+ def count(self) -> int: # noqa: N802 - Qt override
+ return len(self._items)
+
+ def itemAt(self, index: int): # noqa: N802 - Qt override
+ return self._items[index] if 0 <= index < len(self._items) else None
+
+ def takeAt(self, index: int): # noqa: N802 - Qt override
+ return self._items.pop(index) if 0 <= index < len(self._items) else None
+
+ def expandingDirections(self): # noqa: N802 - Qt override
+ return Qt.Orientations(Qt.Orientation(0))
+
+ def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt override
+ return True
+
+ def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt override
+ return self._do_layout(QRect(0, 0, width, 0), test_only=True)
+
+ def setGeometry(self, rect) -> None: # noqa: N802 - Qt override
+ super().setGeometry(rect)
+ self._do_layout(rect, test_only=False)
+
+ def sizeHint(self): # noqa: N802 - Qt override
+ return self.minimumSize()
+
+ def minimumSize(self): # noqa: N802 - Qt override
+ size = QSize()
+ for item in self._items:
+ size = size.expandedTo(item.minimumSize())
+ m = self.contentsMargins()
+ size += QSize(m.left() + m.right(), m.top() + m.bottom())
+ return size
+
+ def _do_layout(self, rect, test_only: bool) -> int:
+ m = self.contentsMargins()
+ effective = QRect(rect.x() + m.left(), rect.y() + m.top(),
+ rect.width() - m.left() - m.right(),
+ rect.height() - m.top() - m.bottom())
+ x, y = effective.x(), effective.y()
+ line_height = 0
+ for item in self._items:
+ hint = item.sizeHint()
+ next_x = x + hint.width() + self._h_spacing
+ if next_x - self._h_spacing > effective.right() and line_height > 0:
+ x = effective.x()
+ y = y + line_height + self._v_spacing
+ next_x = x + hint.width() + self._h_spacing
+ line_height = 0
+ if not test_only:
+ item.setGeometry(QRect(QPoint(x, y), hint))
+ x = next_x
+ line_height = max(line_height, hint.height())
+ return y + line_height - rect.y() + m.bottom()
+
+
+def style_card(frame: QFrame) -> None:
+ """Give a stat/budget card its surface. Flat by design: the raised surface
+ plus a hairline is what separates it from the page — the old drop shadow
+ made a grid of these look like it was hovering off the screen."""
+ p = current_palette()
+ frame.setStyleSheet(
+ f"QFrame {{ background: {p.surface}; border: 1px solid {p.border};"
+ f" border-radius: {p.radius_lg}px; }}")
+
+
class StatCard(QFrame):
"""A titled value card (e.g. token count + its cost as the subtitle) —
shared by Dashboard and Monitoring's token/cost displays."""
def __init__(self):
super().__init__()
- self.setFrameShape(QFrame.StyledPanel)
- self.setStyleSheet(
- "QFrame { border: 1px solid rgba(140,146,152,0.35); border-radius: 16px; }")
- shadow = QGraphicsDropShadowEffect(self)
- shadow.setBlurRadius(18)
- shadow.setOffset(0, 3)
- shadow.setColor(QColor(0, 0, 0, 60))
- self.setGraphicsEffect(shadow)
+ self.setFrameShape(QFrame.NoFrame)
+ style_card(self)
lay = QVBoxLayout(self)
self.title_lbl = QLabel("")
self.title_lbl.setObjectName("hint")
self.title_lbl.setStyleSheet("border: none;")
self.value_lbl = QLabel("—")
- self.value_lbl.setStyleSheet("border: none; font-size: 20px; font-weight: 700;")
+ self.value_lbl.setStyleSheet("border: none; font-size: 22px; font-weight: 600;")
self.sub_lbl = QLabel("")
self.sub_lbl.setObjectName("hint")
self.sub_lbl.setStyleSheet("border: none;")
@@ -46,8 +157,10 @@ class StatCard(QFrame):
# sizeHint hundreds of px wide, forcing its WHOLE grid column open and
# throwing every card in the row out of alignment.
self.sub_lbl.setWordWrap(True)
- lay.addWidget(self.title_lbl)
+ # Number first, name under it — the figure is what the eye is looking
+ # for, and it is how the audit page's tiles are drawn.
lay.addWidget(self.value_lbl)
+ lay.addWidget(self.title_lbl)
lay.addWidget(self.sub_lbl)
def set(self, title: str, value: str, sub: str = "") -> None:
@@ -55,6 +168,19 @@ class StatCard(QFrame):
self.value_lbl.setText(value)
self.sub_lbl.setText(sub)
+ def as_hero(self) -> "StatCard":
+ """Make this the headline card: bigger number, accent colour.
+
+ Used for the one figure a screen is really about (Dashboard's total
+ cost), so a row of otherwise identical tiles has a clear first read.
+ """
+ from ..theme import current_palette
+ p = current_palette()
+ self.value_lbl.setStyleSheet(
+ f"border: none; font-size: 34px; font-weight: 700; color: {p.accent};")
+ self.setObjectName("heroCard")
+ return self
+
class BudgetCard(QFrame):
"""Remaining/Budget box — same card chrome as :class:`StatCard`, plus a
@@ -66,20 +192,14 @@ class BudgetCard(QFrame):
def __init__(self):
super().__init__()
- self.setFrameShape(QFrame.StyledPanel)
- self.setStyleSheet(
- "QFrame { border: 1px solid rgba(140,146,152,0.35); border-radius: 16px; }")
- shadow = QGraphicsDropShadowEffect(self)
- shadow.setBlurRadius(18)
- shadow.setOffset(0, 3)
- shadow.setColor(QColor(0, 0, 0, 60))
- self.setGraphicsEffect(shadow)
+ self.setFrameShape(QFrame.NoFrame)
+ style_card(self)
lay = QVBoxLayout(self)
self.title_lbl = QLabel("")
self.title_lbl.setObjectName("hint")
self.title_lbl.setStyleSheet("border: none;")
self.value_lbl = QLabel("—")
- self._value_style = "border: none; font-size: 20px; font-weight: 700;"
+ self._value_style = "border: none; font-size: 22px; font-weight: 600;"
self.value_lbl.setStyleSheet(self._value_style)
self.sub_lbl = QLabel("")
self.sub_lbl.setObjectName("hint")
@@ -89,8 +209,9 @@ class BudgetCard(QFrame):
# sizeHint hundreds of px wide, forcing its WHOLE grid column open and
# throwing every card in the row out of alignment.
self.sub_lbl.setWordWrap(True)
- lay.addWidget(self.title_lbl)
+ # Same order as StatCard: the number first, its name under it.
lay.addWidget(self.value_lbl)
+ lay.addWidget(self.title_lbl)
lay.addWidget(self.sub_lbl)
row = QHBoxLayout()
@@ -109,7 +230,7 @@ class BudgetCard(QFrame):
self.title_lbl.setText(title)
self.value_lbl.setText(value)
self.value_lbl.setStyleSheet(
- self._value_style + (" color: #E5484D;" if warn else ""))
+ self._value_style + (f" color: {current_palette().danger};" if warn else ""))
self.sub_lbl.setText(sub)
@@ -148,6 +269,310 @@ def guard_wheel(root: QWidget) -> None:
w.installEventFilter(_wheel_guard)
+def tidy_popup(combo) -> None:
+ """Make a drop-list show its options and nothing else.
+
+ Two platform habits to undo. macOS marks the current row with a checkmark,
+ drawn by the menu-style delegate a combo gets by default; the row is already
+ tinted by selection-background-color, so the tick says nothing twice and, in
+ a combo only as wide as "VN", covered the letters it was marking. Handing
+ the view a plain QStyledItemDelegate switches it to item-view painting,
+ where no such glyph exists.
+
+ And the popup inherits the combo's width unless told otherwise, which had
+ the project and provider names cut off here regardless of platform. So
+ measure the longest item — plus an indicator's worth of room, in case a
+ style still draws one — and set that as the view's minimum.
+ """
+ from PySide6.QtWidgets import QStyle, QStyledItemDelegate
+
+ view = combo.view()
+ combo.setItemDelegate(QStyledItemDelegate(combo))
+ fm = view.fontMetrics()
+ longest = max((fm.horizontalAdvance(combo.itemText(i))
+ for i in range(combo.count())), default=0)
+ tick = combo.style().pixelMetric(QStyle.PM_IndicatorWidth, None, combo)
+ pad = combo.style().pixelMetric(QStyle.PM_FocusFrameHMargin, None, combo) * 2
+ view.setMinimumWidth(longest + tick + pad + 16)
+
+
+def ui_scale(widget: QWidget) -> float:
+ """How much bigger this machine draws things than the design baseline.
+
+ Breakpoints written as raw pixels only hold on the screen they were tuned
+ on. At 125%/150% display scaling Qt still reports logical pixels, but every
+ label, button and margin is taller — so the same layout needs MORE logical
+ width before it stops being cramped. Font height is the honest proxy for
+ that: it moves with the display scale and with a user's font-size choice,
+ both of which change how much fits.
+
+ 1.0 at the 15px line height the layouts were measured against.
+ """
+ return max(0.75, min(2.5, widget.fontMetrics().height() / 15.0))
+
+
+class _NarrowGuard(QObject):
+ """Calls back when the WINDOW crosses a width threshold.
+
+ Watching the widget's own width does not work: a pane whose minimum width is
+ larger than the space available never reports being narrow — it just gets
+ clipped, which is the very problem being solved. The window always knows its
+ real size, so that is what gets watched.
+
+ A fold the user did by hand is never undone: auto-expand only reverses an
+ auto-collapse.
+ """
+
+ def __init__(self, owner: QWidget, threshold: int, apply):
+ super().__init__(owner)
+ self._owner = owner
+ self._threshold = threshold
+ self._apply = apply
+ self._auto = False # True while WE are the ones holding it folded
+ self._window = None
+
+ def attach(self) -> None:
+ win = self._owner.window()
+ if win is not None and win is not self._owner and win is not self._window:
+ win.installEventFilter(self)
+ self._window = win
+ # Dragging the window to a monitor with different scaling changes
+ # how much fits without changing its width, so re-decide then too.
+ handle = win.windowHandle()
+ if handle is not None:
+ handle.screenChanged.connect(lambda *_a: self.check())
+ self.check()
+
+ def eventFilter(self, obj, ev): # noqa: N802 - Qt override
+ if ev.type() == QEvent.Resize and obj is self._window:
+ self.check()
+ return super().eventFilter(obj, ev)
+
+ def check(self) -> None:
+ win = self._owner.window()
+ width = win.width() if win is not None else self._owner.width()
+ # The threshold is written for the baseline scale and grows with the
+ # machine's — see ui_scale().
+ narrow = width < self._threshold * ui_scale(self._owner)
+ if narrow == self._auto:
+ return
+ self._auto = narrow
+ self._apply(narrow)
+
+
+def narrow_guard(owner: QWidget, threshold: int, apply):
+ """Fold `owner`'s secondary panes below `threshold` px of window width.
+
+ ``apply(narrow: bool)`` does the folding. Call ``.attach()`` from showEvent.
+ """
+ return _NarrowGuard(owner, threshold, apply)
+
+
+class ToggleSwitch(QCheckBox):
+ """A checkbox drawn as an on/off switch.
+
+ Subclasses QCheckBox rather than replacing it, so every ``isChecked()`` /
+ ``setChecked()`` / ``stateChanged`` call site keeps working untouched — only
+ the painting changes. A switch reads as "this is on or off" where a tick box
+ reads as "this is selected", which is what these settings actually mean.
+ """
+
+ _W, _H = 34, 18
+
+ def __init__(self, text: str = "", parent=None):
+ super().__init__(text, parent)
+ self.setCursor(Qt.PointingHandCursor)
+
+ def sizeHint(self): # noqa: N802 - Qt override
+ base = super().sizeHint()
+ base.setWidth(base.width() + self._W)
+ base.setHeight(max(base.height(), self._H + 4))
+ return base
+
+ def paintEvent(self, _e): # noqa: N802 - Qt override
+ from ..theme import current_palette
+ p = current_palette()
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.Antialiasing)
+ y = (self.height() - self._H) // 2
+ track = QRectF(0, y, self._W, self._H)
+ on = self.isChecked()
+ enabled = self.isEnabled()
+ fill = QColor(p.accent_solid if on else p.border_strong)
+ if not enabled:
+ fill.setAlpha(110)
+ painter.setPen(Qt.NoPen)
+ painter.setBrush(fill)
+ painter.drawRoundedRect(track, self._H / 2, self._H / 2)
+ knob = self._H - 4
+ kx = self._W - knob - 2 if on else 2
+ painter.setBrush(QColor("#FFFFFF" if enabled else p.text_faint))
+ painter.drawEllipse(QRectF(kx, y + 2, knob, knob))
+ if self.text():
+ painter.setPen(QColor(p.text if enabled else p.text_faint))
+ painter.drawText(
+ QRectF(self._W + 8, 0, self.width() - self._W - 8, self.height()),
+ int(Qt.AlignLeft | Qt.AlignVCenter), self.text())
+ painter.end()
+
+
+class SegmentedControl(QWidget):
+ """Two-to-four choices shown side by side instead of hidden in a drop-list.
+
+ Exposes the slice of the QComboBox API this app's settings code uses
+ (addItem / findData / currentData / setCurrentIndex / currentIndexChanged),
+ so it drops into an existing form without touching the save/load paths.
+ """
+
+ currentIndexChanged = Signal(int)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._data: list = []
+ self._buttons: list = []
+ self._current = -1
+ lay = QHBoxLayout(self)
+ lay.setContentsMargins(0, 0, 0, 0)
+ lay.setSpacing(0)
+ self._lay = lay
+ lay.addStretch(1)
+
+ def addItem(self, text: str, data=None) -> None: # noqa: N802 - Qt-style name
+ from PySide6.QtWidgets import QPushButton
+ btn = QPushButton(text)
+ btn.setObjectName("segItem")
+ btn.setCheckable(True)
+ btn.setCursor(Qt.PointingHandCursor)
+ index = len(self._buttons)
+ btn.clicked.connect(lambda _c=False, i=index: self.setCurrentIndex(i))
+ self._lay.insertWidget(index, btn)
+ self._buttons.append(btn)
+ self._data.append(data)
+ if self._current < 0:
+ self.setCurrentIndex(0)
+
+ def findData(self, value) -> int: # noqa: N802
+ return self._data.index(value) if value in self._data else -1
+
+ def currentData(self): # noqa: N802
+ return self._data[self._current] if 0 <= self._current < len(self._data) else None
+
+ def currentIndex(self) -> int: # noqa: N802
+ return self._current
+
+ def count(self) -> int:
+ return len(self._buttons)
+
+ def setItemText(self, index: int, text: str) -> None: # noqa: N802
+ if 0 <= index < len(self._buttons):
+ self._buttons[index].setText(text)
+
+ def setCurrentIndex(self, index: int) -> None: # noqa: N802
+ if not (0 <= index < len(self._buttons)) or index == self._current:
+ for i, b in enumerate(self._buttons):
+ b.setChecked(i == self._current)
+ return
+ self._current = index
+ for i, b in enumerate(self._buttons):
+ b.setChecked(i == index)
+ self.currentIndexChanged.emit(index)
+
+
+def section_panels(sections, width: int = 260):
+ """Left list + right panel: pick a section, see that section only.
+
+ ``sections`` is [(label, widget)]. Returns (list_widget, stack) for the
+ caller to place side by side. Used by Settings and the Task editor so both
+ are navigated the same way, instead of one long scroll where you cannot
+ tell which group you are in or how many are left.
+ """
+ from PySide6.QtWidgets import QListWidget, QListWidgetItem, QStackedWidget
+
+ index = QListWidget()
+ index.setObjectName("sectionIndex")
+ index.setFrameShape(QListWidget.NoFrame)
+ index.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ index.setTextElideMode(Qt.ElideRight)
+ index.setWordWrap(False)
+
+ stack = QStackedWidget()
+ for label, widget in sections:
+ item = QListWidgetItem(label)
+ item.setToolTip(label)
+ index.addItem(item)
+ stack.addWidget(widget)
+ index.currentRowChanged.connect(stack.setCurrentIndex)
+ index.setCurrentRow(0)
+
+ natural = max(index.fontMetrics().horizontalAdvance(lab) for lab, _w in sections) + 36
+ index.setFixedWidth(max(120, min(width, natural)))
+ return index, stack
+
+
+def section_index(scroll, sections, width: int = 260):
+ """A clickable table of contents for a long scrolling dialog.
+
+ ``sections`` is [(label, anchor_widget)]. Clicking a row scrolls its anchor
+ into view; scrolling the dialog moves the highlight back. Purely navigation:
+ every field stays exactly where it was, in the same one scrolling column —
+ Settings and the Task editor were five stacked group boxes deep with no way
+ to tell what was further down.
+
+ Returns the QListWidget so the caller can place it.
+ """
+ from PySide6.QtWidgets import QListWidget, QListWidgetItem
+
+ index = QListWidget()
+ index.setObjectName("sectionIndex")
+ index.setFrameShape(QListWidget.NoFrame)
+ # Long section names (and 125%/150% display scaling) used to push a
+ # horizontal scrollbar into this list. It elides instead, with the full
+ # name on hover.
+ index.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
+ index.setTextElideMode(Qt.ElideRight)
+ index.setWordWrap(False)
+ for label, anchor in sections:
+ item = QListWidgetItem(label)
+ item.setToolTip(label)
+ item.setData(Qt.UserRole, anchor)
+ index.addItem(item)
+ index.setCurrentRow(0)
+ # Wide enough for the longest name at the CURRENT font — so the width grows
+ # with display scaling instead of eliding everything — but capped so it
+ # never eats the form beside it. `width` is that cap, not a fixed size.
+ natural = max(index.fontMetrics().horizontalAdvance(lab) for lab, _a in sections) + 36
+ index.setFixedWidth(max(120, min(width, natural)))
+
+ def _jump(item):
+ anchor = item.data(Qt.UserRole)
+ if anchor is not None:
+ # Scroll so the section's top edge lands at the top of the viewport,
+ # rather than merely "somewhere visible".
+ bar = scroll.verticalScrollBar()
+ top = anchor.mapTo(scroll.widget(), anchor.rect().topLeft()).y()
+ bar.setValue(min(top, bar.maximum()))
+
+ index.itemClicked.connect(_jump)
+
+ def _follow(value: int):
+ """Highlight the last section whose top has passed the viewport top."""
+ row = 0
+ for i in range(index.count()):
+ anchor = index.item(i).data(Qt.UserRole)
+ if anchor is None:
+ continue
+ top = anchor.mapTo(scroll.widget(), anchor.rect().topLeft()).y()
+ if top <= value + 4:
+ row = i
+ if index.currentRow() != row:
+ blocked = index.blockSignals(True)
+ index.setCurrentRow(row)
+ index.blockSignals(blocked)
+
+ scroll.verticalScrollBar().valueChanged.connect(_follow)
+ return index
+
+
class CollapseStrip(QWidget):
"""The slim bar shown in place of a collapsed side panel.
@@ -185,15 +610,16 @@ class CollapseStrip(QWidget):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
w = self.width()
- accent = QColor(ACCENT) if self._hover else QColor("#8b8d98")
+ tok = current_palette()
+ accent = QColor(tok.accent) if self._hover else QColor(tok.text_faint)
# A small rounded "button" at the top carries the expand arrow so the
# collapsed panel always shows a clear, clickable affordance.
bw = min(w - 2.0, 16.0)
btn = QRectF((w - bw) / 2.0, 6.0, bw, 18.0)
- p.setPen(QPen(QColor(139, 144, 150, 130), 1.0))
- p.setBrush(QColor(155, 160, 166, 70) if self._hover else QColor(155, 160, 166, 32))
- p.drawRoundedRect(btn, 4.0, 4.0)
+ p.setPen(QPen(QColor(tok.border_strong), 1.0))
+ p.setBrush(QColor(tok.hover if self._hover else tok.surface))
+ p.drawRoundedRect(btn, float(tok.radius_sm), float(tok.radius_sm))
cx = w / 2.0
cy = btn.center().y()
@@ -211,7 +637,7 @@ class CollapseStrip(QWidget):
# thin handle line below the button
p.setPen(Qt.NoPen)
- p.setBrush(QColor(155, 160, 166, 90))
+ p.setBrush(QColor(tok.border_strong))
line_w = 2.0
x = (w - line_w) / 2.0
ltop = btn.bottom() + 6.0
@@ -226,7 +652,12 @@ class PlanSection(QWidget):
close). Hidden until it has steps; updated in place as the agent calls
``update_plan``."""
- _COLORS = {STEP_RUNNING: ACCENT, STEP_DONE: "#6fe3a4", STEP_ERROR: "#ef6368"}
+ @staticmethod
+ def _step_color(status: str) -> str | None:
+ """Row text colour per step status; None leaves the default. Resolved
+ per call so it follows a live theme switch."""
+ p = current_palette()
+ return {STEP_RUNNING: p.accent, STEP_DONE: p.success, STEP_ERROR: p.danger}.get(status)
@staticmethod
def _step_icon(status: str):
@@ -272,7 +703,7 @@ class PlanSection(QWidget):
continue
status = str((s or {}).get("status", STEP_PENDING)).strip().lower()
item = QListWidgetItem(self._step_icon(status), f" {title}")
- color = self._COLORS.get(status)
+ color = self._step_color(status)
if color:
item.setForeground(QColor(color))
self.list.addItem(item)
@@ -308,7 +739,11 @@ class CollapsibleSection(QWidget):
activated = Signal(str) # emits the path of a clicked item
- def __init__(self, title: str, max_height: int = 130):
+ def __init__(self, title: str, max_height: int | None = 130):
+ """``max_height`` caps the list so it scrolls instead of growing
+ (the default, e.g. for a section sharing space with siblings).
+ ``None`` instead lets it expand to fill whatever room its parent
+ layout hands it — for a section that owns the whole panel."""
super().__init__()
self._title = title
self._paths: list[str] = []
@@ -325,11 +760,14 @@ class CollapsibleSection(QWidget):
lay.addWidget(self.header)
self.list = QListWidget()
- self.list.setMaximumHeight(max_height) # scrolls when longer
+ if max_height is not None:
+ self.list.setMaximumHeight(max_height) # scrolls when longer
+ else:
+ self.list.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
self.list.setVisible(False)
self.list.itemActivated.connect(self._emit)
self.list.itemClicked.connect(self._emit)
- lay.addWidget(self.list)
+ lay.addWidget(self.list, 1 if max_height is None else 0)
self.setVisible(False)
self._update_header()
diff --git a/ui/workspace_tab.py b/ui/workspace_tab.py
index 2830377..533e760 100644
--- a/ui/workspace_tab.py
+++ b/ui/workspace_tab.py
@@ -32,12 +32,32 @@ from .osutil import open_folder
from .widgets import CollapseStrip
+class _ProjectRow(QWidget):
+ """A project in the list: its name, and under it how much is in it.
+
+ The drawing gives every row a second line — "2 đoạn chat · 3 task" — which
+ is the only thing on this screen that says a project holds anything at all.
+ """
+
+ def __init__(self, name: str, counts: str):
+ super().__init__()
+ lay = QVBoxLayout(self)
+ lay.setContentsMargins(6, 4, 6, 4)
+ lay.setSpacing(0)
+ title = QLabel(name)
+ sub = QLabel(counts)
+ sub.setObjectName("hint")
+ lay.addWidget(title)
+ lay.addWidget(sub)
+
+
class WorkspaceTab(QWidget):
status_message = Signal(str)
open_chat = Signal(str, dict) # kind, conversation — open a thread in Cowork
new_chat = Signal(str) # project_id — start a new thread in this project
projects_changed = Signal() # created/edited/deleted → History regroups
subtabs_changed = Signal() # visible sub-tabs changed → left-nav children refresh
+ project_selected = Signal(str) # project_id — the rail's picker follows this
# ---- nav integration: the sub-tabs are driven from the left nav rail -----
def nav_subtabs(self):
@@ -50,10 +70,37 @@ class WorkspaceTab(QWidget):
return [(self.tabs.tabText(i), i, icons.get(i, "chevron-right"))
for i in range(self.tabs.count()) if self.tabs.isTabVisible(i)]
+ def nav_entries(self):
+ """(label, index, icon_name, enabled) for EVERY sub-tab, hidden ones
+ included.
+
+ The rail lists all five all the time and greys out the ones the project
+ gate is currently closing (Cowork, GraphRAG) instead of removing them —
+ same gate, shown rather than hidden, so the menu stops changing shape
+ under the user's hand. See nav_subtabs() for the visible-only view.
+ """
+ icons = {self._project_tab_idx: "folder", self._cowork_tab_idx: "chat",
+ self._co4e_tab_idx: "flow", self._folder_tab_idx: "folder",
+ self._graphrag_tab_idx: "graph"}
+ return [(self.tabs.tabText(i), i, icons.get(i, "chevron-right"),
+ self.tabs.isTabVisible(i))
+ for i in range(self.tabs.count())]
+
+ def subtab_available(self, index: int) -> bool:
+ """False while the project gate is holding this sub-tab shut.
+
+ The rail greys those rows out, but that only guards the rail. This lets
+ every other route ask the same question of the same state.
+ """
+ return bool(0 <= index < self.tabs.count() and self.tabs.isTabVisible(index))
+
def select_subtab(self, index: int) -> None:
if 0 <= index < self.tabs.count():
self.tabs.setCurrentIndex(index)
+ def current_subtab(self) -> int:
+ return self.tabs.currentIndex()
+
def hide_tab_bar(self) -> None:
"""Hide the in-content tab strip (the nav rail drives the sub-tabs now),
so the content area is as large as possible."""
@@ -63,6 +110,9 @@ class WorkspaceTab(QWidget):
super().__init__()
self.ctx = ctx
self._current_id = ""
+ # True once the user asks for the full History panel; until then Cowork
+ # opens with it folded, as the drawing lays the screen out.
+ self._history_opened = False
# Shared widgets embedded as per-project sub-tabs (None in unit tests
# that only drive project management).
self._cowork = cowork
@@ -70,12 +120,23 @@ class WorkspaceTab(QWidget):
self._sidebar = sidebar
root = QVBoxLayout(self)
+ # Title row — the drawing puts "+ Project mới" up here beside the title,
+ # not at the foot of the project list where it read as belonging to the
+ # list's own controls.
self._header = QLabel()
self._header.setStyleSheet("font-weight:700; font-size:15px;")
+ self._new_btn = QPushButton()
+ self._new_btn.setIcon(icon("plus"))
+ self._new_btn.setObjectName("primary")
+ self._new_btn.clicked.connect(self._create)
+ title_row = QHBoxLayout()
+ title_row.addWidget(self._header)
+ title_row.addStretch(1)
+ title_row.addWidget(self._new_btn)
+ root.addLayout(title_row)
self._hint = QLabel()
self._hint.setObjectName("hint")
self._hint.setWordWrap(True)
- root.addWidget(self._header)
root.addWidget(self._hint)
self._split = QSplitter(Qt.Horizontal)
@@ -87,6 +148,9 @@ class WorkspaceTab(QWidget):
ll = QVBoxLayout(left)
ll.setContentsMargins(0, 0, 0, 0)
left_hdr = QHBoxLayout()
+ self._projects_hdr = QLabel()
+ self._projects_hdr.setObjectName("navSectionHdr")
+ left_hdr.addWidget(self._projects_hdr)
self._proj_collapse_btn = QPushButton()
self._proj_collapse_btn.setIcon(collapse_left_icon())
self._proj_collapse_btn.setFixedWidth(28)
@@ -98,15 +162,13 @@ class WorkspaceTab(QWidget):
self.project_list.currentItemChanged.connect(self._on_select)
ll.addWidget(self.project_list, 1)
btns = QHBoxLayout()
- self._new_btn = QPushButton()
- self._new_btn.setIcon(icon("plus"))
- self._new_btn.setObjectName("primary")
- self._new_btn.clicked.connect(self._create)
+ # Delete stays under the list it acts on. The drawing does not show it,
+ # but it does not show it moved either, and dropping a control is not
+ # something a layout pass gets to do.
self._del_btn = QPushButton()
self._del_btn.setIcon(icon("trash"))
self._del_btn.clicked.connect(self._delete)
- btns.addWidget(self._new_btn, 1)
- btns.addWidget(self._del_btn)
+ btns.addWidget(self._del_btn, 1)
ll.addLayout(btns)
self._projects_panel = left
@@ -127,6 +189,13 @@ class WorkspaceTab(QWidget):
# Project comes FIRST; Cowork + GraphRAG only appear once a project is
# actually selected (see _update_tab_visibility).
self.tabs = QTabWidget()
+ # A QTabWidget's minimum width is the MAXIMUM over every page, hidden
+ # ones included — so Co4E (the widest, ~1180px) was setting the floor for
+ # Project and Cowork as well, and through them for the whole window,
+ # which then refused to be smaller than 1453px on any screen. An explicit
+ # minimum overrides that: each page still gets whatever width is going,
+ # and the pages that are not on screen no longer vote.
+ self.tabs.setMinimumWidth(560)
self._project_tab_idx = self.tabs.addTab(self._build_project_tab(), tr("workspace.tab_project"))
self._cowork_tab_idx = -1
self._graphrag_tab_idx = -1
@@ -205,9 +274,14 @@ class WorkspaceTab(QWidget):
rl.addWidget(self._instr_lbl)
rl.addWidget(self.instr_edit)
+ self._folder_hdr = QLabel()
+ rl.addWidget(self._folder_hdr)
folder_row = QHBoxLayout()
- self.folder_lbl = QLabel()
- self.folder_lbl.setObjectName("hint")
+ # The drawing shows the path in a field, not as grey caption text. A
+ # read-only line edit looks like one and, unlike a label, lets the path
+ # be selected and copied.
+ self.folder_lbl = QLineEdit()
+ self.folder_lbl.setReadOnly(True)
self._browse_btn = QPushButton()
self._browse_btn.setIcon(icon("folder"))
self._browse_btn.clicked.connect(self._pick_folder)
@@ -219,6 +293,7 @@ class WorkspaceTab(QWidget):
folder_row.addWidget(self._open_btn)
rl.addLayout(folder_row)
+ rl.addStretch(1) # the drawing floats Lưu project at the bottom
save_row = QHBoxLayout()
self._save_btn = QPushButton()
self._save_btn.setIcon(icon("save"))
@@ -239,11 +314,20 @@ class WorkspaceTab(QWidget):
sb = self._sidebar
sb.open_chat.connect(self._on_sidebar_open)
sb.new_chat.connect(self._on_sidebar_new)
- sb.collapse_requested.connect(lambda: self._set_sidebar_collapsed(True))
- sb.expand_requested.connect(lambda: self._set_sidebar_collapsed(False))
+ sb.collapse_requested.connect(lambda: self._on_history_fold(True))
+ sb.expand_requested.connect(lambda: self._on_history_fold(False))
sb.refresh_requested.connect(self._on_sidebar_refresh)
sb.history_changed.connect(self._reload_threads)
+ def _on_history_fold(self, collapsed: bool) -> None:
+ """Its chevron closes the panel away, back to the drawn layout."""
+ self._history_opened = not collapsed
+ if collapsed:
+ self._sidebar.setVisible(False)
+ self._apply_pane_visibility()
+ else:
+ self._set_sidebar_collapsed(False)
+
def _set_sidebar_collapsed(self, collapsed: bool) -> None:
"""Collapse/expand History sidebar and redistribute splitter space so
the Cowork chat area fills the freed width (same pattern as
@@ -321,17 +405,44 @@ class WorkspaceTab(QWidget):
on_project = idx == self._project_tab_idx
on_cowork = self._cowork_tab_idx >= 0 and idx == self._cowork_tab_idx
self._projects_pane.setVisible(on_project)
+ # "Workspace — Projects" and its three-line explanation describe the
+ # PROJECT screen, but were drawn above every sub-tab — ~90px of vertical
+ # space taken from Co4E's canvas and Folder's tree on every laptop
+ # screen. Shown where they apply; the text itself is unchanged.
+ self._header.setVisible(on_project)
+ # The title row sits above the sub-tabs, so everything on it has to
+ # follow the same rule the title does — moving + Project mới up here put
+ # it in the corner of Cowork, Co4E, Folder and GraphRAG as well.
+ self._new_btn.setVisible(on_project)
+ # ...and the explanation only while there is nothing to explain against:
+ # the drawing heads a populated screen with the title alone.
+ self._hint.setVisible(on_project and self.project_list.count() == 0)
+ narrow = getattr(self, "_is_narrow", False)
if self._sidebar is not None:
- self._sidebar.setVisible(on_cowork)
- # Auto-expand History when entering Cowork tab so it's always usable
- if on_cowork:
+ # Cowork is two columns in the drawing — transcript and files — with
+ # no history pane and no strip where one used to be. The relocation
+ # table is explicit: History moves to Sidebar ▸ RECENTS, "giữ, dễ
+ # tới hơn". So the panel is not on this screen at all until asked
+ # for: "Tất cả project…" in RECENTS brings it in, since search,
+ # filters, pin, rename and multi-select delete live only there.
+ self._sidebar.setVisible(on_cowork and self._history_opened)
+ if on_cowork and self._history_opened:
self._sidebar.set_collapsed(False)
# QSplitter ignores hidden panes, but the freed width isn't handed to
# the remaining panes deterministically — set explicit sizes after any
# pane toggle (same lesson as _set_projects_collapsed).
total = sum(self._split.sizes()) or 1300
- proj_w = 260 if on_project else 0
- hist_w = 240 if (on_cowork and self._sidebar is not None) else 0
+ # A collapsed pane must be given the STRIP width here, not its open
+ # width: this ran after every tab change and handed History a flat
+ # 240px even while it was folded to an 18px strip, leaving ~220px of
+ # dead space beside the chat on a small screen.
+ strip_w = CollapseStrip.WIDTH + 2
+ proj_w = 0
+ if on_project:
+ proj_w = strip_w if self._projects_strip.isVisible() else 260
+ hist_w = 0
+ if on_cowork and self._sidebar is not None:
+ hist_w = strip_w if self._sidebar.is_collapsed() else 240
if self._split.count() >= 3:
self._split.setSizes([proj_w, hist_w, max(1, total - proj_w - hist_w)])
else:
@@ -341,6 +452,8 @@ class WorkspaceTab(QWidget):
def _retranslate(self) -> None:
self._header.setText(tr("workspace.header"))
self._hint.setText(tr("workspace.hint"))
+ self._projects_hdr.setText(tr("workspace.projects_heading").upper())
+ self._folder_hdr.setText(tr("workspace.folder_label"))
self._new_btn.setText(tr("workspace.new_project"))
self._del_btn.setText(tr("workspace.delete"))
self._name_lbl.setText(tr("workspace.name"))
@@ -365,6 +478,38 @@ class WorkspaceTab(QWidget):
self.tabs.setTabText(self._graphrag_tab_idx, tr("workspace.tab_graphrag"))
# ---- project list collapse (same pattern as History / GraphRAG Agent panel) --
+ # Below this window width the three panes (projects 260 + history 240 +
+ # the sub-page, which alone wants ~1245px on Cowork) no longer fit and Qt
+ # clips them instead of shrinking. Measured with tools/check_responsive.py.
+ _NARROW = 1500
+
+ def showEvent(self, e): # noqa: N802 - Qt override
+ super().showEvent(e)
+ if getattr(self, "_narrow", None) is None:
+ from .widgets import narrow_guard
+ self._narrow = narrow_guard(self, self._NARROW, self._apply_narrow)
+ self._narrow.attach()
+
+ def _apply_narrow(self, narrow: bool) -> None:
+ """Fold the two side panes on a small screen so the sub-page keeps its
+ width; unfold them when the window grows back.
+
+ Nothing becomes unreachable: both panes leave their usual collapse strip
+ behind, and the project picker + RECENTS in the rail cover the same
+ ground while they are folded.
+ """
+ self._is_narrow = narrow
+ self._set_projects_collapsed(narrow)
+ if self._sidebar is not None:
+ self._set_sidebar_collapsed(narrow)
+ # The chat's Files pane (~300px) is the other thing that pushes Cowork
+ # past the window; it has the same collapse strip to come back from.
+ if self._cowork is not None and hasattr(self._cowork, "_set_io_collapsed"):
+ self._cowork._set_io_collapsed(narrow)
+ if not narrow:
+ # Re-apply the per-tab rules the two calls above just overrode.
+ self._apply_pane_visibility()
+
def _set_projects_collapsed(self, collapsed: bool) -> None:
strip_w = CollapseStrip.WIDTH + 2
self._projects_panel.setVisible(not collapsed)
@@ -406,21 +551,48 @@ class WorkspaceTab(QWidget):
from ..core.projects import list_projects
keep = self._current_id
+ counts = self._project_counts()
self.project_list.blockSignals(True)
self.project_list.clear()
row_to_select = 0
for i, p in enumerate(list_projects()):
- item = QListWidgetItem(p.name)
+ chats, tasks = counts.get(p.project_id, (0, 0))
+ # No text on the item: the row widget paints the name, and setting
+ # both drew it twice, one string ghosting the other.
+ item = QListWidgetItem()
item.setData(Qt.UserRole, p.project_id)
if p.description:
item.setToolTip(p.description)
self.project_list.addItem(item)
+ row = _ProjectRow(p.name, tr("workspace.counts", chats=chats, tasks=tasks))
+ item.setSizeHint(row.sizeHint())
+ self.project_list.setItemWidget(item, row)
if p.project_id == keep:
row_to_select = i
self.project_list.blockSignals(False)
self.project_list.setCurrentRow(row_to_select)
+ # The drawing heads a populated screen with the title alone; the
+ # explanation is what an EMPTY one says instead of showing nothing.
+ self._hint.setVisible(self.project_list.count() == 0)
self._load_current()
+ @staticmethod
+ def _project_counts():
+ """{project_id: (chats, tasks)} — read once per refresh, not per row."""
+ from ..core.history import list_conversations
+ from ..core.tasks import list_tasks
+
+ out: dict = {}
+ for conv in list_conversations():
+ pid = conv.get("project_id") or "default"
+ chats, tasks = out.get(pid, (0, 0))
+ out[pid] = (chats + 1, tasks)
+ for task in list_tasks():
+ pid = task.get("project_id") or "default"
+ chats, tasks = out.get(pid, (0, 0))
+ out[pid] = (chats, tasks + 1)
+ return out
+
def _selected_id(self) -> str:
item = self.project_list.currentItem()
return item.data(Qt.UserRole) if item else ""
@@ -474,6 +646,94 @@ class WorkspaceTab(QWidget):
self._bind_project(pid)
finally:
self._set_tabs_busy(False)
+ # The rail's project picker mirrors this selection — it is a second view
+ # of the same state, never a second source of truth.
+ self.project_selected.emit(pid)
+
+ # ---- rail integration -------------------------------------------------
+ def project_choices(self):
+ """(name, project_id) for the rail picker, in the list's own order.
+
+ Read from the store, which is what the list is built from — reading
+ item.text() coupled the picker to how a row happens to be drawn, and
+ when rows became widgets the picker went blank: every project showed
+ as a bare folder glyph with no name.
+ """
+ from ..core.projects import list_projects
+
+ return [(p.name, p.project_id) for p in list_projects()]
+
+ def selected_project_id(self) -> str:
+ return self._selected_id()
+
+ def choose_project(self, project_id: str) -> bool:
+ """Select a project by id — the same path the list row takes."""
+ return self._select_project_row(project_id)
+
+ def recent_threads(self, limit: int = 5):
+ """The active project's most recent conversations, newest first.
+
+ Scoped to the project on purpose: history is stored inside the project's
+ own folder (config.history_dir() follows the selection) and the History
+ pane groups by project. A flat, cross-project recents list would quietly
+ drop that scoping.
+ """
+ from ..core.history import list_conversations
+
+ pid = self._current_id
+ if not pid:
+ return []
+ try:
+ convos = list_conversations(self.ctx.config.history_dir())
+ except Exception: # noqa: BLE001
+ return []
+ out = []
+ for meta in convos:
+ if (meta.get("project_id", "") or "default") != pid:
+ continue
+ out.append({
+ "title": meta.get("title") or tr("sidebar.empty"),
+ "path": str(meta["path"]),
+ "kind": meta.get("kind", "") or "cowork",
+ "pinned": bool(meta.get("pinned", False)),
+ "session_id": meta.get("session_id", ""),
+ })
+ if len(out) >= limit:
+ break
+ return out
+
+ def open_thread(self, path: str, kind: str = "cowork") -> bool:
+ """Open a conversation by file path — the same route the History pane's
+ own click takes (load_conversation → _on_sidebar_open)."""
+ from ..core.history import load_conversation
+
+ try:
+ conv = load_conversation(path)
+ except Exception: # noqa: BLE001
+ return False
+ self._on_sidebar_open(kind, conv)
+ return True
+
+ def show_history_pane(self) -> None:
+ """Bring the full History panel into view (un-collapsing it if needed).
+
+ The rail's recents list is a shortcut, not a replacement: search,
+ filters, pin, rename, multi-select delete and the context menu all still
+ live in this panel.
+ """
+ self._history_opened = True
+ self._show_cowork_tab()
+ self._sidebar.setVisible(True)
+ self._set_sidebar_collapsed(False)
+
+ def start_new_chat(self) -> None:
+ """Start a new thread in the current project and show it.
+
+ Exactly what the History pane's own new-chat button does
+ (_on_sidebar_new); the rail button is a second door to the same room,
+ not a second implementation.
+ """
+ self._on_sidebar_new("cowork")
def _set_tabs_busy(self, busy: bool) -> None:
for idx in (self._cowork_tab_idx, self._graphrag_tab_idx):