Delta team/fix comment ui #10

Merged
gitea-admin merged 21 commits from delta-team/fix-comment-UI into main 2026-09-09 16:46:16 +00:00
31 changed files with 1625 additions and 131 deletions
Showing only changes of commit 9459dbe197 - Show all commits
+95 -2
View File
@@ -13,10 +13,19 @@ again every time the language changes. Transient dialogs (Settings, Skills,
Flow, Permission...) are rebuilt from scratch each time they are opened, so
they simply call ``tr()`` while constructing their widgets and need no
registration.
``setText(tr("k"))`` on its own is only correct for the instant it runs, and a
screen with dozens of such one-shot calls is where "I picked English and half
the screen is still Vietnamese" comes from. The :func:`bind_text` family
attaches the key to the widget instead, so every future language change
re-applies it — one line per widget, and nothing to remember in a separate
``retranslate`` method. Bindings hold the widget WEAKLY, so they are safe for
widgets that get rebuilt constantly (Kanban rows, calendar cells).
"""
from __future__ import annotations
from typing import Callable, Dict, List
import weakref
from typing import Any, Callable, Dict, Iterable, List, Tuple
LANGUAGES: Dict[str, str] = {"en": "English", "ja": "日本語", "vi": "Tiếng Việt"}
# Short codes shown in the compact top-bar switcher (Settings keeps the full names above).
@@ -25,6 +34,8 @@ DEFAULT_LANGUAGE = "vi"
_current = DEFAULT_LANGUAGE
_listeners: List[Callable[[], None]] = []
#: (weak ref to the widget, how to re-apply its text) — see :func:`bind_text`.
_bindings: List[Tuple["weakref.ref", Callable[[Any], None]]] = []
# key -> {"en": ..., "ja": ..., "vi": ...}
from . import login_dialog as _login_dialog
@@ -64,6 +75,7 @@ def set_language(lang: str) -> None:
if lang == _current:
return
_current = lang
_apply_bindings()
for fn in list(_listeners):
try:
fn()
@@ -98,6 +110,87 @@ def on_language_changed(fn: Callable[[], None]) -> None:
"""Register a callback that re-applies translations to a persistent widget.
Called once immediately (to apply the current language) and again on every
future call to :func:`set_language`."""
future call to :func:`set_language`. For a single widget whose text is one
key, prefer :func:`bind_text` and friends — they need no callback of their
own and cannot keep a destroyed widget alive."""
_listeners.append(fn)
fn()
# ---- per-widget bindings -------------------------------------------------
def _bind(widget: Any, apply: Callable[[Any], None]) -> Any:
"""Attach a text re-application to one widget, run it now, return the widget.
``apply`` takes the widget as its argument rather than closing over it: a
closure would keep the widget alive for the life of the process, which is
exactly what the weak reference here exists to avoid.
The widget comes back out so a call site can bind IN PLACE of the one-shot
call it replaces — ``bind_text(QLabel(), k)`` where ``QLabel(tr(k))`` was —
without spending a line, which several screens here cannot afford (Gate S).
"""
_bindings.append((weakref.ref(widget), apply))
apply(widget)
return widget
def _apply_bindings() -> None:
"""Re-apply every live binding; drop the ones whose widget is gone.
Both halves of "gone" are handled: the Python wrapper collected (the weak
ref answers None) and the C++ object deleted underneath a live wrapper
(``RuntimeError``). Neither may stop the remaining widgets from updating.
"""
alive: List[Tuple["weakref.ref", Callable[[Any], None]]] = []
for ref, apply in _bindings:
widget = ref()
if widget is None:
continue
try:
apply(widget)
except RuntimeError:
continue
alive.append((ref, apply))
_bindings[:] = alive
def bind_text(widget: Any, key: str, **kwargs) -> Any:
"""Keep ``widget``'s label on ``key`` through every language change."""
return _bind(widget, lambda w: w.setText(tr(key, **kwargs)))
def bind_tip(widget: Any, key: str, **kwargs) -> Any:
"""Keep ``widget``'s tooltip on ``key`` through every language change."""
return _bind(widget, lambda w: w.setToolTip(tr(key, **kwargs)))
def bind_placeholder(widget: Any, key: str, **kwargs) -> Any:
"""Keep an input's placeholder on ``key`` through every language change."""
return _bind(widget, lambda w: w.setPlaceholderText(tr(key, **kwargs)))
def bind_items(widget: Any, keys: Iterable[str]) -> Any:
"""Keep a combo's item LABELS on ``keys``, by position.
``setItemText`` on purpose: clearing and re-adding the items would drop the
per-item data every caller persists (routing mode, task type) and reset the
current selection as a side effect of a translation.
"""
keys = list(keys)
def _apply(w: Any) -> None:
"""Re-label each item that still exists, leaving its data alone."""
for i, key in enumerate(keys[:w.count()]):
w.setItemText(i, tr(key))
return _bind(widget, _apply)
def bind_dynamic(widget: Any, apply: Callable[[], None]) -> Any:
"""Bind text that is not one plain key — a count, a name, a joined list.
``apply`` takes no argument and re-reads whatever it needs itself; the
widget is still what decides how long the binding lives.
"""
return _bind(widget, lambda _w: apply())
+2 -2
View File
@@ -158,7 +158,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Không tìm thấy agent '{name}'."},
# ---- agents_admin_tab.py — Admin-only agent catalog -------------------
"agents_admin.page_title": {"en": "Agents Admin", "ja": "Agents Admin", "vi": "Agents Admin"},
"agents_admin.page_title": {"en": "Agents Admin", "ja": "エージェント管理", "vi": "Agents Admin"},
"agents_admin.edit_row_tooltip": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
"agents_admin.delete_row_tooltip": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"agents_admin.hint": {
@@ -179,7 +179,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Extra instructions this agent always follows (optional)…",
"ja": "このエージェントが常に従う追加指示(任意)…",
"vi": "Chỉ dẫn bổ sung agent này luôn tuân theo (tùy chọn)…"},
"agents_admin.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Provider"},
"agents_admin.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Nhà cung cấp"},
"agents_admin.provider_default": {
"en": "(machine's active provider)", "ja": "(各マシンの現在のプロバイダー)",
"vi": "(provider hiện tại của máy)"},
+16 -16
View File
@@ -168,8 +168,8 @@ STRINGS: Dict[str, Dict[str, str]] = {
"composer.manage_skills": {"en": "Manage skills…", "ja": "スキルを管理…", "vi": "Quản lý skill…"},
# ---- schedule_task_tab.py / task_editor_dialog.py -------------------
"schedtask.title": {"en": "Schedule Task", "ja": "Schedule Task", "vi": "Schedule Task"},
"schedtask.view.kanban": {"en": "Kanban", "ja": "Kanban", "vi": "Kanban"},
"schedtask.title": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"},
"schedtask.view.kanban": {"en": "Kanban", "ja": "カンバン", "vi": "Kanban"},
"schedtask.view.calendar": {"en": "Calendar", "ja": "カレンダー", "vi": "Lịch"},
"schedtask.no_title": {"en": "(untitled)", "ja": "(無題)", "vi": "(chưa có tên)"},
"schedtask.cal_today": {"en": "Today", "ja": "今日", "vi": "Hôm nay"},
@@ -195,23 +195,23 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Describe what you want in natural language — AI proposes tasks/schedule/chain, you confirm before anything is created.",
"ja": "自然文で説明すると、AIがタスク・スケジュール・チェーンを提案します。確認後に作成されます。",
"vi": "Mô tả bằng ngôn ngữ tự nhiên — AI đề xuất task/lịch/chuỗi, bạn xác nhận rồi mới tạo."},
"schedtask.no_tasks": {"en": "No tasks", "ja": "タスクなし", "vi": "No tasks"},
"schedtask.no_tasks": {"en": "No tasks", "ja": "タスクなし", "vi": "Chưa có task"},
"schedtask.no_schedule": {"en": "No schedule", "ja": "スケジュールなし", "vi": "Chưa đặt lịch"},
"schedtask.last_success": {"en": "Last: Success", "ja": "前回: 成功", "vi": "Lần cuối: Thành công"},
"schedtask.last_failed": {"en": "Last: Failed", "ja": "前回: 失敗", "vi": "Lần cuối: Lỗi"},
"schedtask.last_never": {"en": "Last: not run", "ja": "前回: 未実行", "vi": "Lần cuối: chưa chạy"},
"schedtask.status.backlog": {"en": "Backlog", "ja": "Backlog", "vi": "Backlog"},
"schedtask.status.scheduled": {"en": "Scheduled", "ja": "Scheduled", "vi": "Scheduled"},
"schedtask.status.running": {"en": "Running", "ja": "Running", "vi": "Running"},
"schedtask.status.waiting_input": {"en": "Waiting Input", "ja": "Waiting Input", "vi": "Waiting Input"},
"schedtask.status.done": {"en": "Done", "ja": "Done", "vi": "Done"},
"schedtask.status.failed": {"en": "Failed", "ja": "Failed", "vi": "Failed"},
"schedtask.status.paused": {"en": "Paused", "ja": "Paused", "vi": "Paused"},
"schedtask.status.backlog": {"en": "Backlog", "ja": "バックログ", "vi": "Chờ xử lý"},
"schedtask.status.scheduled": {"en": "Scheduled", "ja": "予約済み", "vi": "Đã lên lịch"},
"schedtask.status.running": {"en": "Running", "ja": "実行中", "vi": "Đang chạy"},
"schedtask.status.waiting_input": {"en": "Waiting Input", "ja": "入力待ち", "vi": "Chờ nhập"},
"schedtask.status.done": {"en": "Done", "ja": "完了", "vi": "Hoàn thành"},
"schedtask.status.failed": {"en": "Failed", "ja": "失敗", "vi": "Thất bại"},
"schedtask.status.paused": {"en": "Paused", "ja": "一時停止", "vi": "Tạm dừng"},
"schedtask.type.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
"schedtask.type.co4e_code": {"en": "Code", "ja": "Code", "vi": "Code"},
"schedtask.type.flow": {"en": "Flow", "ja": "Flow", "vi": "Flow"},
"schedtask.type.script": {"en": "Script", "ja": "Script", "vi": "Script"},
"schedtask.type.manual": {"en": "Manual", "ja": "Manual", "vi": "Manual"},
"schedtask.type.flow": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
"schedtask.type.script": {"en": "Script", "ja": "スクリプト", "vi": "Script"},
"schedtask.type.manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"},
"schedtask.priority.low": {"en": "Low", "ja": "低", "vi": "Thấp"},
"schedtask.priority.medium": {"en": "Medium", "ja": "中", "vi": "Trung bình"},
"schedtask.priority.high": {"en": "High", "ja": "高", "vi": "Cao"},
@@ -229,7 +229,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Double-click một dòng để mở thư mục artifact của lần chạy đó."},
"schedtask.hist_col_time": {"en": "Finished at", "ja": "完了時刻", "vi": "Hoàn thành lúc"},
"schedtask.hist_col_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"},
"schedtask.hist_col_run": {"en": "Run ID", "ja": "実行ID", "vi": "Run ID"},
"schedtask.hist_col_run": {"en": "Run ID", "ja": "実行ID", "vi": "Mã lần chạy"},
"schedtask.hist_col_error": {"en": "Error", "ja": "エラー", "vi": "Lỗi"},
"schedtask.menu_create_next": {
"en": "Create next task from output", "ja": "出力から次タスクを作成",
@@ -261,7 +261,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"schedtask.no_workspace": {"en": "— No workspace —", "ja": "— ワークスペースなし —", "vi": "— Không có workspace —"},
"schedtask.f_agent": {"en": "Agent", "ja": "エージェント", "vi": "Agent"},
"schedtask.no_agent": {"en": "— No agent preset —", "ja": "— エージェントなし —", "vi": "— Không dùng agent —"},
"schedtask.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Provider"},
"schedtask.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Nhà cung cấp"},
"schedtask.provider_default": {
"en": "— Default (Settings) —", "ja": "— 既定(設定)—", "vi": "— Mặc định (Settings) —"},
"schedtask.f_model": {"en": "Model", "ja": "モデル", "vi": "Model"},
@@ -317,7 +317,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"schedtask.repeat.daily": {"en": "Daily", "ja": "毎日", "vi": "Hằng ngày"},
"schedtask.repeat.weekly": {"en": "Weekly", "ja": "毎週", "vi": "Hằng tuần"},
"schedtask.repeat.monthly": {"en": "Monthly", "ja": "毎月", "vi": "Hằng tháng"},
"schedtask.repeat.cron": {"en": "Cron expression", "ja": "Cron式", "vi": "Cron expression"},
"schedtask.repeat.cron": {"en": "Cron expression", "ja": "Cron式", "vi": "Biểu thức cron"},
"schedtask.f_task_mode": {"en": "Task type", "ja": "タスク種別", "vi": "Loại task"},
# Run kind: an AI agent vs a saved Co4E flow + multi-format import
"schedtask.f_run_kind": {"en": "Run", "ja": "実行対象", "vi": "Chạy"},
+2 -2
View File
@@ -93,7 +93,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Enable the predefined Req→Demo flow feature (off by default).",
"ja": "定義済みの Req→Demo フロー機能を有効化(初期値はオフ)。",
"vi": "Bật tính năng Flow Req→Demo dựng sẵn (mặc định tắt)."},
"code.flow_btn": {"en": "Flow Management", "ja": "Flow Management", "vi": "Flow Management"},
"code.flow_btn": {"en": "Flow Management", "ja": "フロー管理", "vi": "Flow Management"},
"code.flow_btn_tooltip": {
"en": "Build and run a multi-stage flow from requirement to demo.",
"ja": "要件からデモまでの多段フローを作成・実行します。",
@@ -152,7 +152,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
# because until the index existed nothing had to refer to it.
"settings.group.general": {"en": "General", "ja": "一般", "vi": "Chung"},
"settings.group.provider": {"en": "AI Provider", "ja": "AI プロバイダー", "vi": "Nhà cung cấp AI"},
"settings.group.parameter": {"en": "Parameter", "ja": "Parameter", "vi": "Parameter"},
"settings.group.parameter": {"en": "Parameter", "ja": "パラメータ", "vi": "Tham số"},
"settings.group.about": {"en": "About", "ja": "このアプリについて", "vi": "Giới thiệu"},
"settings.param_section_pricing": {
"en": "Model pricing", "ja": "モデル価格", "vi": "Bảng giá model"},
+6 -6
View File
@@ -45,8 +45,8 @@ STRINGS: Dict[str, Dict[str, str]] = {
"schedtask.step_prompt_ph": {"en": "Prompt / command", "ja": "プロンプト/コマンド", "vi": "Prompt / lệnh"},
"schedtask.stepexec.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
"schedtask.stepexec.co4e": {"en": "Code", "ja": "Code", "vi": "Code"},
"schedtask.stepexec.script": {"en": "Script", "ja": "Script", "vi": "Script"},
"schedtask.stepexec.manual": {"en": "Manual", "ja": "Manual", "vi": "Manual"},
"schedtask.stepexec.script": {"en": "Script", "ja": "スクリプト", "vi": "Script"},
"schedtask.stepexec.manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"},
"schedtask.del_step_tooltip": {"en": "Delete the selected step", "ja": "選択したステップを削除",
"vi": "Xóa bước đang chọn"},
"schedtask.guide_tooltip": {
@@ -168,7 +168,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Safety: the scheduler will NEVER auto-run this — it parks in Waiting Input until you right-click → Run now.",
"ja": "安全: 自動実行されず、Run nowまで待機します。",
"vi": "An toàn: scheduler KHÔNG BAO GIỜ tự chạy task này — nó nằm ở Waiting Input tới khi bạn chuột phải → Chạy ngay."},
"schedtask.g_input": {"en": "Input", "ja": "入力", "vi": "Input"},
"schedtask.g_input": {"en": "Input", "ja": "入力", "vi": "Đầu vào"},
"schedtask.f_input_mode": {"en": "Input mode", "ja": "入力モード", "vi": "Chế độ input"},
"schedtask.inmode.empty": {"en": "Empty (default)", "ja": "空(既定)", "vi": "Trống (mặc định)"},
"schedtask.inmode.manual": {"en": "Manual text", "ja": "手入力テキスト", "vi": "Văn bản nhập tay"},
@@ -199,7 +199,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"ja": "各URLを取得し(ベストエフォート)、テキストをコンテキストとして渡します。",
"vi": "Mỗi link được tải nội dung (khi có thể) và đưa vào ngữ cảnh cho agent."},
"schedtask.f_prev_task": {"en": "Previous task", "ja": "前タスク", "vi": "Task trước"},
"schedtask.g_output": {"en": "Output", "ja": "出力", "vi": "Output"},
"schedtask.g_output": {"en": "Output", "ja": "出力", "vi": "Đầu ra"},
"schedtask.f_output_mode": {"en": "Output mode", "ja": "出力モード", "vi": "Chế độ output"},
"schedtask.g_dependency": {"en": "Dependency / Next task", "ja": "依存 / 次タスク", "vi": "Phụ thuộc / Task tiếp theo"},
"schedtask.f_next_task": {"en": "Next task", "ja": "次タスク", "vi": "Task tiếp theo"},
@@ -244,8 +244,8 @@ STRINGS: Dict[str, Dict[str, str]] = {
"ja": "プレビュー(確認するまで作成されません):",
"vi": "Xem trước (chưa tạo gì cho tới khi bạn xác nhận):"},
"schedtask.ai_confirm": {"en": "Create tasks", "ja": "タスクを作成", "vi": "Tạo các task"},
"schedtask.tab_ai": {"en": "AI gen task", "ja": "AIタスク生成", "vi": "AI gen task"},
"schedtask.tab_import": {"en": "Import", "ja": "インポート", "vi": "Import"},
"schedtask.tab_ai": {"en": "AI gen task", "ja": "AIタスク生成", "vi": "Tạo task bằng AI"},
"schedtask.tab_import": {"en": "Import", "ja": "インポート", "vi": "Nhập"},
"schedtask.export_template_btn": {
"en": "Create Excel template…", "ja": "Excelテンプレートを作成…",
"vi": "Tạo template Excel…"},
+9 -3
View File
@@ -149,8 +149,14 @@ STRINGS: Dict[str, Dict[str, str]] = {
"app.provider": {"en": "Provider:", "ja": "プロバイダー:", "vi": "Nhà cung cấp:"},
"app.language": {"en": "Language:", "ja": "言語:", "vi": "Ngôn ngữ:"},
"app.settings": {"en": "Settings", "ja": "設定", "vi": "Cài đặt"},
"app.tab.dashboard": {"en": "Dashboard", "ja": "Dashboard", "vi": "Dashboard"},
"app.tab.schedule": {"en": "Schedule Task", "ja": "Schedule Task", "vi": "Schedule Task"},
# Shown on the cover while a language switch blocks the GUI thread. It is
# deliberately read BEFORE the switch, so it appears in the language the
# user is leaving — the only one they can still read at that moment.
"app.lang.switching": {
"en": "Switching language…", "ja": "言語を切り替えています…",
"vi": "Đang đổi ngôn ngữ…"},
"app.tab.dashboard": {"en": "Dashboard", "ja": "ダッシュボード", "vi": "Dashboard"},
"app.tab.schedule": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"},
"app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
"app.tab.code": {"en": "Code", "ja": "Code", "vi": "Code"},
"app.tab.structure": {"en": "GraphRAG", "ja": "GraphRAG", "vi": "GraphRAG"},
@@ -158,7 +164,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"app.tab.monitoring": {"en": "Monitoring", "ja": "モニタリング", "vi": "Giám sát"},
"app.nav.collapse_tooltip": {"en": "Collapse menu to icons only", "ja": "メニューをアイコンのみに折りたたむ", "vi": "Thu gọn menu về icon"},
"app.nav.expand_tooltip": {"en": "Expand menu", "ja": "メニューを展開", "vi": "Mở rộng menu"},
"app.nav.menu_label": {"en": "MENU", "ja": "MENU", "vi": "MENU"},
"app.nav.menu_label": {"en": "MENU", "ja": "メニュー", "vi": "MENU"},
# Shown on the rail rows the project gate disables (Cowork, GraphRAG) —
# they stay listed and greyed instead of disappearing from the menu.
"app.nav.needs_project": {
+2 -2
View File
@@ -123,7 +123,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Không phân tích được template này. Kiểm tra file .pptx/.xlsx hợp lệ và provider AI trong Settings hoạt động tốt, hoặc tự thêm skill thủ công."},
# ---- flow_dialog.py -----------------------------------------------
"flow.title": {"en": "Flow Management", "ja": "Flow Management", "vi": "Flow Management"},
"flow.title": {"en": "Flow Management", "ja": "フロー管理", "vi": "Flow Management"},
"flow.tab_flow": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
"flow.tab_agents": {"en": "Agents", "ja": "エージェント", "vi": "Agents"},
"flow.tab_skills": {"en": "Skills", "ja": "スキル", "vi": "Skills"},
@@ -140,7 +140,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"flow.task_prompt": {"en": "Task (prompt)", "ja": "タスク(プロンプト)", "vi": "Nhiệm vụ (prompt)"},
"flow.skill": {"en": "Skill", "ja": "スキル", "vi": "Skill"},
"flow.agent": {"en": "AI provider", "ja": "AI プロバイダー", "vi": "AI provider"},
"flow.model_label": {"en": "Agent:", "ja": "Agent:", "vi": "Agent:"},
"flow.model_label": {"en": "Agent:", "ja": "エージェント:", "vi": "Agent:"},
"flow.default_model": {"en": "(provider default)", "ja": "(プロバイダー既定)", "vi": "(mặc định của provider)"},
"flow.gen_task_from_hint": {"en": "Generate task from hint", "ja": "ヒントからタスクを生成", "vi": "Tạo task từ gợi ý"},
"flow.gen_task_tooltip": {
+7 -5
View File
@@ -35,7 +35,7 @@ from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from ...i18n import tr
from ...i18n import bind_text, bind_tip
from ...ui.icons import icon
from .palette_list import _PaletteList
@@ -55,9 +55,11 @@ class AgentListPanel(QWidget):
def __init__(self, parent: QWidget | None = None) -> None:
"""Danh sách agent ở cột trái Co4E Studio, kèm nút tạo mới."""
super().__init__(parent)
self.new_btn = QPushButton(tr("co4e.new"))
# Bound, not set once: this panel has no retranslate hook of its own, and
# Co4ETab (which owns the language callback) cannot reach these tooltips.
self.new_btn = bind_text(QPushButton(), "co4e.new")
self.new_btn.setIcon(icon("plus"))
self.new_btn.setToolTip(tr("co4e.tt_new_agent"))
bind_tip(self.new_btn, "co4e.tt_new_agent")
self.new_btn.setObjectName("co4eSectionAction")
self.new_btn.setFlat(True)
self.new_btn.setCursor(Qt.PointingHandCursor)
@@ -75,11 +77,11 @@ class AgentListPanel(QWidget):
# Edit/delete act on the selected row, so they stay with the list.
self.edit_btn = QPushButton()
self.edit_btn.setIcon(icon("edit"))
self.edit_btn.setToolTip(tr("co4e.tt_edit_agent"))
bind_tip(self.edit_btn, "co4e.tt_edit_agent")
self.edit_btn.setFixedWidth(34)
self.del_btn = QPushButton()
self.del_btn.setIcon(icon("trash"))
self.del_btn.setToolTip(tr("co4e.tt_del_agent"))
bind_tip(self.del_btn, "co4e.tt_del_agent")
self.del_btn.setFixedWidth(34)
# KHONG noi .clicked o day: cung ly do nhu new_btn o tren.
btns.addWidget(self.edit_btn)
+7 -1
View File
@@ -13,7 +13,7 @@ from PySide6.QtWidgets import QSplitter, QWidget
from ...core import co4e, skills as skills_mod
from ...core.co4e_builtins import BUILTIN_AGENTS
from ...core.worker import AgentWorker
from ...i18n import tr
from ...i18n import bind_dynamic, tr
from ...ui.chat_view import ChatView
from ...ui.icons import icon
from ...presentation.co4e.co4e_chat_view import ChatPanel
@@ -55,6 +55,12 @@ class Co4EChatMixin:
self._co4e_routed_provider = None # routing provider override for the next turn
self._vsplit_sizes = [540, 220] # sizes to restore when expanded
self._msgs_collapsed = True
# The tooltip names the action the button would perform, so it depends on
# which way the box is folded — and the fold state lives here, not in the
# panel. Bound so a language change re-reads it instead of freezing the
# wording set when the tab was built.
bind_dynamic(self.chat_toggle_btn, lambda: self.chat_toggle_btn.setToolTip(
tr("co4e.tt_expand_msgs" if self._msgs_collapsed else "co4e.tt_collapse_msgs")))
return panel
def _toggle_messages(self) -> None:
"""Show/hide the WHOLE chat box (message list + composer) below the
+6 -4
View File
@@ -48,7 +48,7 @@ from PySide6.QtWidgets import (
from ...core import co4e, skills as skills_mod
from ...core.co4e_builtins import BUILTIN_AGENTS
from ...i18n import tr
from ...i18n import bind_placeholder, bind_text, tr
from ...theme import current_palette
from ...ui.icons import icon
from ...ui.routing_toggle import RoutingToggle
@@ -223,7 +223,8 @@ class ChatPanel(QWidget):
self.header = QWidget(); self.header.setObjectName("msgHeader")
mh = QHBoxLayout(self.header); mh.setContentsMargins(6, 3, 6, 3); mh.setSpacing(6)
self.msgs_icon = QLabel(); self.msgs_icon.setPixmap(icon("message").pixmap(14, 14))
self.msgs_title = QLabel(tr("co4e.messages")); self.msgs_title.setObjectName("hint")
self.msgs_title = bind_text(QLabel(), "co4e.messages")
self.msgs_title.setObjectName("hint")
self.chat_toggle_btn = QPushButton()
self.chat_toggle_btn.setObjectName("msgToggle")
self.chat_toggle_btn.setFlat(True)
@@ -253,10 +254,11 @@ class ChatPanel(QWidget):
crow.addWidget(self.usage_total_lbl)
_inp = QWidget(); row = QHBoxLayout(_inp); row.setContentsMargins(0, 0, 0, 0)
self.chat_input = _ChatInput()
self.chat_input.setPlaceholderText(tr("co4e.chat_placeholder"))
bind_placeholder(self.chat_input, "co4e.chat_placeholder")
# KHONG noi .submit o day: cung ly do nhu chat_toggle_btn o tren
# (ben goi noi toi _chat_send cua chinh no).
self.chat_send_btn = QPushButton(tr("co4e.send")); self.chat_send_btn.setIcon(icon("send"))
self.chat_send_btn = bind_text(QPushButton(), "co4e.send")
self.chat_send_btn.setIcon(icon("send"))
# KHONG noi .clicked o day: cung ly do nhu tren.
row.addWidget(self.chat_input, 1)
# Off/Auto/Manual routing toggle for Co4E (surface key "co4e").
+4 -2
View File
@@ -14,7 +14,7 @@ from typing import List
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QComboBox, QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QSizePolicy, QSpacerItem, QSplitter, QTabBar, QTabWidget, QVBoxLayout, QWidget
from ...core import co4e
from ...i18n import tr
from ...i18n import bind_text, tr
from ...theme import current_palette
from ...ui.co4e_canvas import Co4ECanvas
from ...ui.icons import icon
@@ -141,7 +141,9 @@ class Co4ELayoutMixin:
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
self.runs_btn.toggled.connect(self._show_runs)
bar.addWidget(QLabel(tr("co4e.flow_name")))
# Bound: nothing else holds this label, so a one-shot tr() here would
# leave "Flow" stuck in the language the toolbar was built in.
bar.addWidget(bind_text(QLabel(), "co4e.flow_name"))
bar.addWidget(self.name_edit, 1)
bar.addWidget(self.add_step_btn)
bar.addWidget(self.save_btn)
+15 -13
View File
@@ -39,7 +39,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from ...i18n import tr
from ...i18n import bind_text, bind_tip
from ...ui.icons import icon
@@ -66,13 +66,15 @@ class RunsPagePanel(QWidget):
hdr = QHBoxLayout()
# The Runs page covers the flow toolbar, so it carries its own way back —
# otherwise the toggle that opened it is off screen.
self.back_btn = QPushButton(tr("co4e.back_to_flow"))
# Bound, not set once: this panel has no retranslate hook of its own, and
# Co4ETab (which owns the language callback) cannot reach these strings.
self.back_btn = bind_text(QPushButton(), "co4e.back_to_flow")
self.back_btn.setIcon(icon("chevron-left"))
self.back_btn.setToolTip(tr("co4e.tt_back_to_flow"))
bind_tip(self.back_btn, "co4e.tt_back_to_flow")
# KHONG noi .clicked o day: ben goi (Co4ETab) tu quyet dinh slot nao
# xu ly - panel chi dung widget, khong biet _show_runs la gi.
hdr.addWidget(self.back_btn)
self.title_label = QLabel(tr("co4e.running_flows"))
self.title_label = bind_text(QLabel(), "co4e.running_flows")
self.title_label.setObjectName("hint")
hdr.addWidget(self.title_label)
# Show + open the workspace folder where flow outputs land (below the tab,
@@ -85,21 +87,21 @@ class RunsPagePanel(QWidget):
# ca hai deu thuoc Co4ETab (can ctx/manager de biet duong dan that).
hdr.addWidget(self.ws_folder_btn)
hdr.addStretch(1)
self.stop_btn = QPushButton(tr("co4e.stop"))
self.stop_btn = bind_text(QPushButton(), "co4e.stop")
self.stop_btn.setIcon(icon("stop"))
self.stop_btn.setObjectName("danger")
self.stop_btn.setToolTip(tr("co4e.tt_stop_run"))
bind_tip(self.stop_btn, "co4e.tt_stop_run")
# KHONG noi .clicked o day: cung ly do nhu back_btn o tren.
self.rename_btn = QPushButton(tr("co4e.rename_run"))
self.rename_btn = bind_text(QPushButton(), "co4e.rename_run")
self.rename_btn.setIcon(icon("edit"))
self.rename_btn.setToolTip(tr("co4e.tt_rename_run"))
bind_tip(self.rename_btn, "co4e.tt_rename_run")
# KHONG noi .clicked o day: cung ly do nhu back_btn o tren.
self.del_btn = QPushButton(tr("co4e.delete_run"))
self.del_btn = bind_text(QPushButton(), "co4e.delete_run")
self.del_btn.setIcon(icon("trash"))
self.del_btn.setToolTip(tr("co4e.tt_delete_run"))
bind_tip(self.del_btn, "co4e.tt_delete_run")
# KHONG noi .clicked o day: cung ly do nhu back_btn o tren.
self.clear_btn = QPushButton(tr("co4e.clear_done"))
self.clear_btn.setToolTip(tr("co4e.tt_clear_runs"))
self.clear_btn = bind_text(QPushButton(), "co4e.clear_done")
bind_tip(self.clear_btn, "co4e.tt_clear_runs")
# KHONG noi .clicked o day: cung ly do nhu back_btn o tren. (Ban goc
# noi thang toi lambda: self.manager.clear_finished(), khong qua mot
# method rieng - Co4ETab van giu dung quirk do khi noi lai signal nay.)
@@ -113,7 +115,7 @@ class RunsPagePanel(QWidget):
self.table.verticalHeader().setVisible(False)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.setToolTip(tr("co4e.tt_runs_list"))
bind_tip(self.table, "co4e.tt_runs_list")
# KHONG noi .itemDoubleClicked o day: cung ly do nhu back_btn o tren.
# Right-click a run → Open / Delete (delete a single old run from history).
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
+53 -15
View File
@@ -11,14 +11,42 @@ from typing import List
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QSplitter, QVBoxLayout, QWidget
from ...core import co4e, skills as skills_mod
from ...i18n import tr
from ...i18n import bind_dynamic, bind_text, bind_tip, tr
from ...ui.icons import icon
from ...presentation.co4e.agent_list_panel import AgentListPanel
from ...presentation.co4e.co4e_chat_view import _skill_names
from ...presentation.co4e.palette_list import _PaletteList
from ...presentation.co4e.skills_list_panel import SkillsListPanel
def _skill_prefix_lookup(all_skills):
"""Answer ``skills.skill_prefix_for`` from an ALREADY-LOADED skill list.
``skill_prefix_for`` re-reads the whole skill folder on every call, so
asking it once per skill made a sidebar reload cost one full disk scan per
skill — measured at ~3.8s of frozen GUI thread on a 121-skill library, and
that reload runs on every language switch.
The scan order and the blank-instructions rule are copied from
``skill_prefix_for`` deliberately: a namesake with no instructions must NOT
end the search, or a skill's text silently becomes empty in an agent prompt.
"""
cache: dict = {}
def lookup(name: str) -> str:
"""The ``## Skill: <name>\\n<instructions>`` block for one name, or ''."""
if not name:
return ""
low = name.strip().lower()
if low not in cache:
cache[low] = next(
(f"## Skill: {s.name}\n{s.instructions.strip()}" for s in all_skills
if (s.slug == low or s.name.lower() == low) and s.instructions.strip()),
"")
return cache[low]
return lookup
class Co4ESidebarMixin:
"""Cột trái của Co4E Studio: Workflows, Agents, Skills và Flow Status."""
def _build_sidebar(self) -> QWidget:
@@ -66,9 +94,12 @@ class Co4ESidebarMixin:
col = _Col(self.side_split)
# --- WORKFLOWS ---------------------------------------------------
self.wf_new_btn = QPushButton(tr("co4e.new"))
# Bound, not set once: Co4ETab._retranslate reloads the sidebar's LIST
# CONTENTS, but these headings, buttons and tooltips are built here and
# nothing re-applied them — they stayed in the language of app start-up.
self.wf_new_btn = bind_text(QPushButton(), "co4e.new")
self.wf_new_btn.setIcon(icon("plus"))
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
bind_tip(self.wf_new_btn, "co4e.tt_new_wf")
self.wf_new_btn.setObjectName("co4eSectionAction")
self.wf_new_btn.setFlat(True)
self.wf_new_btn.setCursor(Qt.PointingHandCursor)
@@ -78,7 +109,7 @@ class Co4ESidebarMixin:
# Draggable: drag a flow onto the canvas to merge it in (Nova-style);
# double-click loads it onto the canvas.
self.wf_list = _PaletteList(payload_role=Qt.UserRole + 2)
self.wf_list.setToolTip(tr("co4e.drag_hint"))
bind_tip(self.wf_list, "co4e.drag_hint")
self.wf_list.itemDoubleClicked.connect(self._load_selected_workflow)
self.wf_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.wf_list.customContextMenuRequested.connect(self._wf_context_menu)
@@ -93,8 +124,9 @@ class Co4ESidebarMixin:
wl.addLayout(wf_btns)
# Its own row: sharing one line with the three icon buttons cut "Chạy
# nền" down to "Chạ" as soon as the sidebar hit its narrow width.
self.wf_runbg_btn = QPushButton(tr("co4e.run_bg")); self.wf_runbg_btn.setIcon(icon("play"))
self.wf_runbg_btn.setToolTip(tr("co4e.tt_run_bg"))
self.wf_runbg_btn = bind_text(QPushButton(), "co4e.run_bg")
self.wf_runbg_btn.setIcon(icon("play"))
bind_tip(self.wf_runbg_btn, "co4e.tt_run_bg")
self.wf_runbg_btn.clicked.connect(self._run_selected_in_background)
wl.addWidget(self.wf_runbg_btn)
col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3)
@@ -135,12 +167,12 @@ class Co4ESidebarMixin:
self.runs_more_btn.setIcon(icon("chevron-right"))
self.runs_more_btn.setFixedWidth(30)
self.runs_more_btn.setFlat(True)
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
bind_tip(self.runs_more_btn, "co4e.tt_runs_tab")
self.runs_more_btn.clicked.connect(lambda: self._show_runs(True))
runs_body = QWidget(); rl = QVBoxLayout(runs_body)
rl.setContentsMargins(0, 0, 0, 0); rl.setSpacing(4)
self.runs_side_list = QListWidget()
self.runs_side_list.setToolTip(tr("co4e.tt_runs_tab"))
bind_tip(self.runs_side_list, "co4e.tt_runs_tab")
self.runs_side_list.itemClicked.connect(self._on_side_run_clicked)
rl.addWidget(self.runs_side_list, 1)
col.addWidget(self._section("co4e.runs_tab", runs_body, self.runs_more_btn), 2)
@@ -202,7 +234,10 @@ class Co4ESidebarMixin:
v.addWidget(body, 1)
self._sections[key] = (head, body, stretch)
self._sync_section_arrow(key)
# bind_dynamic, not bind_text: the heading is the fold arrow plus the
# translated name in caps, so re-applying it means re-running the whole
# line rather than pushing one key into setText.
bind_dynamic(head, lambda k=key: self._sync_section_arrow(k))
return box
def _fold_section(self, key: str, body: QWidget, box: QWidget, on: bool) -> None:
"""Fold/unfold a section AND give its height back to the others.
@@ -223,7 +258,7 @@ class Co4ESidebarMixin:
head.setText(("▾ " if head.isChecked() else "▸ ") + tr(key).upper())
def _icon_btn(self, icon_name: str, tip_key: str, slot) -> QPushButton:
"""Dựng một nút icon nhỏ (rộng 34px) kèm tooltip cho hàng công cụ của mục."""
b = QPushButton(); b.setIcon(icon(icon_name)); b.setToolTip(tr(tip_key))
b = QPushButton(); b.setIcon(icon(icon_name)); bind_tip(b, tip_key)
b.setFixedWidth(34)
b.clicked.connect(slot)
return b
@@ -254,13 +289,16 @@ class Co4ESidebarMixin:
co4e._step_dict(step))
it.setData(Qt.UserRole + 1, ca.id)
self.agent_list.addItem(it)
# Skills
# Skills — the library is read ONCE here and both the names and the
# instructions come out of that one read (see _skill_prefix_lookup).
self.skill_list.clear()
for name in _skill_names():
content = skills_mod.skill_prefix_for(name)
all_skills = skills_mod.list_skills() + skills_mod.builtin_skills()
skill_prefix = _skill_prefix_lookup(all_skills)
for skill in all_skills:
name = skill.name
payload = co4e._step_dict(co4e.Step(
label=name, agent_slug=co4e.slugify(name), role="SKILL", icon="sparkle",
instructions=content, skills=[name]))
instructions=skill_prefix(name), skills=[name]))
self.skill_list.addItem(self._palette_item(name, "sparkle", payload))
@staticmethod
def _palette_item(text: str, icon_name: str, payload: dict) -> QListWidgetItem:
+36 -28
View File
@@ -39,7 +39,7 @@ from PySide6.QtWidgets import (
from ...config import PROVIDER_LABELS
from ...core.co4e import PERMISSION_PRESETS, STEP_DONE, STEP_RUNNING, Step
from ...i18n import tr
from ...i18n import bind_items, bind_placeholder, bind_text, bind_tip, tr
from ...ui.icons import icon, icon_picker_combo
from .node_property_actions_mixin import _StepConfigActionsMixin
from .step_config_section import _add_section
@@ -83,26 +83,30 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
self.label_edit = QLineEdit()
self.label_edit.textChanged.connect(self._on_edit)
form.addRow(tr("co4e.f_label"), self.label_edit)
form.addRow(bind_text(QLabel(), "co4e.f_label"), self.label_edit)
self.role_edit = QLineEdit()
self.role_edit.textChanged.connect(self._on_edit)
form.addRow(tr("co4e.f_role"), self.role_edit)
form.addRow(bind_text(QLabel(), "co4e.f_role"), self.role_edit)
# Dropdown of every icon in the registry (Monitoring's Icon Management
# set + built-ins), each row previewing its actual glyph — still
# editable so a not-yet-added custom name can be typed directly.
self.icon_edit = icon_picker_combo()
self.icon_edit.lineEdit().setPlaceholderText(tr("co4e.f_icon_placeholder"))
# Kept on self because the combo's line edit belongs to C++: a binding
# holds its widget weakly, so with no owner on this side the Python
# wrapper could be collected and the binding silently dropped.
self._icon_line = self.icon_edit.lineEdit()
bind_placeholder(self._icon_line, "co4e.f_icon_placeholder")
self.icon_edit.currentTextChanged.connect(self._on_edit)
form.addRow(tr("co4e.f_icon"), self.icon_edit)
form.addRow(bind_text(QLabel(), "co4e.f_icon"), self.icon_edit)
self.instructions_edit = QPlainTextEdit()
self.instructions_edit.setMaximumHeight(120)
self.instructions_edit.textChanged.connect(self._on_edit)
self.gen_btn = QPushButton(tr("co4e.ai_draft"))
self.gen_btn = bind_text(QPushButton(), "co4e.ai_draft")
self.gen_btn.setIcon(icon("sparkle"))
self.gen_btn.setToolTip(tr("co4e.ai_draft_tooltip"))
bind_tip(self.gen_btn, "co4e.ai_draft_tooltip")
self.gen_btn.setEnabled(ctx is not None)
self.gen_btn.clicked.connect(self._ai_draft)
instr_box = QWidget()
@@ -110,15 +114,15 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
ib.setContentsMargins(0, 0, 0, 0)
ib.addWidget(self.instructions_edit)
ib.addWidget(self.gen_btn, alignment=Qt.AlignRight)
form.addRow(tr("co4e.f_instructions"), instr_box)
form.addRow(bind_text(QLabel(), "co4e.f_instructions"), instr_box)
# Extra context — free-text background/info fed to the step at run time
# (in addition to instructions, attachments and upstream outputs).
self.context_edit = QPlainTextEdit()
self.context_edit.setMaximumHeight(90)
self.context_edit.setPlaceholderText(tr("co4e.f_context_placeholder"))
bind_placeholder(self.context_edit, "co4e.f_context_placeholder")
self.context_edit.textChanged.connect(self._on_edit)
form.addRow(tr("co4e.f_context"), self.context_edit)
form.addRow(bind_text(QLabel(), "co4e.f_context"), self.context_edit)
form2, _model_card = _add_section(outer, tr("co4e.tab_model_perm"))
@@ -128,28 +132,32 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
self.model_combo.editTextChanged.connect(self._on_edit)
self.load_models_btn = QPushButton()
self.load_models_btn.setIcon(icon("download"))
self.load_models_btn.setToolTip(tr("co4e.load_models_tooltip"))
bind_tip(self.load_models_btn, "co4e.load_models_tooltip")
self.load_models_btn.clicked.connect(self._load_models)
self.load_models_btn.setEnabled(ctx is not None)
model_row.addWidget(self.model_combo, 1)
model_row.addWidget(self.load_models_btn)
mrow = QWidget(); mrow.setLayout(model_row)
form2.addRow(tr("co4e.f_model"), mrow)
form2.addRow(bind_text(QLabel(), "co4e.f_model"), mrow)
self.perm_combo = QComboBox()
for preset in PERMISSION_PRESETS:
self.perm_combo.addItem(tr(f"co4e.perm.{preset}"), preset)
perm_keys = [f"co4e.perm.{preset}" for preset in PERMISSION_PRESETS]
for preset, key in zip(PERMISSION_PRESETS, perm_keys):
self.perm_combo.addItem(tr(key), preset)
# Only the visible labels follow the language — the data column stays
# the preset id that ``_on_edit`` persists onto the Step.
bind_items(self.perm_combo, perm_keys)
self.perm_combo.currentIndexChanged.connect(self._on_edit)
form2.addRow(tr("co4e.f_permission"), self.perm_combo)
form2.addRow(bind_text(QLabel(), "co4e.f_permission"), self.perm_combo)
verify_row = QHBoxLayout()
self.verify_chk = QCheckBox(tr("co4e.f_self_verify"))
self.verify_chk = bind_text(QCheckBox(), "co4e.f_self_verify")
self.verify_chk.toggled.connect(self._on_edit)
self.rounds_spin = QSpinBox()
self.rounds_spin.setRange(1, 5)
self.rounds_spin.valueChanged.connect(self._on_edit)
verify_row.addWidget(self.verify_chk)
verify_row.addWidget(QLabel(tr("co4e.f_verify_rounds")))
verify_row.addWidget(bind_text(QLabel(), "co4e.f_verify_rounds"))
verify_row.addWidget(self.rounds_spin)
verify_row.addStretch(1)
vrow = QWidget(); vrow.setLayout(verify_row)
@@ -161,15 +169,15 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
self.skills_list = QListWidget()
self.skills_list.setMaximumHeight(110)
self.skills_list.itemChanged.connect(self._on_edit)
form3.addRow(tr("co4e.f_skills"), self.skills_list)
form3.addRow(bind_text(QLabel(), "co4e.f_skills"), self.skills_list)
# Attachments — files whose extracted text is fed to this step at run time.
self.attach_list = QListWidget()
self.attach_list.setMaximumHeight(80)
self.attach_add_btn = QPushButton(tr("co4e.attach_add"))
self.attach_add_btn = bind_text(QPushButton(), "co4e.attach_add")
self.attach_add_btn.setIcon(icon("plus"))
self.attach_add_btn.clicked.connect(self._add_attachment)
self.attach_del_btn = QPushButton(tr("co4e.attach_remove"))
self.attach_del_btn = bind_text(QPushButton(), "co4e.attach_remove")
self.attach_del_btn.setIcon(icon("trash"))
self.attach_del_btn.clicked.connect(self._del_attachment)
att_btns = QHBoxLayout()
@@ -177,7 +185,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
att_btns.addWidget(self.attach_del_btn)
att_btns.addStretch(1)
abtn = QWidget(); abtn.setLayout(att_btns)
form3.addRow(tr("co4e.f_attachments"), self.attach_list)
form3.addRow(bind_text(QLabel(), "co4e.f_attachments"), self.attach_list)
form3.addRow("", abtn)
# Parallel sub-agents get their OWN section — same header style as
@@ -189,10 +197,10 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
self.sub_list = QListWidget()
self.sub_list.setMaximumHeight(90)
self.sub_list.itemDoubleClicked.connect(self._edit_subagent) # re-pick agent
self.sub_add_btn = QPushButton(tr("co4e.add_subagent"))
self.sub_add_btn = bind_text(QPushButton(), "co4e.add_subagent")
self.sub_add_btn.setIcon(icon("plus"))
self.sub_add_btn.clicked.connect(self._add_subagent)
self.sub_del_btn = QPushButton(tr("co4e.del_subagent"))
self.sub_del_btn = bind_text(QPushButton(), "co4e.del_subagent")
self.sub_del_btn.setIcon(icon("trash"))
self.sub_del_btn.clicked.connect(self._del_subagent)
sub_btns = QHBoxLayout()
@@ -205,17 +213,17 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
# Footer actions — one compact row (Run · Run from here · Delete),
# kept below every section, not inside one of the cards.
self.run_btn = QPushButton(tr("co4e.run"))
self.run_btn = bind_text(QPushButton(), "co4e.run")
self.run_btn.setIcon(icon("play"))
self.run_btn.setToolTip(tr("co4e.run_this_step"))
bind_tip(self.run_btn, "co4e.run_this_step")
self.run_btn.clicked.connect(lambda: self.run_node.emit(self._node_id))
self.run_from_btn = QPushButton(tr("co4e.run_from_here"))
self.run_from_btn.setToolTip(tr("co4e.run_from_here"))
self.run_from_btn = bind_text(QPushButton(), "co4e.run_from_here")
bind_tip(self.run_from_btn, "co4e.run_from_here")
self.run_from_btn.clicked.connect(lambda: self.run_from.emit(self._node_id))
self.del_btn = QPushButton()
self.del_btn.setIcon(icon("trash"))
self.del_btn.setObjectName("danger")
self.del_btn.setToolTip(tr("co4e.delete_step"))
bind_tip(self.del_btn, "co4e.delete_step")
self.del_btn.setFixedWidth(38)
self.del_btn.clicked.connect(lambda: self.delete_node.emit(self._node_id))
foot = QHBoxLayout()
+5 -3
View File
@@ -29,7 +29,7 @@ from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget
from ...i18n import tr
from ...i18n import bind_text, bind_tip
from .palette_list import _PaletteList
@@ -50,8 +50,10 @@ class SkillsListPanel(QWidget):
cái gì.
"""
super().__init__(parent)
self.manage_btn = QPushButton(tr("co4e.manage_skills"))
self.manage_btn.setToolTip(tr("co4e.tt_manage_skills"))
# Bound, like AgentListPanel's: the panel owns how its own button reads,
# so no embedder has to remember it in a retranslate method.
self.manage_btn = bind_text(QPushButton(), "co4e.manage_skills")
bind_tip(self.manage_btn, "co4e.tt_manage_skills")
self.manage_btn.setObjectName("co4eSectionAction")
self.manage_btn.setFlat(True)
self.manage_btn.setCursor(Qt.PointingHandCursor)
@@ -121,6 +121,13 @@ class UsageChartWidget(QWidget):
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho nhãn và tooltip."""
# setItemText, chứ không clear()+addItem(): cột data của hai combo này
# là thứ quyết định kỳ và chỉ số đang xem, dựng lại danh sách sẽ reset cả
# hai. Khoá dịch suy ra từ chính cột data nên không phải chép lại danh
# sách giá trị ở hai nơi.
for combo, prefix in ((self.gran_combo, "gran"), (self.metric_combo, "metric")):
for i in range(combo.count()):
combo.setItemText(i, tr(f"dashboard.{prefix}_{combo.itemData(i)}"))
self.currency_lbl.setText(tr("monitoring.overview_currency"))
self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
self._chart_title.setText(tr("dashboard.chart_title"))
@@ -20,7 +20,7 @@ from PySide6.QtWidgets import (
QSplitter, QTableWidget, QVBoxLayout, QWidget,
)
from ....i18n import tr
from ....i18n import bind_tip, tr
from ....ui.icons import icon
from .event_table import PAGE_SIZE_OPTIONS, ClickOutsideCloser, EventTable
from .event_detail_panel import EventDetailPanel
@@ -83,7 +83,10 @@ def build_filter_scaffold(
search.textChanged.connect(table.apply_filter)
ai_btn = QPushButton(tr("monitoring.ai_filter_btn"))
ai_btn.setIcon(icon("sparkle"))
ai_btn.setToolTip(tr("monitoring.ai_filter_tooltip"))
# Bound rather than set once: this scaffold builds the button for all
# three event tabs, and none of their retranslate() methods can reach a
# tooltip that was applied here.
bind_tip(ai_btn, "monitoring.ai_filter_tooltip")
ai_btn.setCursor(Qt.PointingHandCursor)
if on_ai_filter is not None:
ai_btn.clicked.connect(lambda: on_ai_filter(search, ai_btn))
+108
View File
@@ -0,0 +1,108 @@
"""Lớp phủ "đang xử lý" ở cấp cửa sổ, dành cho tác vụ chặn GUI thread.
Vì sao là file riêng chứ không nhét vào ``main_window.py``: file đó chỉ còn 9
dòng vật lý dưới trần 400 của Gate S, và một lớp phủ cấp cửa sổ là một trách
nhiệm riêng (guardrail G6).
Cùng lý do ``repaint()`` với panel bận của GraphRAG — xem
``presentation/graph/structure_graph_view.py:139-151``.
"""
from __future__ import annotations
from time import perf_counter
from typing import Callable
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
# Dưới mức này người dùng chưa kịp nhận ra mình đang đợi, nên một lớp phủ toàn
# cửa sổ chỉ kịp nháy lên rồi tắt — tự nó là một khuyết tật giao diện, không
# phải một lời trấn an.
_NOTICEABLE_MS = 400.0
class BusyOverlay(QWidget):
"""A window-wide "please wait" cover for work that blocks the GUI thread.
Deliberately NOT registered with :func:`i18n.on_language_changed`: the text
is supplied by the caller right before the block and must stay in the
language the rest of the screen is still showing.
"""
def __init__(self, parent: QWidget) -> None:
"""Build the cover hidden; it sizes itself to the parent on every show."""
super().__init__(parent)
self.setObjectName("busyOverlay")
# A QWidget SUBCLASS ignores a stylesheet background without this
# attribute; a plain QWidget instance (the panel below) does not need it.
self.setAttribute(Qt.WA_StyledBackground, True)
self.setFocusPolicy(Qt.NoFocus)
# Chưa đo được lượt nào: xem mục ``run_blocking``.
self._last_ms: float | None = None
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addStretch(1)
row = QHBoxLayout()
row.addStretch(1)
self._panel = QWidget()
self._panel.setObjectName("busyOverlayPanel")
inner = QHBoxLayout(self._panel)
inner.setContentsMargins(24, 18, 24, 18)
self._label = QLabel()
inner.addWidget(self._label)
row.addWidget(self._panel)
row.addStretch(1)
lay.addLayout(row)
lay.addStretch(1)
self.hide()
def text(self) -> str:
"""Chữ đang hiện trên lớp phủ (dùng cho test)."""
return self._label.text()
def run_blocking(self, message: str, work: Callable[[], None]) -> None:
"""Run ``work`` on the GUI thread, covered only when that is worth doing.
Nothing can time the freeze WHILE it happens: the GUI thread stops, so
no timer fires and no watchdog can raise the cover mid-way. The only
honest clock is the PREVIOUS run of this same call, so that is what
decides. No measurement yet (the first switch of a process) errs
towards showing: one flash is a smaller defect than a multi-second
freeze with nothing on screen to explain it.
The result is self-calibrating. A fast machine flashes once per launch
and then stays out of the way; a slow one, or a big skill library, gets
the cover on every switch from the second one on.
``work`` is timed and its exceptions propagate — the cover still comes
down, so a raising callback cannot leave it stuck on screen forever.
"""
if self._last_ms is None or self._last_ms >= _NOTICEABLE_MS:
self.show_busy(message)
started = perf_counter()
try:
work()
finally:
self._last_ms = (perf_counter() - started) * 1000.0
self.hide_busy()
def show_busy(self, message: str) -> None:
"""Show the cover and FORCE it onto the screen right now.
``repaint()``, not ``update()``: the caller is about to block the GUI
thread, so a queued paint would only run once the freeze is over — the
one moment the cover is no longer needed.
No animated progress bar on purpose: with no event loop running,
nothing would move; only static text is guaranteed to be readable.
"""
self._label.setText(message)
self.setGeometry(self.parent().rect())
self.show()
self.raise_()
self.repaint()
def hide_busy(self) -> None:
"""Release the cover. Call from ``finally`` so a raising callback
cannot leave it stuck on screen forever."""
self.hide()
+13
View File
@@ -260,6 +260,12 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip"))
if hasattr(self, "provider_lbl"):
self.provider_lbl.setText(tr("app.provider"))
# The label is hidden — the combo names itself through its tooltip
# (see top_bar._build_account_row), so that is the one users read.
self.provider_combo.setToolTip(tr("app.provider"))
if hasattr(self, "nav_project"):
self.nav_project.setToolTip(tr("app.nav.project_pick"))
self.nav_recents_hdr.setText(tr("app.nav.recents"))
if hasattr(self, "settings_btn"):
self.settings_btn.setText(tr("app.settings"))
if hasattr(self, "theme_btn"):
@@ -271,6 +277,13 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
if getattr(self, "help_agent", None) is not None:
self.help_agent.retranslate()
self._tray.retranslate()
# Thanh trạng thái (góc dưới bên trái) nhận thông báo từ hàng chục nơi
# qua signal ``status_message``, và signal đó mang CHUỖI ĐÃ DỊCH chứ
# không mang khoá — nên không thể dịch lại câu đang hiện. Đưa nó về câu
# nền của ngôn ngữ mới: câu cũ không đọng lại bằng thứ tiếng vừa rời đi,
# mà chỗ đó cũng không trống trơn. Thông báo là ghi chú về một việc vừa
# xong, nên bỏ nó đi khi đổi ngôn ngữ không làm mất thông tin nào.
self.statusBar().showMessage(tr("app.status.ready"))
# ---- system tray (run in background when the window is closed) ---
+16 -2
View File
@@ -253,10 +253,24 @@ class NavRailMixin:
tree.blockSignals(blocked)
# Both destination lists are exactly as tall as their rows; the
# stretch in between belongs to RECENTS.
#
# The frame, and nothing else. A flat ``+ 8`` here used to leave 6px
# of dead space under the last row of each list, and because the
# Settings button sits DIRECTLY under nav_bottom (nvl has no
# spacing), that space landed between Giám sát and Settings only —
# so three rows that read as one list were spaced 18/26px. Padding
# a row is the item delegate's job; this is the frame's.
row_h = 0
for tree in (self.nav, self.nav_bottom):
n = tree.topLevelItemCount()
row_h = tree.sizeHintForRow(0) if n else 0
tree.setFixedHeight(n * row_h + 8)
row_h = tree.sizeHintForRow(0) if n else row_h
tree.setFixedHeight(n * row_h + 2 * tree.frameWidth())
# Settings is one more row of the same list, so it gets the rows'
# own height rather than a second set of paddings guessed to match
# it — the only way the three stay evenly spaced when the font (and
# with it ``sizeHintForRow``) is not the one this was tuned on.
if row_h and hasattr(self, "_nav_settings_btn"):
self._nav_settings_btn.setFixedHeight(row_h)
if keep:
self._select_nav_row(*keep)
finally:
+47 -3
View File
@@ -54,7 +54,12 @@ class TopBarMixin:
self._nav_settings_btn.setCursor(Qt.PointingHandCursor)
self._nav_settings_btn.clicked.connect(self._open_settings)
srow = QHBoxLayout(self._nav_settings_btn)
srow.setContentsMargins(_NAV_ROW_INSET, 6, 8, 6)
# No vertical padding of its own: ``_rebuild_nav`` pins this button to the
# nav rows' OWN height, so the 6px a row pads with is already inside
# that number. Adding it again here made the row taller than the button
# (28 wanted, 20 given), which both clipped the icon and pushed the text
# 8px below an even pitch with Dashboard / Giám sát.
srow.setContentsMargins(_NAV_ROW_INSET, 0, 8, 0)
srow.setSpacing(_NAV_ROW_GAP)
self._nav_settings_icon = QLabel()
self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16))
@@ -63,6 +68,9 @@ class TopBarMixin:
srow.addWidget(self._nav_settings_icon)
srow.addWidget(self._nav_settings_text)
srow.addStretch(1)
# The first _rebuild_nav() ran before this button existed (it is what
# fills the list this row belongs under), so take the height here too.
self._nav_settings_btn.setFixedHeight(self.nav_bottom.sizeHintForRow(0))
nvl.addWidget(self._nav_settings_btn)
self._account_row = self._build_account_row()
@@ -194,6 +202,39 @@ class TopBarMixin:
# Khong bao "dang dung <provider>" o thanh trang thai: chinh bo chon
# provider nam ngay tren man hinh va da hien thu vua chon, nen dong thong
# bao chi nhac lai mot thu nguoi dung vua tu tay lam.
def _lang_busy_overlay(self):
"""The window's busy cover, built on first use.
Built lazily so a window that never changes language never gets one —
and so ``_open_settings`` can be checked for "no switch, no flash".
"""
overlay = getattr(self, "_lang_busy", None)
if overlay is None:
from .busy_overlay import BusyOverlay
overlay = BusyOverlay(self)
self._lang_busy = overlay
return overlay
def _switch_language(self, lang: str) -> None:
"""Apply a new UI language behind a busy cover.
``set_language`` runs every registered widget's re-translation on the
GUI thread, which on a large skill library takes long enough to look
like a hang. Nothing can raise a cover once that has started (no event
loop is left running), so it goes up FIRST — see ``busy_overlay.py``.
The message is read before the switch on purpose: mid-switch the only
language the user can still read is the one being left behind.
"""
message = tr("app.lang.switching")
self.language_combo.setEnabled(False)
try:
self._lang_busy_overlay().run_blocking(message, lambda: set_language(lang))
finally:
# In a ``finally`` so a listener that raises cannot leave the
# switcher locked for the rest of the session.
self.language_combo.setEnabled(True)
def _on_language_changed(self, _idx: int) -> None:
"""Đổi ngôn ngữ giao diện; trùng ngôn ngữ hiện tại thì bỏ qua để không dựng lại
toàn bộ chữ vô ích.
@@ -203,7 +244,7 @@ class TopBarMixin:
return
self.ctx.config.language = lang
self.ctx.save()
set_language(lang) # notifies every registered persistent widget
self._switch_language(lang) # notifies every registered persistent widget
def _open_settings(self) -> None:
"""Mở hộp thoại Cài đặt; bấm Lưu thì áp lại theme và làm mới thanh trên."""
dlg = SettingsDialog(self.ctx, self)
@@ -214,7 +255,10 @@ class TopBarMixin:
from ...ui.icons import icon as _theme_icon
self.theme_btn.setIcon(
_theme_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor")))
set_language(self.ctx.config.language) # apply if changed in Settings
# Guarded, not left to set_language's own no-op check: the cover
# around the switch would otherwise flash on every Save.
if self.ctx.config.language != get_language():
self._switch_language(self.ctx.config.language)
# reflect provider/theme/language changes
i = self.provider_combo.findData(self.ctx.config.active_provider)
if i >= 0:
+174
View File
@@ -0,0 +1,174 @@
"""Co4E left column: the skill library must be read ONCE per sidebar reload.
Why this test exists
--------------------
``Co4ESidebarMixin._reload_sidebar`` used to call
``core.skills.skill_prefix_for(name)`` once per skill. Every one of those calls
re-reads the *whole* skill folder from disk (``list_skills()`` +
``builtin_skills()``), so the reload was O(N**2) in the number of skills.
That reload runs on every language switch (``Co4ETab._retranslate`` ->
``_reload_sidebar``). Measured end-to-end on a real library of 121 skills, the
per-name calls cost ~3.8 s of blocked GUI thread — the "app freezes for a few
seconds when I switch Vietnamese -> English" the user reported.
Two different things are guarded here, and they fail for different reasons:
* ``test_reload_sidebar_reads_the_skill_library_once`` — the performance
contract. Red before the fix (the library was read 1 + N times), green after.
* the two equivalence tests — the *correctness* contract. A lookup table is
easy to build subtly wrong, and a wrong one silently changes which skill text
is pushed into an agent prompt. They pin the answer to ``skill_prefix_for``
itself in exactly the cases where a naive ``{name: skill}`` dict diverges:
a name shared by a user skill and a built-in (first match wins, user first),
a skill whose instructions are blank (it does NOT end ``skill_prefix_for``'s
scan, so a later namesake with real instructions must still win), and
lookups by slug / different case / padding / unknown name / empty name.
Those two are characterization tests: green before AND after by design.
"""
from __future__ import annotations
from dataclasses import replace
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QListWidget
from cowork_local.core import co4e, skills as skills_mod
from cowork_local.presentation.co4e.co4e_sidebar import Co4ESidebarMixin
class _SidebarHarness(Co4ESidebarMixin):
"""Just enough state to run the real ``_reload_sidebar``.
Building a whole ``Co4ETab`` would drag in config/HOME-dependent module
constants (see ``tests/test_build_co4e_tab.py``); the mixin only touches
the three list widgets, so the real production method runs unchanged here.
"""
def __init__(self) -> None:
"""Create the three palette lists ``_reload_sidebar`` fills in."""
self.wf_list = QListWidget()
self.agent_list = QListWidget()
self.skill_list = QListWidget()
@pytest.fixture
def harness(qapp, monkeypatch):
"""A sidebar harness with the workflow/agent sources stubbed out empty."""
monkeypatch.setattr(co4e, "list_workflows", lambda: [])
monkeypatch.setattr(co4e, "list_custom_agents", lambda: [])
return _SidebarHarness()
def _install_skills(monkeypatch, user, builtin):
"""Replace the two disk-reading skill sources and count how often they run.
Fresh copies are handed out on every call so a caller mutating a returned
``Skill`` cannot make a later call look different.
"""
calls = {"list_skills": 0, "builtin_skills": 0}
def _list_skills(directory=None):
"""Stand-in for ``skills.list_skills`` that records each read."""
calls["list_skills"] += 1
return [replace(s) for s in user]
def _builtin_skills():
"""Stand-in for ``skills.builtin_skills`` that records each read."""
calls["builtin_skills"] += 1
return [replace(s) for s in builtin]
monkeypatch.setattr(skills_mod, "list_skills", _list_skills)
monkeypatch.setattr(skills_mod, "builtin_skills", _builtin_skills)
return calls
def _skill_rows(sidebar):
"""Return ``(item text, payload instructions)`` for every skill palette row."""
return [(sidebar.skill_list.item(i).text(),
sidebar.skill_list.item(i).data(Qt.UserRole)["instructions"])
for i in range(sidebar.skill_list.count())]
def test_reload_sidebar_reads_the_skill_library_once(harness, monkeypatch):
"""One sidebar reload must hit the skill library exactly once, not once per skill."""
user = [skills_mod.Skill(name=f"Skill {i}", instructions=f"Body {i}")
for i in range(8)]
calls = _install_skills(monkeypatch, user, [])
harness._reload_sidebar()
assert harness.skill_list.count() == 8, "every skill must still reach the palette"
assert calls["list_skills"] == 1, (
f"the skill library was read {calls['list_skills']}x for 8 skills — "
"reading it once per skill is the O(N^2) freeze on language switch"
)
assert calls["builtin_skills"] == 1, (
f"built-in skills were read {calls['builtin_skills']}x for 8 skills"
)
def test_skill_payloads_match_skill_prefix_for(harness, monkeypatch):
"""The palette payload must be byte-identical to ``skill_prefix_for``'s answer.
The fixture is deliberately hostile: a name shared by a user skill and a
built-in, a blank-instructions skill followed by a namesake with real
content, and names with padding/odd casing.
"""
user = [
skills_mod.Skill(name="Shared Name", instructions="USER VERSION"),
skills_mod.Skill(name="Blank First", instructions=" "),
skills_mod.Skill(name="Blank First", instructions="LATER, WITH CONTENT"),
skills_mod.Skill(name=" Padded Name ", instructions="PADDED BODY"),
skills_mod.Skill(name="MiXeD CaSe", instructions="MIXED BODY"),
]
builtin = [
skills_mod.Skill(name="Shared Name", instructions="BUILTIN VERSION"),
skills_mod.Skill(name="Builtin Only", instructions="BUILTIN BODY"),
]
_install_skills(monkeypatch, user, builtin)
harness._reload_sidebar()
expected = [(s.name, skills_mod.skill_prefix_for(s.name)) for s in user + builtin]
assert _skill_rows(harness) == expected
rows = dict(_skill_rows(harness))
# A user skill wins over a built-in of the same name: list_skills() comes
# first and skill_prefix_for() returns the FIRST match, not the last.
assert rows["Shared Name"] == "## Skill: Shared Name\nUSER VERSION"
# A blank-instructions skill does not end the scan, so its later namesake
# supplies the text. A last-write-wins dict would answer '' here.
assert rows["Blank First"] == "## Skill: Blank First\nLATER, WITH CONTENT"
assert rows["Builtin Only"] == "## Skill: Builtin Only\nBUILTIN BODY"
def test_lookup_matches_skill_prefix_for_on_odd_names(monkeypatch):
"""Slug / case / padding / unknown / empty lookups must answer like ``skill_prefix_for``."""
# Imported inside the test on purpose: this symbol is what the fix
# introduces, and a module-level import would turn the pre-fix run into a
# collection error instead of a real assertion failure in the test above.
from cowork_local.presentation.co4e.co4e_sidebar import _skill_prefix_lookup
user = [
skills_mod.Skill(name="Shared Name", instructions="USER VERSION"),
skills_mod.Skill(name="Blank First", instructions=""),
skills_mod.Skill(name="Blank First", instructions="LATER, WITH CONTENT"),
skills_mod.Skill(name="Tiếng Việt / 日本語", instructions="UNICODE BODY"),
skills_mod.Skill(name="", instructions="NAMELESS BODY"),
]
builtin = [skills_mod.Skill(name="Shared Name", instructions="BUILTIN VERSION")]
_install_skills(monkeypatch, user, builtin)
lookup = _skill_prefix_lookup(skills_mod.list_skills() + skills_mod.builtin_skills())
probes = [
"Shared Name", "shared name", "SHARED NAME", " Shared Name ",
"shared-name", # by slug
"Blank First", "blank-first",
"Tiếng Việt / 日本語", "tiếng-việt-日本語",
"", " ", "no-such-skill-anywhere", "skill",
]
for probe in probes:
assert lookup(probe) == skills_mod.skill_prefix_for(probe), probe
+325
View File
@@ -0,0 +1,325 @@
"""Đổi ngôn ngữ thì MỌI chữ trên màn hình phải đổi theo.
Lỗi mà bộ test này khoá lại: `w.setToolTip(tr("k"))` chỉ đúng ở đúng thời điểm
chạy dòng đó. Không có gì áp lại, nên sau khi người dùng đổi ngôn ngữ thì chữ
đứng nguyên ở ngôn ngữ cũ. Vì `DEFAULT_LANGUAGE = "vi"`, triệu chứng người dùng
báo là "chọn English mà nhiều chỗ vẫn tiếng Việt".
Cách kiểm: KHÔNG so chữ với bảng dịch — làm thế thì một nhãn tiếng Việt trùng
chữ với nhãn khác sẽ báo oan (và ngược lại, "OneDrive" giống nhau ở cả ba ngôn
ngữ sẽ lọt). Thay vào đó thay `tr()` bằng hàm trả về một chuỗi MỐC, rồi gọi
`set_language()`. Chỗ nào được áp lại sẽ mang mốc; chỗ nào không mang mốc là chỗ
không có ai áp lại — đó chính là lỗi, và biết chắc chứ không phải suy đoán.
Hai điểm mà một bản kiểm ngây thơ sẽ sai:
* `from ...i18n import tr` COPY tham chiếu vào namespace của từng module, nên
sửa `i18n.tr` một mình là không đủ — phải thay ở mọi module đã import nó.
* Lưới vẽ lại bằng `deleteLater()` để lại widget cũ còn sống tới khi vòng lặp sự
kiện tiêu hoá xong. Không `sendPostedEvents(DeferredDelete)` thì hàng chục
widget bóng ma mang chữ cũ sẽ bị đếm là lỗi trong khi người dùng không hề thấy.
"""
from __future__ import annotations
import sys
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
MOC = "\u2063" # invisible separator: không chuỗi hiển thị thật nào chứa nó
#: Khoá cố ý giống nhau ở cả ba ngôn ngữ: tên thương hiệu, tên sản phẩm, ký hiệu,
#: placeholder thuần định dạng, và mã mức độ hiển thị nguyên dạng. Thêm khoá mới
#: vào đây phải kèm lý do — đây là chỗ dễ dùng để lặng lẽ bỏ qua việc dịch.
KHOA_KHONG_CAN_DICH = {
# thương hiệu / tên sản phẩm
"app.logo", "app.credit", "login.header",
"app.tab.code", "app.tab.cowork", "app.tab.structure",
"code.title", "code.onedrive_badge", "code.onedrive_btn",
"cowork.title", "sidebar.filter.code", "sidebar.filter.cowork",
"workspace.tab_co4e", "workspace.tab_co4e_tooltip", "workspace.tab_cowork",
"workspace.tab_graphrag", "structure.cmem_ui_open",
"settings.group.anthropic", "settings.group.cowork", "settings.group.structure",
"settings.group.teams", "settings.history_onedrive",
"settings.ms365_connector.onedrive", "settings.ms365_connector.outlook",
"settings.ms365_connector.sharepoint", "settings.ms365_connector.teams",
"schedtask.stepexec.co4e", "schedtask.stepexec.cowork",
"schedtask.type.co4e_code", "schedtask.type.cowork",
"monitoring.tab_mcp", "ext.mode_rest", "schedtask.f_cron",
"help_agent.badge", "help_agent.title", # dùng nguyên dạng trong cả câu tiếng Nhật
"help_agent.default_user", # đứng thay cho TÊN người dùng
# viết tắt / ký hiệu / thuần định dạng
"accounts.ai_search_btn", "monitoring.ai_filter_btn", "monitoring.overview_res_cpu",
"monitoring.na", "schedtask.add_link_label", "settings.teams_webhook",
"cowork.project_label", "connectors.jira_fail", "tools_admin.jira_fail",
# mã mức độ: giữ nguyên dạng ở CẢ BA ngôn ngữ, có chủ ý
"monitoring.severity_critical", "monitoring.severity_info", "monitoring.severity_medium",
}
# ---- dữ liệu: bảng dịch không được thiếu tiếng Nhật ----------------------
def test_moi_khoa_co_du_ba_ngon_ngu():
from cowork_local.i18n import STRINGS
thieu = {k: sorted({"en", "ja", "vi"} - {x for x in v if v.get(x)})
for k, v in STRINGS.items()
if not all(v.get(x) for x in ("en", "ja", "vi"))}
assert thieu == {}, f"khoá thiếu bản dịch: {thieu}"
def test_ban_dich_nhat_khong_phai_chuoi_tieng_anh():
"""`ja` bằng đúng `en` nghĩa là khoá đó chưa được dịch — trừ danh sách miễn.
Đây là nửa còn lại của lỗi người dùng báo: "chọn tiếng Nhật thì vài chỗ hiển
thị tiếng Anh". Nó KHÔNG phải lỗi dây nối (widget vẫn áp lại `tr()` đúng),
mà là lỗ trong dữ liệu dịch — nên phải có cổng riêng canh.
"""
from cowork_local.i18n import STRINGS
chua_dich = sorted(k for k, v in STRINGS.items()
if v.get("en") == v.get("ja") and k not in KHOA_KHONG_CAN_DICH)
assert chua_dich == [], (
"khoá chưa có bản dịch tiếng Nhật (thêm bản dịch, hoặc khai vào "
f"KHOA_KHONG_CAN_DICH kèm lý do): {chua_dich}")
def test_danh_sach_mien_khong_phinh_len_am_tham():
"""Danh sách miễn chỉ được chứa khoá THẬT SỰ giống nhau ở en/ja.
Không có test này thì cách dễ nhất để làm cổng trên xanh lại là nhét khoá
vào danh sách miễn rồi để nguyên đó sau khi đã dịch.
"""
from cowork_local.i18n import STRINGS
thua = sorted(k for k in KHOA_KHONG_CAN_DICH
if k in STRINGS and STRINGS[k].get("en") != STRINGS[k].get("ja"))
assert thua == [], f"khoá đã có bản dịch nhưng vẫn nằm trong danh sách miễn: {thua}"
# ---- lúc chạy: đổi ngôn ngữ thì chữ trên màn hình phải đổi --------------
def _chu_cua(w):
"""Mọi chỗ chữ hiện ra từ một widget, kèm tên thuộc tính."""
from PySide6.QtWidgets import (
QComboBox, QLineEdit, QListWidget, QPlainTextEdit, QTabWidget,
QTableWidget, QTextEdit, QTreeWidget,
)
ra = []
def them(ten, s):
if isinstance(s, str) and s.strip():
ra.append((ten, s))
# QLineEdit.text() là NỘI DUNG người dùng gõ, không phải nhãn giao diện.
if hasattr(w, "text") and not isinstance(w, (QLineEdit, QTextEdit, QPlainTextEdit)):
try:
them("text", w.text())
except RuntimeError:
return ra
for ten in ("windowTitle", "placeholderText", "toolTip", "title", "accessibleName"):
f = getattr(w, ten, None)
if callable(f):
try:
them(ten, f())
except (RuntimeError, TypeError):
pass
if isinstance(w, QTabWidget):
for i in range(w.count()):
them(f"tabText[{i}]", w.tabText(i))
them(f"tabToolTip[{i}]", w.tabToolTip(i))
if isinstance(w, QComboBox):
for i in range(w.count()):
them(f"itemText[{i}]", w.itemText(i))
if isinstance(w, QListWidget):
for i in range(w.count()):
it = w.item(i)
if it is not None:
them(f"item[{i}]", it.text())
them(f"itemTip[{i}]", it.toolTip())
if isinstance(w, QTableWidget):
for c in range(w.columnCount()):
h = w.horizontalHeaderItem(c)
if h is not None:
them(f"hheader[{c}]", h.text())
if isinstance(w, QTreeWidget):
hi = w.headerItem()
if hi is not None:
for c in range(w.columnCount()):
them(f"hheader[{c}]", hi.text(c))
return ra
def _chu_so_huu(w):
"""Lớp widget của chính dự án gần nhất trên đường đi lên.
Một `QPushButton` trần không cho biết nó thuộc màn nào; tổ tiên gần nhất do
dự án định nghĩa thì cho biết, và đó là file cần sửa.
"""
cur = w
while cur is not None:
mod = type(cur).__module__ or ""
if mod.startswith("cowork_local"):
return f"{mod}.{type(cur).__name__}"
cur = cur.parent()
return "?"
def _chup(root, giu):
"""Ảnh chụp mọi chỗ chữ trong cây widget.
``giu`` nhận thêm tham chiếu tới từng widget đã đi qua: wrapper PySide6 bị
thu hồi thì `id()` được cấp lại cho wrapper sau, và vòng quét sẽ tưởng đã
thăm rồi mà dừng sớm — mất phần lớn cây.
"""
from PySide6.QtWidgets import QMenu, QWidget
anh, ngan, da_tham = {}, [root], set()
while ngan:
w = ngan.pop()
if id(w) in da_tham:
continue
da_tham.add(id(w))
giu.append(w)
so_huu = _chu_so_huu(w)
for ten, s in _chu_cua(w):
anh[(id(w), type(w).__name__, so_huu, ten)] = s
try:
ngan.extend([c for c in w.children() if isinstance(c, (QWidget, QMenu))])
except RuntimeError:
pass
return anh
def _thay_tr(moi, cu):
"""Thay `tr` ở MỌI module của dự án đang trỏ tới ``cu``. Trả về số module."""
from cowork_local import i18n as i18n_mod
n = 0
for ten, m in list(sys.modules.items()):
if ten.startswith("cowork_local") and getattr(m, "tr", None) is cu:
setattr(m, "tr", moi)
n += 1
i18n_mod.tr = moi
return n
def _tieu_hoa(qapp):
"""Cho Qt xử lý hết `deleteLater()` để không đếm widget bóng ma."""
from PySide6.QtCore import QEvent
for _ in range(3):
qapp.processEvents()
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
@pytest.fixture
def cua_so(qapp, tmp_path):
"""`MainWindow` thật, đã dựng cả bốn trang dựng-lười.
Trang chưa dựng thì không có chữ nào để kiểm, mà đó lại đúng là nơi lỗi hay
nằm — nên phải gọi `_ensure_page` cho hết.
"""
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
duong_dan = tmp_path / "config.json"
build_config(duong_dan)
win = MainWindow(build_context(duong_dan))
win.resize(1500, 950)
for row in range(len(win._page_widgets)):
win._ensure_page(row)
yield win
win.close()
def test_doi_ngon_ngu_thi_khong_con_chu_ngon_ngu_cu(qapp, cua_so):
"""Không chỗ nào giữ lại chữ của ngôn ngữ cũ sau khi đổi ngôn ngữ."""
from cowork_local import i18n as i18n_mod
giu = [] # chống cấp lại id() cho wrapper mới
goc = i18n_mod.tr
ngon_ngu_cu = i18n_mod.get_language()
i18n_mod.set_language("vi")
_tieu_hoa(qapp)
truoc = _chup(cua_so, giu)
def moc(key, **kw):
return MOC + key
try:
assert _thay_tr(moc, goc) > 0, "không thay được tr() ở module nào"
i18n_mod.set_language("en")
_tieu_hoa(qapp)
sau = _chup(cua_so, giu)
finally:
_thay_tr(goc, moc)
i18n_mod.set_language(ngon_ngu_cu)
# Chỉ tính chỗ mà chữ CÓ phụ thuộc ngôn ngữ: "OneDrive" giống nhau ở cả ba
# ngôn ngữ nên không áp lại cũng không ai thấy khác.
phu_thuoc_ngon_ngu = set()
for v in i18n_mod.STRINGS.values():
gia_tri = {v.get("en"), v.get("ja"), v.get("vi")}
if len(gia_tri) > 1:
phu_thuoc_ngon_ngu.update(x for x in gia_tri if x)
# Nhãn nhà cung cấp là DANH TÍNH lấy từ ``config.PROVIDER_LABELS``, không đi
# qua ``tr()`` ở bất kỳ đâu (Agents Admin, model resolver, preview_ai đều
# hiện nguyên dạng như nhau). Một trong số chúng tình cờ trùng chữ với bản
# tiếng Anh của ``settings.group.openai`` — là tiêu đề mục trong Cài đặt,
# một khoá khác hẳn. Không trừ ra thì phép đo báo oan chỗ này mãi mãi.
# Muốn dịch tên nhà cung cấp thì phải sửa ``config.py`` và sửa đồng loạt cả
# năm nơi đang đọc nó — đó là một quyết định sản phẩm, không phải lỗi dây nối.
from cowork_local.config import PROVIDER_LABELS
phu_thuoc_ngon_ngu -= set(PROVIDER_LABELS.values())
con_chu_cu = sorted(
f"{cho[2]} :: {cho[1]}.{cho[3]} = {chu!r}"
for cho, chu in sau.items()
if cho in truoc and not chu.startswith(MOC) and chu in phu_thuoc_ngon_ngu
)
assert con_chu_cu == [], (
"những chỗ này không được áp lại khi đổi ngôn ngữ — dùng bind_text / "
"bind_tip / bind_placeholder / bind_items / bind_dynamic của i18n thay "
"cho setText(tr(...)) một lần:\n " + "\n ".join(con_chu_cu))
def test_phep_do_thuc_su_quet_duoc_man_hinh(qapp, cua_so):
"""Chốt độ phủ của chính phép đo trên.
Một hôm nào đó `_chup` đi sai (đúng lỗi `id()` bị cấp lại đã gặp) thì nó vẫn
trả về một dict rỗng và test trên vẫn xanh — xanh vì không kiểm gì cả.
"""
giu = []
anh = _chup(cua_so, giu)
assert len(anh) > 500, f"chỉ quét được {len(anh)} chỗ chữ — phép đo đang bị cắt"
def test_binding_tu_don_khi_widget_bi_xoa(qapp):
"""Ràng buộc không được giữ widget sống, và không được ném khi widget chết.
Đây là điều kiện để `bind_*` dùng được cho widget dựng lại liên tục (hàng
Kanban, ô lịch) mà không tích thành rác.
"""
from PySide6.QtWidgets import QLabel
from cowork_local import i18n as i18n_mod
lbl = QLabel()
i18n_mod.bind_text(lbl, "app.settings")
truoc = len(i18n_mod._bindings)
assert truoc > 0
lbl.deleteLater()
del lbl
_tieu_hoa(qapp)
i18n_mod._apply_bindings() # không được ném
assert len(i18n_mod._bindings) < truoc, "ràng buộc của widget đã xoá vẫn còn"
@@ -0,0 +1,389 @@
"""Đổi ngôn ngữ phải có lớp phủ "đang xử lý" — và chỉ khi nó đáng có.
``set_language()`` chạy ĐỒNG BỘ trên GUI thread: nó gọi lần lượt mọi callback
đã đăng ký qua ``on_language_changed``. Trong lúc đó không có vòng lặp sự kiện,
nên không ``QTimer`` nào bắn được và không thanh tiến trình nào quay được — lớp
phủ phải được ``repaint()`` NGAY, không phải ``update()``.
Cùng lý do đó, lớp phủ không thể "đợi xem có chậm không rồi mới hiện": khi việc
chặn đã bắt đầu thì không còn ai chạy để bật nó lên. Thứ duy nhất đo được là
LẦN ĐỔI TRƯỚC. Vì vậy chính sách là: lần đầu trong phiên thì luôn hiện (thận
trọng), các lần sau chỉ hiện khi lần trước đã đủ chậm để người ta kịp nhận ra.
Máy nhanh vì thế nháy đúng một lần rồi thôi, thay vì nháy mãi mãi.
"""
from __future__ import annotations
from pathlib import Path
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để dựng widget thật")
from cowork_local.presentation.shell import top_bar as top_bar_mod
class _Ctx:
"""Ngữ cảnh tối thiểu mà ``_switch_language``/``_open_settings`` cần."""
def __init__(self, language: str = "vi") -> None:
self.config = type("C", (), {})()
self.config.language = language
self.config.active_provider = "p"
self.config.theme = "dark"
self.config.data = {}
self.so_lan_luu = 0
def save(self) -> None:
self.so_lan_luu += 1
class _Any:
"""Nuốt mọi lời gọi.
``_open_settings`` còn làm mới sidebar/cowork/workspace sau khi đổi ngôn
ngữ; không thứ nào trong số đó là thứ đang được kiểm ở đây.
"""
def __getattr__(self, name):
return _Any()
def __call__(self, *a, **k):
return _Any()
def _dung(qapp):
"""Một ``QMainWindow`` thật có mang ``TopBarMixin`` — đúng ``self`` mà mixin
nhận trong sản phẩm, nên lớp phủ làm con của cửa sổ chính y như ``Toast``."""
from PySide6.QtWidgets import QComboBox, QMainWindow
class W(top_bar_mod.TopBarMixin, QMainWindow):
_THEME_ICONS = {"system": "monitor", "dark": "moon", "light": "sun"}
w = W()
w.resize(900, 700)
w.ctx = _Ctx()
combo = QComboBox()
for code in ("en", "ja", "vi"):
combo.addItem(code.upper(), code)
combo.setCurrentIndex(combo.findData("vi"))
w.language_combo = combo
pcombo = QComboBox()
pcombo.addItem("P", "p")
w.provider_combo = pcombo
return w
def _dong_ho(*moc):
"""Đồng hồ giả trả lần lượt các mốc thời gian (giây) cho trước."""
it = iter(moc)
return lambda: next(it)
@pytest.fixture
def dat_ngon_ngu():
"""Đặt ngôn ngữ hiện tại mà KHÔNG phát tán cho listener.
``set_language()`` gọi mọi callback đã đăng ký — trong một phiên pytest đầy
đủ, danh sách đó chứa widget của các test khác. Ở đây chỉ cần biết ``tr()``
đang trả về ngôn ngữ nào.
"""
from cowork_local import i18n as i18n_mod
cu = i18n_mod._current
def dat(code: str) -> None:
i18n_mod._current = code
yield dat
i18n_mod._current = cu
# ---- lớp phủ bật TRƯỚC khi việc chặn bắt đầu -----------------------------
def test_overlay_hien_truoc_khi_set_language_chan(qapp, monkeypatch, dat_ngon_ngu):
"""Đi qua đúng đường người dùng bấm: combo ngôn ngữ ở chân nav rail.
``isHidden()`` chứ không ``isVisible()``: cửa sổ cha chưa ``show()`` thì
``isVisible()`` của widget con luôn False, đúng idiom mà
``test_graphrag_busy_panel.py:41`` đã dùng.
"""
dat_ngon_ngu("vi")
w = _dung(qapp)
ghi = {}
def probe(lang):
ghi["hien"] = not w._lang_busy.isHidden()
monkeypatch.setattr(top_bar_mod, "set_language", probe)
try:
w.language_combo.setCurrentIndex(w.language_combo.findData("en"))
w._on_language_changed(0)
assert ghi["hien"] is True, (
"lớp phủ bật sau khi GUI thread đã bị chặn thì người dùng không thấy gì")
finally:
w.deleteLater()
def test_nguoi_dung_that_su_nhin_thay_lop_phu(qapp, monkeypatch, dat_ngon_ngu):
"""Cửa sổ đã hiện thì ``isVisible()`` mới có nghĩa — chốt luôn mức đó."""
dat_ngon_ngu("vi")
w = _dung(qapp)
ghi = {}
def probe(lang):
ghi["hien"] = w._lang_busy.isVisible()
monkeypatch.setattr(top_bar_mod, "set_language", probe)
w.show()
try:
w._switch_language("en")
assert ghi["hien"] is True
finally:
w.hide()
w.deleteLater()
def test_overlay_tat_sau_khi_doi_xong(qapp, monkeypatch, dat_ngon_ngu):
dat_ngon_ngu("vi")
w = _dung(qapp)
monkeypatch.setattr(top_bar_mod, "set_language", lambda lang: None)
try:
w._switch_language("en")
assert w._lang_busy.isHidden() is True
finally:
w.deleteLater()
def test_callback_nem_loi_van_tat_overlay(qapp, monkeypatch, dat_ngon_ngu):
"""``i18n/__init__.py:68`` chỉ nuốt ``RuntimeError``. Một listener ném loại
khác sẽ xuyên qua ``set_language()`` — không có ``try/finally`` thì lớp phủ
treo lại trên màn hình vĩnh viễn."""
dat_ngon_ngu("vi")
w = _dung(qapp)
def probe(lang):
raise ValueError("một listener nào đó hỏng")
monkeypatch.setattr(top_bar_mod, "set_language", probe)
try:
with pytest.raises(ValueError):
w._switch_language("en")
assert w._lang_busy.isHidden() is True, "lớp phủ treo lại mãi mãi"
assert w.language_combo.isEnabled() is True, "combo khoá lại mãi mãi"
finally:
w.deleteLater()
# ---- chữ hiện bằng NGÔN NGỮ CŨ -------------------------------------------
def _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu: str, sang: str) -> str:
"""Chữ trên lớp phủ tại thời điểm việc chặn bắt đầu.
Probe đổi ``_current`` thật, y như ``set_language()`` làm, nên nếu ``tr()``
bị gọi SAU lượt đổi thì chữ sẽ ra ngôn ngữ mới và test đỏ.
"""
from cowork_local import i18n as i18n_mod
dat_ngon_ngu(tu)
w = _dung(qapp)
ghi = {}
def probe(lang):
i18n_mod._current = lang
ghi["chu"] = w._lang_busy.text()
monkeypatch.setattr(top_bar_mod, "set_language", probe)
try:
w._switch_language(sang)
return ghi["chu"]
finally:
w.deleteLater()
def test_chu_hien_bang_ngon_ngu_cu(qapp, monkeypatch, dat_ngon_ngu):
from cowork_local.i18n import STRINGS
chu = _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu="vi", sang="en")
assert chu == STRINGS["app.lang.switching"]["vi"]
def test_chu_hien_bang_ngon_ngu_cu_chieu_nguoc(qapp, monkeypatch, dat_ngon_ngu):
from cowork_local.i18n import STRINGS
chu = _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu="en", sang="vi")
assert chu == STRINGS["app.lang.switching"]["en"]
def test_i18n_du_ba_ngon_ngu():
from cowork_local.i18n import STRINGS
assert set(STRINGS["app.lang.switching"]) >= {"en", "ja", "vi"}
# ---- hình dạng: phủ kín cửa sổ, khoá combo -------------------------------
def test_overlay_phu_kin_cua_so(qapp, monkeypatch, dat_ngon_ngu):
"""Phủ nửa vời thì người dùng vẫn bấm được vào phần còn lại."""
dat_ngon_ngu("vi")
w = _dung(qapp)
monkeypatch.setattr(top_bar_mod, "set_language", lambda lang: None)
try:
w._switch_language("en")
assert w._lang_busy.geometry() == w.rect()
finally:
w.deleteLater()
def test_combo_bi_khoa_trong_luc_chan(qapp, monkeypatch, dat_ngon_ngu):
dat_ngon_ngu("vi")
w = _dung(qapp)
ghi = {}
def probe(lang):
ghi["khoa"] = w.language_combo.isEnabled()
monkeypatch.setattr(top_bar_mod, "set_language", probe)
try:
w._switch_language("en")
assert ghi["khoa"] is False
assert w.language_combo.isEnabled() is True, "phải mở khoá lại sau khi xong"
finally:
w.deleteLater()
# ---- chính sách: chỉ nháy một lần trên máy nhanh -------------------------
def test_lan_dau_trong_phien_luon_hien(qapp, monkeypatch, dat_ngon_ngu):
"""Chưa đo được gì thì nghiêng về phía hiện: một cái nháy còn hơn một lần
đơ vài giây không lời giải thích."""
from cowork_local.presentation.shell import busy_overlay as busy_mod
dat_ngon_ngu("vi")
w = _dung(qapp)
ghi = {}
monkeypatch.setattr(top_bar_mod, "set_language",
lambda lang: ghi.setdefault("hien", not w._lang_busy.isHidden()))
try:
assert w._lang_busy_overlay()._last_ms is None
w._switch_language("en")
assert ghi["hien"] is True
assert busy_mod._NOTICEABLE_MS > 0
finally:
w.deleteLater()
def test_lan_truoc_nhanh_thi_khong_hien_nua(qapp, monkeypatch, dat_ngon_ngu):
"""20 ms thì không ai kịp thấy mình đang đợi — hiện lớp phủ ở đó là tự tạo
ra một cái nháy toàn màn hình."""
from cowork_local.presentation.shell import busy_overlay as busy_mod
dat_ngon_ngu("vi")
w = _dung(qapp)
monkeypatch.setattr(busy_mod, "perf_counter", _dong_ho(0.0, 0.02, 5.0, 5.02))
ghi = []
monkeypatch.setattr(top_bar_mod, "set_language",
lambda lang: ghi.append(not w._lang_busy.isHidden()))
try:
w._switch_language("en")
w._switch_language("ja")
assert ghi == [True, False]
finally:
w.deleteLater()
def test_lan_truoc_cham_thi_van_hien(qapp, monkeypatch, dat_ngon_ngu):
"""Máy chậm / thư viện skill lớn: lần trước mất 1 giây thì lần này phải có
lớp phủ, không đợi thêm lần nào nữa."""
from cowork_local.presentation.shell import busy_overlay as busy_mod
dat_ngon_ngu("vi")
w = _dung(qapp)
monkeypatch.setattr(busy_mod, "perf_counter", _dong_ho(0.0, 1.0, 5.0, 6.0))
ghi = []
monkeypatch.setattr(top_bar_mod, "set_language",
lambda lang: ghi.append(not w._lang_busy.isHidden()))
try:
w._switch_language("en")
w._switch_language("ja")
assert ghi == [True, True]
finally:
w.deleteLater()
# ---- đường Cài đặt ▸ Chung -----------------------------------------------
def _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu: str):
"""Chạy ``_open_settings`` với hộp thoại bị thay, trả về (window, ghi)."""
w = _dung(qapp)
w._apply_theme = lambda: None
w.theme_btn = _Any()
w.cowork = _Any()
w.workspace = _Any()
w.sidebar = _Any()
ghi = {"goi": 0}
class _Dlg:
def __init__(self, ctx, parent):
self._ctx = ctx
def exec(self):
self._ctx.config.language = ngon_ngu_sau_khi_luu
return True
def probe(lang):
ghi["goi"] += 1
ghi["hien"] = not w._lang_busy.isHidden()
monkeypatch.setattr(top_bar_mod, "SettingsDialog", _Dlg)
monkeypatch.setattr(top_bar_mod, "set_language", probe)
return w, ghi
def test_duong_cai_dat_cung_co_overlay(qapp, monkeypatch, dat_ngon_ngu):
"""Cài đặt ▸ Chung đổi ngôn ngữ thì cũng chặn GUI thread y hệt combo."""
dat_ngon_ngu("vi")
w, ghi = _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu="en")
try:
w._open_settings()
assert ghi["goi"] == 1
assert ghi["hien"] is True
finally:
w.deleteLater()
def test_duong_cai_dat_khong_nhay_khi_khong_doi_ngon_ngu(qapp, monkeypatch, dat_ngon_ngu):
"""Bấm Lưu mà không đụng tới ngôn ngữ: ``set_language()`` là no-op
(``i18n/__init__.py:62``), nên bật lớp phủ ở đây chỉ là một cái nháy."""
dat_ngon_ngu("vi")
w, ghi = _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu="vi")
try:
w._open_settings()
assert ghi["goi"] == 0, "gọi set_language() cho một lượt đổi không tồn tại"
assert getattr(w, "_lang_busy", None) is None
finally:
w.deleteLater()
# ---- màu lấy từ theme, không hardcode ------------------------------------
def test_mau_lay_tu_theme():
"""Guardrail G4: ngoài ``theme/`` không file nào được đặt tên một màu."""
from cowork_local.theme.qss import _TEMPLATE
assert "QWidget#busyOverlayPanel" in _TEMPLATE.template
src = (Path(__file__).resolve().parents[2]
/ "presentation" / "shell" / "busy_overlay.py").read_text(encoding="utf-8")
assert "setStyleSheet" not in src
+212
View File
@@ -0,0 +1,212 @@
"""Regression: Định tuyến / Tự chạy phải đổi chữ theo ngôn ngữ runtime (UI-20260907-01).
Người dùng dựng app ở một ngôn ngữ rồi đổi sang ngôn ngữ khác: chữ trên công tắc
"Định tuyến" và ô "Tự chạy" ở tab Cowork vẫn đóng băng ở ngôn ngữ lúc dựng widget.
Vì vậy MỌI test ở đây phải theo khuôn **dựng ở X → đổi sang Y → kiểm**: kiểm ngay
lúc vừa dựng là vô nghĩa (widget luôn đúng lúc dựng, kể cả khi bản vá sai).
"""
from __future__ import annotations
import pytest
from cowork_local import i18n
from cowork_local.ui.routing_toggle import AutoRunToggle, RoutingToggle
class FakeCtx:
"""AppContext tối thiểu; ghi lại MỌI lần ghi để test chứng minh dịch lại không ghi đĩa."""
def __init__(self) -> None:
"""Khởi tạo với sổ ghi rỗng."""
self.writes: list = []
def project_routing_mode(self, surface: str) -> str:
"""Chế độ định tuyến của bề mặt đang mở — cố định "manual" cho test."""
return "manual"
def set_project_routing_mode(self, surface: str, mode: str) -> None:
"""Ghi lại lần ghi chế độ định tuyến thay vì chạm đĩa."""
self.writes.append((surface, mode))
def project_auto_run(self) -> bool:
"""Trạng thái tự chạy của project — cố định True cho test."""
return True
def set_project_auto_run(self, value: bool) -> None:
"""Ghi lại lần ghi cờ tự chạy thay vì chạm đĩa."""
self.writes.append(("auto_run", value))
@pytest.fixture
def i18n_sach():
"""Trả ngôn ngữ VÀ danh sách listener về nguyên trạng.
``i18n._listeners`` chỉ có đường vào (i18n/__init__.py:100); widget do test
dựng sẽ nằm lại đó và bị gọi ở mọi test sau. Khôi phục để test hermetic.
"""
lang, listeners = i18n.get_language(), list(i18n._listeners)
yield
i18n._listeners[:] = listeners
i18n.set_language(lang)
def _chu_dinh_tuyen_phai_la(toggle: RoutingToggle, lang: str) -> None:
"""Mọi chữ trên công tắc định tuyến phải khớp bản dịch của ``lang``.
So với ``i18n.STRINGS`` chứ không hard-code chuỗi: đổi từ ngữ sau này không
được làm test đỏ oan.
"""
assert toggle._label.text() == i18n.STRINGS["routing.toggle_label"][lang]
assert toggle._combo.toolTip() == i18n.STRINGS["routing.toggle_tooltip"][lang]
for i, (_value, key) in enumerate(toggle._modes):
assert toggle._combo.itemText(i) == i18n.STRINGS[key][lang], (
f"item {i} sai ngôn ngữ"
)
def _on_dinh_layout(app, lap: int = 3) -> None:
"""Ép Qt xử lý hết layout request đang xếp hàng để bề rộng widget ổn định."""
from PySide6.QtCore import QEvent
for _ in range(lap):
app.sendPostedEvents(None, QEvent.LayoutRequest)
app.processEvents()
def _be_rong_o_chu(combo) -> int:
"""Bề rộng vùng hiển thị chữ của combo (đã trừ khung và mũi tên)."""
from PySide6.QtWidgets import QStyle, QStyleOptionComboBox
opt = QStyleOptionComboBox()
opt.initFrom(combo)
opt.currentText = combo.currentText()
opt.editable = combo.isEditable()
opt.frame = combo.hasFrame()
opt.subControls = QStyle.SC_All
return combo.style().subControlRect(
QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxEditField, combo
).width()
def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_ja_roi_doi_sang_vi(qapp, i18n_sach):
"""Dựng ở tiếng Nhật, đổi sang tiếng Việt → nhãn/tooltip/4 chế độ phải là tiếng Việt."""
i18n.set_language("ja")
toggle = RoutingToggle(FakeCtx(), "cowork")
i18n.set_language("vi")
_chu_dinh_tuyen_phai_la(toggle, "vi")
def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_ja_roi_doi_sang_en(qapp, i18n_sach):
"""Dựng ở tiếng Nhật, đổi sang tiếng Anh → toàn bộ chữ phải là tiếng Anh."""
i18n.set_language("ja")
toggle = RoutingToggle(FakeCtx(), "cowork")
i18n.set_language("en")
_chu_dinh_tuyen_phai_la(toggle, "en")
def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_vi_roi_doi_sang_ja(qapp, i18n_sach):
"""Chiều ngược lại: dựng ở tiếng Việt, đổi sang tiếng Nhật."""
i18n.set_language("vi")
toggle = RoutingToggle(FakeCtx(), "cowork")
i18n.set_language("ja")
_chu_dinh_tuyen_phai_la(toggle, "ja")
def test_doi_ngon_ngu_khong_lam_doi_che_do_dinh_tuyen(qapp, i18n_sach):
"""Test KHOÁ: dịch lại chỉ được đổi CHỮ, không đổi chế độ và không ghi đĩa.
Cột ``data`` ("off"/"auto"/"manual"/"fallback") được persist xuống đĩa; nếu
ai đó dịch luôn cột đó, hoặc đổi ``retranslate()`` sang ``clear()+addItem()``,
thì mode routing của mọi workspace bị âm thầm reset về "off".
"""
i18n.set_language("ja")
ctx = FakeCtx()
toggle = RoutingToggle(ctx, "cowork")
for lang in ("vi", "en", "ja"):
i18n.set_language(lang)
assert toggle.current_mode() == "manual"
assert toggle._combo.currentData() == "manual"
assert ctx.writes == [], "đổi ngôn ngữ không được ghi lại chế độ định tuyến"
def test_tu_chay_dung_ngon_ngu_sau_khi_doi_ngon_ngu(qapp, i18n_sach):
"""Ô "Tự chạy" cũng phải đổi chữ, và không được tự đổi trạng thái tick."""
i18n.set_language("ja")
ctx = FakeCtx()
toggle = AutoRunToggle(ctx)
i18n.set_language("vi")
assert toggle._chk.text() == i18n.STRINGS["routing.autorun_label"]["vi"]
assert toggle._chk.toolTip() == i18n.STRINGS["routing.autorun_tooltip"]["vi"]
i18n.set_language("en")
assert toggle._chk.text() == i18n.STRINGS["routing.autorun_label"]["en"]
assert toggle._chk.toolTip() == i18n.STRINGS["routing.autorun_tooltip"]["en"]
assert toggle._chk.isChecked() is True
assert ctx.writes == [], "đổi ngôn ngữ không được ghi lại cờ tự chạy"
def test_bon_che_do_khong_bi_cat_chu_sau_khi_doi_ngon_ngu(qapp, i18n_sach):
"""Dịch lại xong thì combo phải rộng ra theo chữ mới, không nuốt đuôi.
Combo dựng ở tiếng Nhật rồi đổi sang tiếng Việt: tên chế độ tiếng Việt dài
hơn tên tiếng Nhật, nên nếu bề rộng còn bị khoá theo lần hiện đầu tiên thì
"Thủ công"/"Dự phòng" (96px) không lọt ô chữ 89px và bị cắt đuôi.
Hai lớp assert, mỗi lớp bắt một nửa bản vá:
* chữ phải là tiếng Việt — bắt việc quên đăng ký ``on_language_changed``;
* chữ phải lọt ô — bắt việc quên ``setSizeAdjustPolicy``, vì combo chỉ đo
lại bề rộng khi chính sách là ``AdjustToContents``.
``host`` được nới rộng có chủ ý: ``addStretch`` phải còn chỗ trống để nhả ra
cho combo, nếu không thì cả combo đúng lẫn combo sai đều kẹt ở bề rộng cũ và
test không phân biệt được hai trạng thái.
"""
from PySide6.QtGui import QFontMetrics
from PySide6.QtWidgets import QHBoxLayout, QWidget
i18n.set_language("ja")
host = QWidget()
lay = QHBoxLayout(host)
toggle = RoutingToggle(FakeCtx(), "cowork")
lay.addWidget(toggle)
lay.addStretch(1)
host.resize(600, 60)
host.show()
_on_dinh_layout(qapp)
i18n.set_language("vi")
_on_dinh_layout(qapp)
combo = toggle._combo
fm = QFontMetrics(combo.font())
o_chu = _be_rong_o_chu(combo)
chu_hien = [combo.itemText(i) for i in range(combo.count())]
bi_cat = [t for t in chu_hien if fm.horizontalAdvance(t) > o_chu]
host.close()
assert chu_hien == [i18n.STRINGS[key]["vi"] for _value, key in toggle._modes]
assert not bi_cat, f"bị cắt chữ: {bi_cat} (ô chữ rộng {o_chu}px)"
@pytest.mark.parametrize("key", [
"routing.toggle_label", "routing.toggle_tooltip",
"routing.autorun_label", "routing.autorun_tooltip",
"routing.mode_off", "routing.mode_auto",
"routing.mode_manual", "routing.mode_fallback",
])
def test_key_routing_co_du_ba_ngon_ngu(key):
"""Test KHOÁ: thiếu một bản dịch thì ``tr()`` rơi về tiếng Anh, lỗi lại tái diễn."""
for lang in ("en", "ja", "vi"):
assert i18n.STRINGS[key].get(lang), f"{key} thiếu {lang}"
+8 -4
View File
@@ -183,10 +183,14 @@ QPushButton#navSettingsBtn {
/* Padding stays at 0: the row lays its own icon and label out, so that
the spacing does not change with the platform's button style. */
padding: 0; text-align: left; border-radius: ${radius}px;
/* No side margin: Settings reads as one more row under Dashboard/Giám sát,
so its icon has to start on their x. A 6px margin put it at 14 — near
enough the middle of the collapsed 54px rail to look centred. */
margin: 2px 0px 6px 0px;
/* No margin at all. A vertical one here was doing the opposite of what it
read as: QVBoxLayout gave this button its geometry with the margin
ignored (nav_bottom's bottom edge and this button's top edge measured
the same y), while the PAINTER honoured it — so the only thing 10px/14px
actually did was inset the hover fill, leaving Settings with a visibly
shorter hover pill than the rows above. Spacing above/below the row is
nav_rail's, which pins this button to the rows' own height. */
margin: 0;
}
QPushButton#navSettingsBtn:hover { background: $nav_hover; color: $text; }
QPushButton#navSettingsBtn:pressed { background: $active; }
+11
View File
@@ -324,6 +324,17 @@ QLabel#welcomeCardTitle { color: $text; font-weight: 600; }
nen dac cua rieng minh thay vi chi la mot dong chu. */
QWidget#graphBusy { background: $overlay; border: 1px solid $border_strong; border-radius: ${radius}px; }
QWidget#graphBusy QLabel { color: $text; font-weight: 600; }
/* Lop phu ca cua so trong luc doi ngon ngu — cung ly do voi #graphBusy o tren:
viec chan chay DONG BO tren GUI thread. Nen mo chu khong dac: nguoi dung con
thay man hinh cu mo di, nen hieu la app dang lam viec chu khong phai da nhay
sang mot man hinh khac. */
QWidget#busyOverlay { background: rgba(0, 0, 0, 0.45); }
QWidget#busyOverlayPanel {
background: $overlay; border: 1px solid $border_strong;
border-radius: ${radius}px;
}
QWidget#busyOverlayPanel QLabel { color: $text; font-weight: 600; }
QLabel#faint { color: $text_faint; }
QLabel#warning { color: $warning; font-weight: 600; }
QLabel#error { color: $danger; font-weight: 600; }
+27 -13
View File
@@ -390,21 +390,35 @@ class Co4ETab(
# ---- i18n -------------------------------------------------------------
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề các mục và tooltip."""
for key in self._sections:
self._sync_section_arrow(key)
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
self.wf_new_btn.setText(tr("co4e.new"))
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề các mục và tooltip.
Cột trái và trang Flow Status không có mặt ở đây: mỗi widget bên đó tự
ràng buộc khoá dịch của mình tại chỗ dựng (``i18n.bind_*``), nên panel
dùng ở đâu cũng đúng ngôn ngữ mà không cần ai nhớ hộ.
"""
self.runs_btn.setText(tr("co4e.runs_tab"))
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
self.runs_back_btn.setText(tr("co4e.back_to_flow"))
self.runs_back_btn.setToolTip(tr("co4e.tt_back_to_flow"))
self.runs_title.setText(tr("co4e.running_flows"))
self.run_stop_btn.setText(tr("co4e.stop"))
self.run_rename_btn.setText(tr("co4e.rename_run"))
self.run_del_btn.setText(tr("co4e.delete_run"))
self.run_clear_btn.setText(tr("co4e.clear_done"))
# Flow toolbar.
self.name_edit.setToolTip(tr("co4e.tt_flow_name"))
self.add_step_btn.setText(tr("co4e.add"))
self.add_step_btn.setToolTip(tr("co4e.tt_add_step"))
self.save_btn.setText(tr("co4e.save"))
self.save_btn.setToolTip(tr("co4e.tt_save"))
self.save_tpl_btn.setToolTip(tr("co4e.tt_save_template"))
self.mode_combo.setToolTip(tr("co4e.tt_mode"))
# By position, from the same source the items were built from: the mode
# string in each item's data is persisted, so it must survive a
# translation untouched.
for i, mode in enumerate(co4e.RUN_MODES):
self.mode_combo.setItemText(i, tr(f"co4e.mode.{mode}"))
self.run_btn.setToolTip(tr("co4e.tt_run"))
# Not a plain tr(): this button reads "Dừng" while THIS flow is running.
self._update_run_btn()
# Step-config panel header. The toggle's tooltip names the action it
# would perform, which depends on which way the panel is folded.
self.config_title.setText(tr("co4e.config_title"))
self.config_toggle_btn.setToolTip(tr(
"co4e.tt_expand_config" if self._config_collapsed else "co4e.tt_collapse_config"))
self._refresh_ws_folder_btn()
self.runs_table.setHorizontalHeaderLabels([
tr("co4e.runs_col_flow"), tr("co4e.runs_col_status"), tr("co4e.runs_col_steps"),
+2 -2
View File
@@ -19,7 +19,7 @@ from PySide6.QtWidgets import (
from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..i18n import bind_text, on_language_changed, tr
from ..state import AppContext
from .ext_connector_dialog import ExtConnectorEditDialog
from .icons import icon
@@ -160,7 +160,7 @@ class ConnectorsPanel(QWidget):
self.connect_external_sw.toggled.connect(self._on_connect_external_toggled)
lay.addWidget(self.connect_external_sw)
hint = QLabel(tr("settings.ext_hint"))
hint = bind_text(QLabel(), "settings.ext_hint")
hint.setObjectName("hint")
hint.setWordWrap(True)
lay.addWidget(hint)
+4
View File
@@ -99,6 +99,10 @@ class CoworkTab(ChatPanel):
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và các nút trên thanh công cụ."""
# ChatPanel dựng phần chrome dùng chung (nhãn Agent, nút Nén, tiêu đề ba
# bảng tệp) nhưng KHÔNG tự đăng ký dịch lại — lớp con là chỗ duy nhất có
# đăng ký, nên bỏ dòng này là toàn bộ phần đó đứng ở ngôn ngữ lúc dựng.
self._retranslate_base()
self.refresh_title()
self.skills_btn.setText(tr("cowork.skills_btn"))
self.skills_btn.setToolTip(tr("cowork.skills_tooltip"))
+12 -1
View File
@@ -23,7 +23,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from ..i18n import tr
from ..i18n import on_language_changed, tr
class RoutingToggle(QWidget):
@@ -71,6 +71,11 @@ class RoutingToggle(QWidget):
self._label.setObjectName("hint")
self._combo = QComboBox()
self._combo.setToolTip(tr("routing.toggle_tooltip"))
# Mode names differ in length per language ("Thủ công" is wider than
# "手動"), and a combo only re-measures itself under this policy — without
# it the width stays frozen at the language the widget was built in and
# the longer translation is cut off.
self._combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
# (data value, i18n key) — data is the persisted mode string. Order is
# least-to-most autonomous, with Fallback (R03-T03) last because it is
# the "only when something breaks" mode rather than a stronger Auto.
@@ -88,6 +93,11 @@ class RoutingToggle(QWidget):
self._combo.currentIndexChanged.connect(self._on_changed)
lay.addWidget(self._label)
lay.addWidget(self._combo)
# Registered HERE, not by the four surfaces that embed this widget
# (Cowork, Co4E, AI-Edit): every one of them had forgotten to, so the
# control stayed frozen in the language it was built in. Owning it here
# means no future embedder can forget either.
on_language_changed(self.retranslate)
def current_mode(self) -> str:
"""Chế độ định tuyến đang chọn; 'off' nếu chưa đặt."""
@@ -149,6 +159,7 @@ class AutoRunToggle(QWidget):
self.refresh()
self._chk.toggled.connect(self._on_toggled)
lay.addWidget(self._chk)
on_language_changed(self.retranslate) # same reason as RoutingToggle
def refresh(self) -> None:
"""Đọc lại trạng thái tự chạy của project đang mở lên ô đánh dấu."""