diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..207c701 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,29 @@ +# Normalise line endings so a Windows checkout and a Linux CI runner see the +# same bytes. Without this, committing from Windows records CRLF and every file +# shows as fully rewritten to anyone (or any CI job) on Linux. +* text=auto eol=lf + +# Windows-only scripts must keep CRLF or cmd.exe mis-parses them. +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Binary: never touch, never try to diff as text. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.pptx binary +*.xlsx binary +*.docx binary +*.7z binary +*.zip binary +*.ttf binary +*.woff binary +*.woff2 binary + +# The audit page is a single 8 MB file with base64 images inlined — a textual +# diff of it is noise, and merging it by hand is never the right move. +docs/ui-audit.html -diff -merge diff --git a/.gitignore b/.gitignore index 578f3f7..182f2ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,39 +1,122 @@ -# Python bytecode and test/tool caches +# ============================================================================= +# Dependencies +# ============================================================================= +node_modules/ +.pnpm-store/ __pycache__/ -*.py[cod] -*$py.class -.pytest_cache/ -.ruff_cache/ -.mypy_cache/ -.coverage -htmlcov/ - -# Local environments and packaging output +*.pyc +*.pyo +*.pyd .venv/ venv/ env/ -build/ -dist/ +.env.venv/ +pip-wheel-metadata/ *.egg-info/ +*.egg +.eggs/ +bower_components/ -# Local configuration, credentials, and runtime data +# ============================================================================= +# Environment & Secrets +# ============================================================================= .env -.env.* -!.env.example -.cowork_local/ -ms365_token_cache.bin -*.log -*.sqlite -*.sqlite3 -*.db +.env.local +.env.*.local +.env.production +.env.development +.env.preview *.pem *.key -*.p12 -*.pfx +secrets/ +credentials.json +.npmrc +.yarnrc -# Editors and operating systems -.DS_Store +# ============================================================================= +# Build & Distribution +# ============================================================================= +dist/ +build/ +out/ +.next/ +.nuxt/ +.output/ + +# ============================================================================= +# IDE & Editor +# ============================================================================= .idea/ .vscode/ *.swp +*.swo *~ +.project +.classpath +.settings/ +*.sublime-project +*.sublime-workspace + +# ============================================================================= +# OS Files +# ============================================================================= +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +desktop.ini + +# ============================================================================= +# Logs & Debug +# ============================================================================= +*.log +logs/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# ============================================================================= +# Testing & Coverage +# ============================================================================= +.coverage +.coverage.* +htmlcov/ +.pytest_cache/ +.tox/ +.nox/ +coverage/ +*.cover +*.py,cover +.hypothesis/ +.nyc_output/ +test-results/ +playwright-report/ + +# ============================================================================= +# AI & Agent Workspace +# ============================================================================= +vibeflow.json +.claude/ +.cursor/ +.aider/ +.continue/ +.copilot/ + +# ============================================================================= +# Temporary & Cache +# ============================================================================= +*.tmp +*.temp +.cache/ +.parcel-cache/ +.turbo/ +*.tsbuildinfo + +# Runtime data the app writes next to itself when it is run from the repo. +# A chat transcript got committed and pushed this way. +.cowork_history/ +.cowork_local/ diff --git a/.vibeflow-preview/entrypoint.sh b/.vibeflow-preview/entrypoint.sh new file mode 100644 index 0000000..e18260d --- /dev/null +++ b/.vibeflow-preview/entrypoint.sh @@ -0,0 +1,99 @@ +#!/bin/sh +# Boot the Cowork-Local PySide6 desktop app on Qt's built-in VNC server, then +# expose the live GUI to the browser through noVNC + websockify on :6080. +# +# This entrypoint is intended to be run from a `python:3.11-slim-bookworm` +# container via podman-compose. All setup (Qt runtime libs, noVNC, PySide6 +# wheels) happens here so we don't need a custom image / Dockerfile. +# Idempotent: reruns are fast (apt reuses debs, pip uses the named cache vol). + +set -e + +log() { printf '[entrypoint] %s\n' "$*"; } + +# --------------------------------------------------------------------------- +# 1. System runtime libraries: Qt6 needs EGL/GL/XCB; noVNC needs websockify. +# --------------------------------------------------------------------------- +if [ ! -f /var/cache/cowork_setup.stamp ]; then + log "installing apt packages (first run only)…" + apt-get update -qq + apt-get install -y --no-install-recommends \ + libegl1 libgl1 libglib2.0-0 libdbus-1-3 libfontconfig1 \ + libxkbcommon0 libxkbcommon-x11-0 libxcb-cursor0 \ + libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 \ + libxcb-render-util0 libxcb-shape0 libxcb-sync1 \ + libxcb-xfixes0 libxcb-xinerama0 libxcb-xkb1 libxcb-util1 \ + libxcomposite1 libxdamage1 libxrandr2 libxss1 libxtst6 \ + libxi6 libxrender1 libfreetype6 \ + fonts-dejavu fonts-noto-cjk \ + netcat-openbsd ca-certificates >/dev/null + rm -rf /var/lib/apt/lists/* + touch /var/cache/cowork_setup.stamp + log "apt setup done." +else + log "apt setup already done (skipping)." +fi + +# --------------------------------------------------------------------------- +# 2. Python dependencies (cached in the named `pip-cache` volume). +# `websockify` is shipped as a pip wheel and bundles the noVNC web +# assets under its package `web/` directory, so we don't need the +# (unavailable on slim-bookworm) apt `novnc` / `websockify` packages. +# --------------------------------------------------------------------------- +log "ensuring python deps…" +pip install --no-cache-dir --quiet \ + "PySide6>=6.6" "pydantic>=2" requests psutil websockify numpy +log "python deps OK." + +# --------------------------------------------------------------------------- +# 3. Make the workspace importable as `cowork_local` (the package name that +# `python -m cowork_local` and the test suite expect). Compose mounts the +# live repo at /workspace; we add a thin symlink at /opt/cowork_local. +# --------------------------------------------------------------------------- +ln -sfn /workspace /opt/cowork_local +export PYTHONPATH=/opt + +# --------------------------------------------------------------------------- +# 4. Launch the app on Qt's built-in VNC platform (no Xvfb needed). +# Qt VNC listens on QT_QPA_VNC_HOST:QT_QPA_VNC_PORT (127.0.0.1:5900). +# --------------------------------------------------------------------------- +: > /var/log/app.log +log "launching app on Qt VNC platform…" +python -m cowork_local >/var/log/app.log 2>&1 & +APP_PID=$! + +# Wait until the VNC socket accepts a connection (or give up after 60s). +for i in $(seq 1 120); do + if nc -z 127.0.0.1 "${QT_QPA_VNC_PORT:-5900}" 2>/dev/null; then + log "VNC server up on 127.0.0.1:${QT_QPA_VNC_PORT:-5900} (pid ${APP_PID})." + break + fi + if ! kill -0 "${APP_PID}" 2>/dev/null; then + log "app process died before VNC was up; log tail:" + tail -n 60 /var/log/app.log >&2 || true + exit 1 + fi + sleep 0.5 +done + +if ! nc -z 127.0.0.1 "${QT_QPA_VNC_PORT:-5900}" 2>/dev/null; then + log "VNC never came up; log tail:" + tail -n 60 /var/log/app.log >&2 || true + exit 1 +fi + +# --------------------------------------------------------------------------- +# 5. Bridge browser -> VNC. websockify serves the noVNC web client on :6080 +# and proxies WebSocket connections to the raw VNC server. The noVNC web +# assets are bundled inside the websockify pip wheel. +# --------------------------------------------------------------------------- +NOVNC_WEB="$(python - <<'PY' +import os, websockify +print(os.path.join(os.path.dirname(websockify.__file__), "web")) +PY +)" +[ -f "${NOVNC_WEB}/vnc.html" ] || { log "noVNC web assets not found at ${NOVNC_WEB}, aborting."; exit 1; } + +log "noVNC web assets: ${NOVNC_WEB}" +log "starting noVNC bridge on 0.0.0.0:6080 -> 127.0.0.1:${QT_QPA_VNC_PORT:-5900}" +exec websockify --web="${NOVNC_WEB}" 0.0.0.0:6080 "127.0.0.1:${QT_QPA_VNC_PORT:-5900}" diff --git a/__main__.py b/__main__.py index 7fd094f..4c2c993 100644 --- a/__main__.py +++ b/__main__.py @@ -1,13 +1,29 @@ -"""Entry point: ``python -m cowork_local``.""" +"""Entry point: ``python -m cowork_local``. + +Also works when run directly as ``python __main__.py`` — see main(). +""" from __future__ import annotations +import os import sys def main() -> int: # Imported lazily so that ``-h`` style tooling and tests can import the # package without spinning up a full Qt application. - from .app import run + # + # `from .app import run` requires this file to be loaded as part of the + # `cowork_local` package (i.e. via `python -m cowork_local`). When run as + # a plain script (`python __main__.py`), `__package__` is empty so the + # relative import fails — in that case put the package root (the parent + # of this file's directory) on sys.path and use an absolute import. + if __package__: + from .app import run + else: + parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if parent not in sys.path: + sys.path.insert(0, parent) + from cowork_local.app import run return run(sys.argv) diff --git a/app.py b/app.py index 2ed8f89..49c974b 100644 --- a/app.py +++ b/app.py @@ -7,18 +7,21 @@ from pathlib import Path from typing import List from PySide6.QtCore import Qt, QTimer -from PySide6.QtGui import QGuiApplication, QIcon +from PySide6.QtGui import QColor, QGuiApplication, QIcon from PySide6.QtWidgets import ( + QStyledItemDelegate, QApplication, QComboBox, QHBoxLayout, QLabel, QMainWindow, QMenu, - QPushButton, QSizePolicy, QSplitter, QStackedWidget, QSystemTrayIcon, - QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, + QPushButton, QScrollArea, QSizePolicy, QSplitter, QStackedWidget, + QSystemTrayIcon, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, + QWidget, ) from . import APP_NAME, DISPLAY_NAME, __version__ from .config import PROVIDER_LABELS, AppConfig from .i18n import LANGUAGE_SHORT, LANGUAGES, get_language, on_language_changed, set_language, tr from .state import AppContext -from .theme import ACCENT, stylesheet +from .ui.widgets import tidy_popup +from .theme import current_palette, set_active_theme, stylesheet from .core.task_scheduler import TaskScheduler from .ui.cowork_tab import CoworkTab from .ui.dashboard_tab import DashboardTab @@ -35,6 +38,23 @@ ASSETS = Path(__file__).resolve().parent / "assets" # shows icon-only (still fully clickable, just narrower). _NAV_EXPANDED_WIDTH = 150 _NAV_COLLAPSED_WIDTH = 54 +# The splitter between rail and content draws a drag handle. It only means +# something if the rail can actually take a width from it, so the expanded rail +# is a range rather than one number; long project and thread names in RECENTS +# are the reason someone would widen it. +# +# The ceiling is a SHARE of the window, not a pixel count: 360px is a quarter +# of a 1440 screen and more than a quarter of a 1280 one, where it left the +# seven Kanban lanes 920px of the 1067 they need. A share behaves the same on +# every monitor. +# Where a rail row starts, and how much air sits between its icon and its +# label. The tree rows get these from the style; anything laid out by hand +# beside them has to use the same two numbers or it will not line up. +_NAV_ROW_INSET = 4 +_NAV_ROW_GAP = 6 +_NAV_MIN_WIDTH = 132 +_NAV_MAX_SHARE = 0.22 +_NAV_MAX_CEILING = 360 def app_icon() -> QIcon: @@ -64,10 +84,12 @@ class _Toast(QLabel): self._timer.timeout.connect(self.hide) def show_message(self, text: str, ok: bool = True, ms: int = 4500) -> None: - bg = "#1f9d63" if ok else "#e5484d" + p = current_palette() + bg = p.success_soft if ok else p.danger_soft + fg = p.success if ok else p.danger self.setStyleSheet( - f"#toast {{ background:{bg}; color:white; border-radius:12px;" - f" padding:10px 16px; font-weight:600; }}") + f"#toast {{ background:{bg}; color:{fg}; border:1px solid {fg};" + f" border-radius:{p.radius}px; padding:10px 16px; font-weight:600; }}") self.setText(text) self.adjustSize() self.move(14, 14) # top-left of the window @@ -76,6 +98,23 @@ class _Toast(QLabel): self._timer.start(ms) +class _NavItemDelegate(QStyledItemDelegate): + """Keep a rail row's icon on the left edge, whatever the column is doing. + + QStyledItemDelegate hands the style decorationAlignment = AlignHCenter, so + a row with no label — every row once the rail collapses to 54px — has its + icon centred inside whatever box the column happens to give it. That box + tracks the column width, which is not stable: stretched to the viewport the + icons land in the middle of the rail, while a column left wider than the + view leaves them at the left. Same code, two different pictures, which is + why a test render disagreed with the running app. + """ + + def initStyleOption(self, option, index): + super().initStyleOption(option, index) + option.decorationAlignment = Qt.AlignLeft | Qt.AlignVCenter + + class MainWindow(QMainWindow): # Nav rows (Dashboard/Schedule/Monitoring are lazy; Workspace is the eager home page). _ROW_DASHBOARD, _ROW_SCHEDULE, _ROW_WORKSPACE, _ROW_MONITORING = 0, 1, 2, 3 @@ -155,8 +194,6 @@ class MainWindow(QMainWindow): ("app.tab.workspace", "workspaces", None, self.workspace), ("app.tab.monitoring", "monitoring", self._build_monitoring, None), ] - # Container pages whose sub-tabs become expandable nav children. - self._nav_parents = {self._ROW_WORKSPACE, self._ROW_MONITORING} self._page_widgets = [] # page index → widget (placeholder until lazily built) self._built = [] for _key, _icon_name, _builder, widget in self._nav_defs: @@ -165,48 +202,37 @@ class MainWindow(QMainWindow): self._page_widgets.append(page) self._built.append(widget is not None) - # Left nav rail as a parent→child accordion (Claude-style): the container - # pages (Workspaces, Monitoring) expand to list their sub-views as - # children, and their in-content tab strips are hidden — so the content - # area is as large as possible. - from .ui.icons import icon as _icon - self.nav = QTreeWidget() - self.nav.setObjectName("navrail") - self.nav.setHeaderHidden(True) - self.nav.setIndentation(14) - self.nav.setRootIsDecorated(True) - self.nav.setExpandsOnDoubleClick(False) - self._nav_items = [] # page index → top-level QTreeWidgetItem - for page, (key, icon_name, _b, _w) in enumerate(self._nav_defs): - it = QTreeWidgetItem([tr(key)]) - it.setIcon(0, _icon(icon_name)) - it.setData(0, Qt.UserRole, {"page": page, "sub": None, - "parent": page in self._nav_parents, "key": key}) - # A container (Monitoring/Workspace) always shows the dropdown arrow — - # even before its page/children are lazily built — so it's obvious it - # holds multiple sub-views. It stays collapsed until first expanded. - if page in self._nav_parents: - it.setChildIndicatorPolicy(QTreeWidgetItem.ShowIndicator) - self.nav.addTopLevelItem(it) - self._nav_items.append(it) + # Left nav rail — ONE FLAT LIST, no accordion. Every screen the user + # works in is one click away: the Workspace sub-views are listed + # directly instead of hiding behind an expandable parent. The two + # occasional admin destinations sit in a second, bottom-pinned list. + # + # Monitoring is the exception that keeps its sub-views OUT of the rail: + # it has eight, which would double the rail's length for screens opened + # once a week. Its own tab strip is left visible instead (it was hidden + # while the rail carried its children), so all eight stay reachable. + self.nav = self._new_nav_tree("navrail") + self.nav_bottom = self._new_nav_tree("navrailBottom") + self._nav_building = False # guards the rebuild → select → rebuild loop self.workspace.hide_tab_bar() - self._reload_nav_children(self._ROW_WORKSPACE) # Workspace is eager - self.workspace.subtabs_changed.connect( - lambda: self._reload_nav_children(self._ROW_WORKSPACE)) - self.nav.currentItemChanged.connect(lambda cur, _prev: self._navigate(cur)) - self.nav.itemClicked.connect(self._on_nav_click) - self.nav.itemExpanded.connect(self._on_nav_expanded) # build children lazily + self._rebuild_nav() + self.workspace.subtabs_changed.connect(self._rebuild_nav) + for tree in (self.nav, self.nav_bottom): + tree.currentItemChanged.connect( + lambda cur, _prev, t=tree: self._on_nav_current(t, cur)) rlay.addWidget(self.pages, 1) # Nav rail wrapper: a small toggle button ABOVE the page list so the # whole rail can collapse to icon-only (still fully clickable). Same # collapse/expand chevron iconography as every other collapsible panel. from .ui.icons import collapse_left_icon, collapse_right_icon + from .ui.icons import icon as _icon self._collapse_left_icon = collapse_left_icon self._collapse_right_icon = collapse_right_icon self._nav_wrap = QWidget() self._nav_wrap.setObjectName("navWrap") - self._nav_wrap.setFixedWidth(_NAV_EXPANDED_WIDTH) + self._nav_width = _NAV_EXPANDED_WIDTH # remembered across collapses + self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) nvl = QVBoxLayout(self._nav_wrap) nvl.setContentsMargins(0, 0, 0, 0) nvl.setSpacing(0) @@ -228,7 +254,107 @@ class MainWindow(QMainWindow): toggle_row.addWidget(self._nav_toggle_btn, 0, Qt.AlignLeft) toggle_row.addStretch(1) nvl.addLayout(toggle_row) - nvl.addWidget(self.nav, 1) + # Primary action at the top of the rail, with the project it will land + # in named right above it. Before, starting a chat in another project + # meant leaving Cowork → Project tab → click a row → come back. + self.nav_project = QComboBox() + self.nav_project.setObjectName("navProjectPick") + self.nav_project.setToolTip(tr("app.nav.project_pick")) + self.nav_project.currentIndexChanged.connect(self._on_rail_project_pick) + tidy_popup(self.nav_project) + self.nav_new_chat = QPushButton(tr("cowork.new_chat")) + self.nav_new_chat.setObjectName("navNewChatBtn") + self.nav_new_chat.setIcon(_icon("plus")) + self.nav_new_chat.setCursor(Qt.PointingHandCursor) + self.nav_new_chat.clicked.connect(self._on_rail_new_chat) + # At 54px the picker cannot show a name, but dropping it altogether left + # the collapsed rail with no way to change project at all. This stands in + # for it: same list, same handler, just the folder icon and a tooltip. + self.nav_project_btn = QToolButton() + self.nav_project_btn.setObjectName("navProjectPickMini") + self.nav_project_btn.setIcon(_icon("folder")) + self.nav_project_btn.setCursor(Qt.PointingHandCursor) + self.nav_project_btn.setPopupMode(QToolButton.InstantPopup) + self.nav_project_btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.nav_project_btn.setMenu(QMenu(self.nav_project_btn)) + self.nav_project_btn.menu().aboutToShow.connect(self._fill_rail_project_menu) + self.nav_project_btn.setVisible(False) + head = QVBoxLayout() + head.setContentsMargins(6, 0, 6, 6) + head.setSpacing(6) + head.addWidget(self.nav_project) + head.addWidget(self.nav_project_btn) + head.addWidget(self.nav_new_chat) + nvl.addLayout(head) + self.workspace.project_selected.connect(self._sync_rail_project) + self.workspace.projects_changed.connect(self._sync_rail_project) + self._syncing_rail_project = False + self._sync_rail_project() + # The destinations and RECENTS scroll together; the bottom group, the + # Settings button and the account row stay pinned below them. + # + # Without this the rail simply ran out of room on a short window (a + # 1280×720 laptop leaves ~570px here): nav and the bottom group have + # fixed heights, so the squeeze fell entirely on RECENTS, and once that + # hit zero the layout drew the "GẦN ĐÂY" heading straight over the last + # nav row. + self._nav_scroll = QScrollArea() + self._nav_scroll.setObjectName("navScroll") + self._nav_scroll.setWidgetResizable(True) + self._nav_scroll.setFrameShape(QScrollArea.NoFrame) + self._nav_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll_body = QWidget() + sv = QVBoxLayout(scroll_body) + sv.setContentsMargins(0, 0, 0, 0) + sv.setSpacing(0) + sv.addWidget(self.nav, 0) + # RECENTS — the threads of the project named in the picker above, right + # where Claude puts them. A shortcut only: the full History panel (search, + # filters, pin, bulk delete, context menu) stays exactly where it is, and + # "all projects…" at the end of this list opens it. + self.nav_recents_hdr = QLabel(tr("app.nav.recents")) + self.nav_recents_hdr.setObjectName("navSectionHdr") + sv.addWidget(self.nav_recents_hdr) + self.nav_recents = self._new_nav_tree("navRecents") + self.nav_recents.itemClicked.connect(self._on_rail_recent) + sv.addWidget(self.nav_recents, 1) + # Collapsing hides RECENTS, and with it the only item carrying a stretch + # factor. A box layout with nothing left to expand centres what remains, + # so the destinations dropped ~300px down the rail — "thu gọn menu lại + # ra giữa". This spacer takes the slack instead, and takes none of it + # while RECENTS is visible (stretch 0 against its 1). + sv.addStretch(0) + self._nav_scroll.setWidget(scroll_body) + nvl.addWidget(self._nav_scroll, 1) + # Bottom-pinned group: the places you visit occasionally, kept out of the + # way of the ones you live in. A hairline (styled via #navrailBottom in + # theme.py) separates the two lists. + nvl.addWidget(self.nav_bottom, 0) + # Settings reads as one more row under Dashboard / Giám sát, so its icon + # and label must start exactly where theirs do. Letting QPushButton place + # them does not achieve that: the gap it leaves between icon and text is + # the platform style's, and on macOS it is visibly tighter than the tree + # rows above — a Windows-tuned nudge only moved the mismatch. So the row + # is laid out here, in the same two numbers the tree uses: 4px in, 6px + # between. + self._nav_settings_btn = QPushButton() + self._nav_settings_btn.setObjectName("navSettingsBtn") + self._nav_settings_btn.setFlat(True) + 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) + srow.setSpacing(_NAV_ROW_GAP) + self._nav_settings_icon = QLabel() + self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16)) + self._nav_settings_icon.setFixedSize(16, 16) + self._nav_settings_text = QLabel(tr("app.settings")) + srow.addWidget(self._nav_settings_icon) + srow.addWidget(self._nav_settings_text) + srow.addStretch(1) + nvl.addWidget(self._nav_settings_btn) + self._account_row = self._build_account_row() + nvl.addWidget(self._account_row) self.split = QSplitter(Qt.Horizontal) self.split.addWidget(self._nav_wrap) @@ -236,10 +362,12 @@ class MainWindow(QMainWindow): self.split.setStretchFactor(0, 0) self.split.setStretchFactor(1, 1) self.split.setSizes([_NAV_EXPANDED_WIDTH, 1000]) + self.split.splitterMoved.connect(self._on_split_moved) self.setCentralWidget(self.split) - # Workspace = landing/home (expand it and select its first sub-view). - self._nav_items[self._ROW_WORKSPACE].setExpanded(True) - self.nav.setCurrentItem(self._nav_items[self._ROW_WORKSPACE]) + # Landing stays Workspace ▸ Project, exactly as before. Go through _goto + # so the page is actually shown — selecting the row alone only moves the + # highlight (its signals are blocked to avoid rebuild loops). + self._goto(self._ROW_WORKSPACE, self.workspace.current_subtab()) self.toast = _Toast(self) # top-left "task done" popup # Floating in-app Help assistant — a robot icon pinned bottom-right on # every screen; expands into a small help-only chat (see @@ -253,8 +381,8 @@ class MainWindow(QMainWindow): # widget sits at the right end and is never cleared by showMessage (which # writes on the left). self._credit = QLabel(tr("app.credit")) - self._credit.setObjectName("hint") - self._credit.setStyleSheet("color: rgba(140,146,152,0.85); padding: 0 10px;") + self._credit.setObjectName("faint") + self._credit.setStyleSheet("padding: 0 10px;") self.statusBar().addPermanentWidget(self._credit) self._restore_sessions() self._setup_tray() @@ -273,6 +401,11 @@ class MainWindow(QMainWindow): def resizeEvent(self, event): # noqa: N802 - Qt override super().resizeEvent(event) + # The rail's ceiling is a share of the window, so it moves with the + # window. Computed once at construction it was read off a not-yet-sized + # window and stuck at 162px on every monitor. + if getattr(self, "_nav_wrap", None) is not None and not self._nav_collapsed: + self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) # Keep the floating Help assistant pinned to the bottom-right corner. if getattr(self, "help_agent", None) is not None: self.help_agent.reposition() @@ -280,8 +413,24 @@ class MainWindow(QMainWindow): def showEvent(self, event): # noqa: N802 - Qt override super().showEvent(event) if getattr(self, "help_agent", None) is not None: + self._update_dock_guard() self.help_agent.reposition() self.help_agent.raise_() + # Build GraphRAG's browser view and first graph once the window is up + # and idle, so clicking GraphRAG does not sit on an empty view while + # both happen. 3s is after the first paint and any startup refresh. + if not getattr(self, "_graph_prewarmed", False): + self._graph_prewarmed = True + QTimer.singleShot(3000, self._prewarm_graph) + + def _prewarm_graph(self) -> None: + view = getattr(self, "structure", None) + if view is None or not hasattr(view, "prewarm"): + return + try: + view.prewarm() + except Exception: # noqa: BLE001 — a warm-up must never break the app + pass # ---- i18n ---------------------------------------------------------- def _retranslate(self) -> None: @@ -368,12 +517,9 @@ class MainWindow(QMainWindow): placeholder.deleteLater() self._page_widgets[row] = real self._built[row] = True - # Container pages: hide their in-content tab strip + list their sub-views - # as children in the nav rail now that the real widget exists. - if hasattr(real, "hide_tab_bar"): - real.hide_tab_bar() - if row in self._nav_parents: - self._reload_nav_children(row) + # Monitoring KEEPS its own tab strip: its eight sub-views live in the + # page, not in the rail. Workspace is the one that hides its strip, + # because the rail lists its sub-views directly. def _page_index(self, widget) -> int: if widget is self.workspace: @@ -386,28 +532,311 @@ class MainWindow(QMainWindow): return self._ROW_MONITORING return self.pages.indexOf(widget) - # ---- nav rail collapse (icon-only) -------------------------------- + # ---- flat nav rail ------------------------------------------------- + def _new_nav_tree(self, name: str) -> QTreeWidget: + """One flat, single-column list. No indentation and no expand arrows — + every row is a destination, nothing is a container.""" + tree = QTreeWidget() + tree.setObjectName(name) + tree.setHeaderHidden(True) + tree.setIndentation(0) + tree.setRootIsDecorated(False) + tree.setUniformRowHeights(True) + # The column follows the viewport instead of the widest label. Left + # to size itself it stayed ~100px wide inside the 54px collapsed + # rail, so a horizontal scrollbar appeared and slid the icons out of + # the position they hold while the rail is open. + from PySide6.QtWidgets import QHeaderView + tree.header().setSectionResizeMode(0, QHeaderView.Stretch) + tree.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + tree.setItemDelegate(_NavItemDelegate(tree)) + return tree + + def _nav_rows(self): + """(tree, page, sub, label, icon, enabled) for every row, rail order. + + Workspace contributes all five of its sub-views — including the two the + project gate currently disables — so the rail never changes shape while + the user is looking at it. + """ + rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on) + for label, sub, ic, on in self.workspace.nav_entries()] + rows.append((self.nav, self._ROW_SCHEDULE, None, + tr("app.tab.schedule"), "schedule", True)) + rows.append((self.nav_bottom, self._ROW_DASHBOARD, None, + tr("app.tab.dashboard"), "dashboard", True)) + rows.append((self.nav_bottom, self._ROW_MONITORING, None, + tr("app.tab.monitoring"), "monitoring", True)) + return rows + + def _rebuild_nav(self, force: bool = False) -> None: + """Re-fill both lists from _nav_rows(), keeping the current selection. + + Rebuilding changes the current item, which would fire navigation and can + loop back here via subtabs_changed — hence the guard and the blocked + signals. + """ + if self._nav_building: + return + spec = self._nav_rows() + # Rebuilding deletes the QTreeWidgetItems, including the one a signal is + # currently being delivered for. subtabs_changed fires on every visit to + # Workspace, so skip the rebuild unless the rows really differ. + sig = [(label, page, sub, enabled) + for _t, page, sub, label, _ic, enabled in spec] + if not force and sig == getattr(self, "_nav_sig", None): + return + self._nav_sig = sig + self._nav_building = True + try: + from .ui.icons import icon as _icon + keep = self._current_nav_key() + for tree in (self.nav, self.nav_bottom): + blocked = tree.blockSignals(True) + tree.clear() + tree.blockSignals(blocked) + for tree, page, sub, label, icon_name, enabled in spec: + it = QTreeWidgetItem([""] if self._nav_collapsed else [label]) + it.setIcon(0, _icon(icon_name)) + it.setData(0, Qt.UserRole, {"page": page, "sub": sub}) + if not enabled: + # Same gate as before, shown instead of hidden: the row stays + # in place, greyed, and says why it cannot be opened. + it.setDisabled(True) + it.setToolTip(0, tr("app.nav.needs_project")) + elif self._nav_collapsed: + it.setToolTip(0, label) + blocked = tree.blockSignals(True) + tree.addTopLevelItem(it) + tree.blockSignals(blocked) + # Both destination lists are exactly as tall as their rows; the + # stretch in between belongs to RECENTS. + 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) + if keep: + self._select_nav_row(*keep) + finally: + self._nav_building = False + + def _current_nav_key(self): + """(page, sub) of the highlighted row, or None.""" + for tree in (self.nav, self.nav_bottom): + it = tree.currentItem() + if it is not None and it.isSelected(): + data = it.data(0, Qt.UserRole) or {} + if "page" in data: + return data["page"], data.get("sub") + return None + + def _select_nav_row(self, page: int, sub) -> None: + """Highlight the row for (page, sub) without triggering navigation. + + Called both when the user clicks (to keep the two lists mutually + exclusive) and from _goto, so programmatic navigation moves the + highlight too — it used to stay behind on whatever was clicked last. + """ + for tree in (self.nav, self.nav_bottom): + blocked = tree.blockSignals(True) + match = None + for i in range(tree.topLevelItemCount()): + it = tree.topLevelItem(i) + data = it.data(0, Qt.UserRole) or {} + if data.get("page") == page and ( + data.get("sub") == sub or data.get("sub") is None): + match = it + break + if match is not None: + tree.setCurrentItem(match) + else: + tree.setCurrentItem(None) + tree.clearSelection() + tree.blockSignals(blocked) + + def _on_nav_current(self, tree: QTreeWidget, item) -> None: + """A row was picked: clear the other list so only one row looks active.""" + if item is None or self._nav_building: + return + data = item.data(0, Qt.UserRole) or {} + other = self.nav_bottom if tree is self.nav else self.nav + blocked = other.blockSignals(True) + other.setCurrentItem(None) + other.clearSelection() + other.blockSignals(blocked) + self._goto(data.get("page", 0), data.get("sub")) + + # ---- rail header: project picker + new chat ------------------------ + def _sync_rail_project(self, *_a) -> None: + """Mirror the workspace's project list/selection into the rail picker. + + One-way on purpose: the project list stays the source of truth, this is + only a second place to see and change it. + """ + if self._syncing_rail_project: + return + self._syncing_rail_project = True + try: + choices = self.workspace.project_choices() + current = self.workspace.selected_project_id() + self.nav_project.clear() + for name, pid in choices: + self.nav_project.addItem(f"📁 {name}", pid) + if not choices: + # No project yet: say so, and say what to do about it, instead of + # leaving an empty box and a button that silently does nothing. + self.nav_project.addItem(tr("app.nav.no_project"), "") + idx = self.nav_project.findData(current) + if idx >= 0: + self.nav_project.setCurrentIndex(idx) + has = bool(choices) + tidy_popup(self.nav_project) + self.nav_project.setEnabled(has) + self.nav_project_btn.setEnabled(has) + self.nav_project_btn.setToolTip( + self.nav_project.currentText().replace("📁 ", "") + if has else tr("app.nav.create_project_first")) + self.nav_new_chat.setEnabled(has) + self.nav_new_chat.setToolTip( + "" if has else tr("app.nav.create_project_first")) + finally: + self._syncing_rail_project = False + + def _fill_rail_project_menu(self) -> None: + """Mirror the picker's items. Choosing one moves the picker, which runs + _on_rail_project_pick — the collapsed rail adds no second code path.""" + menu = self.nav_project_btn.menu() + menu.clear() + for i in range(self.nav_project.count()): + act = menu.addAction(self.nav_project.itemText(i)) + act.setCheckable(True) + act.setChecked(i == self.nav_project.currentIndex()) + act.triggered.connect( + lambda _checked=False, row=i: self.nav_project.setCurrentIndex(row)) + + def _on_rail_project_pick(self, _idx: int) -> None: + if self._syncing_rail_project: + return + pid = self.nav_project.currentData() + if pid: + self.workspace.choose_project(pid) + + # ---- rail RECENTS -------------------------------------------------- + _RAIL_RECENTS = 5 + + def _refresh_rail_recents(self) -> None: + """Re-fill the rail's recents from the active project's history.""" + from .ui.icons import DOT_BLUE, dot_icon + from .ui.icons import icon as _icon + + tree = self.nav_recents + blocked = tree.blockSignals(True) + tree.clear() + running = self._running_session_ids() + threads = self.workspace.recent_threads(self._RAIL_RECENTS) + for t in threads: + it = QTreeWidgetItem([t["title"]]) + it.setToolTip(0, t["title"]) + if t["session_id"] in running: + it.setIcon(0, dot_icon(DOT_BLUE)) # same marker as History + elif t["pinned"]: + it.setIcon(0, _icon("pin")) + it.setData(0, Qt.UserRole, {"path": t["path"], "kind": t["kind"]}) + tree.addTopLevelItem(it) + if not threads: + it = QTreeWidgetItem([tr("sidebar.empty")]) + it.setDisabled(True) + tree.addTopLevelItem(it) + # The way back to everything the rail cannot show — styled as a link + # (italic, accent-colored) so it reads as "go elsewhere", not another row. + more = QTreeWidgetItem([tr("app.nav.all_projects")]) + more.setData(0, Qt.UserRole, {"all": True}) + more_font = more.font(0) + more_font.setItalic(True) + more.setFont(0, more_font) + more.setForeground(0, QColor(current_palette().accent)) + tree.addTopLevelItem(more) + tree.blockSignals(blocked) + self.nav_recents_hdr.setVisible(not self._nav_collapsed) + self.nav_recents.setVisible(not self._nav_collapsed) + + def _on_rail_recent(self, item, _col: int = 0) -> None: + data = item.data(0, Qt.UserRole) or {} + if data.get("all"): + self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx) + self.workspace.show_history_pane() + return + path = data.get("path") + if path: + self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx) + self.workspace.open_thread(path, data.get("kind", "cowork")) + + def _on_rail_new_chat(self) -> None: + """Start a new chat, from any screen. + + Same call the Cowork toolbar button makes — that button stays exactly + where it was; this is a second entry point, not a replacement. + """ + self._goto(self._ROW_WORKSPACE, None) + self.workspace.start_new_chat() + self._select_nav_row(self._ROW_WORKSPACE, self.workspace.current_subtab()) + def _apply_nav_labels(self) -> None: - """Set each top-level nav item's text for the current language AND - collapse state: collapsed shows icon-only (label → tooltip) and folds - the accordion so only the top-level icons show.""" - for page, item in enumerate(self._nav_items): - key = self._nav_defs[page][0] - label = tr(key) - item.setText(0, "" if self._nav_collapsed else label) - item.setToolTip(0, label if self._nav_collapsed else "") - if self._nav_collapsed: - item.setExpanded(False) - # Refresh child labels (language-aware, from each container's tabText). + """Re-label every row for the current language and collapse state + (collapsed = icon only, label moves to the tooltip).""" + # force: collapsing leaves the row spec identical, only the text changes. + self._rebuild_nav(force=True) + self._nav_settings_text.setText(tr("app.settings")) + self._nav_settings_text.setVisible(not self._nav_collapsed) + self._nav_settings_btn.setToolTip(tr("app.settings")) + # Collapsed to 54px there is no room for either control's label; the + # picker would be a stub of a name, so it steps aside entirely and the + # button keeps just its + icon. + self.nav_project.setVisible(not self._nav_collapsed) + self.nav_project_btn.setVisible(self._nav_collapsed) + self._refresh_rail_recents() + # Collapsed to 54px only the theme toggle still fits; the rest of the + # account row would be clipped, so it steps aside (Settings, which opens + # the same values in a dialog, stays reachable as an icon). + self.account_lbl.setVisible(not self._nav_collapsed) + self.language_combo.setVisible(not self._nav_collapsed) + self.provider_combo.setVisible(not self._nav_collapsed) + self.nav_new_chat.setText("" if self._nav_collapsed else tr("cowork.new_chat")) + if self._nav_new_chat_enabled(): + self.nav_new_chat.setToolTip( + tr("cowork.new_chat") if self._nav_collapsed else "") + self._sync_rail_project() + + def _nav_new_chat_enabled(self) -> bool: + return bool(self.workspace.project_choices()) + + def _nav_max_width(self) -> int: + """The rail's ceiling for THIS window, as a share of it.""" + return max(_NAV_MIN_WIDTH, + min(_NAV_MAX_CEILING, int(self.width() * _NAV_MAX_SHARE))) + + def _set_nav_width_range(self, lo: int, hi: int) -> None: + """setFixedWidth would leave the splitter handle inert — visible, and + doing nothing when dragged.""" + self._nav_wrap.setMinimumWidth(lo) + self._nav_wrap.setMaximumWidth(hi) + + def _on_split_moved(self, _pos: int, _index: int) -> None: if not self._nav_collapsed: - for page in self._nav_parents: - if self._built[page]: - self._reload_nav_children(page) + self._nav_width = max(_NAV_MIN_WIDTH, + min(self._nav_max_width(), self._nav_wrap.width())) def _toggle_nav(self) -> None: + if not self._nav_collapsed: + self._nav_width = max(_NAV_MIN_WIDTH, + min(self._nav_max_width(), self._nav_wrap.width())) self._nav_collapsed = not self._nav_collapsed - width = _NAV_COLLAPSED_WIDTH if self._nav_collapsed else _NAV_EXPANDED_WIDTH - self._nav_wrap.setFixedWidth(width) + if self._nav_collapsed: + width = _NAV_COLLAPSED_WIDTH + self._set_nav_width_range(width, width) + else: + width = self._nav_width + self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) self._apply_nav_labels() # Same chevron convention as every other collapsible panel: right- # pointing (fill-right) means "click to expand", left means "collapse". @@ -444,6 +873,7 @@ class MainWindow(QMainWindow): current = self.cowork.session_id self.sidebar.set_view_state(current, self._running_session_ids()) self.sidebar.refresh() + self._refresh_rail_recents() # the rail shortcut follows the panel QTimer.singleShot(0, _do) @@ -531,9 +961,8 @@ class MainWindow(QMainWindow): def _build_topbar(self) -> QWidget: bar = QWidget() bar.setObjectName("topbar") - # Transparent: the logo/provider/language text sits directly on the - # window background, no separate card box behind it. - bar.setStyleSheet("#topbar { background: transparent; border: none; }") + # Styled centrally (see theme._TEMPLATE): flat, with a single hairline + # separating it from the content below — no card box behind it. h = QHBoxLayout(bar) h.setContentsMargins(16, 10, 12, 10) h.setSpacing(10) @@ -548,22 +977,32 @@ class MainWindow(QMainWindow): self.logo_img.setVisible(False) h.addWidget(self.logo_img) self.logo_lbl = QLabel(tr("app.logo")) - self.logo_lbl.setStyleSheet(f"font-weight:800; font-size:16px; color:{ACCENT};") + self.logo_lbl.setObjectName("brand") # styled centrally — see theme._TEMPLATE h.addWidget(self.logo_lbl) h.addStretch(1) + # Provider / language / theme / Settings used to live here, five controls + # wide across the top of every screen. They are per-account settings, not + # per-screen ones, so they moved to the account row at the foot of the + # rail (_build_account_row) — same widgets, same handlers, new home. + return bar - self.provider_lbl = QLabel(tr("app.provider")) - self.provider_lbl.setObjectName("hint") - h.addWidget(self.provider_lbl) - self.provider_combo = QComboBox() - for key, label in PROVIDER_LABELS.items(): - self.provider_combo.addItem(label, key) - idx = self.provider_combo.findData(self.ctx.config.active_provider) - if idx >= 0: - self.provider_combo.setCurrentIndex(idx) - self.provider_combo.currentIndexChanged.connect(self._on_provider_changed) - h.addWidget(self.provider_combo) + def _build_account_row(self) -> QWidget: + """The rail's foot: who you are, and the settings that follow you. + Nothing new is introduced here — these are the exact widgets the top bar + used to hold, moved as-is so every existing signal still lands. + """ + box = QWidget() + box.setObjectName("navAccount") + v = QVBoxLayout(box) + v.setContentsMargins(6, 4, 6, 4) + v.setSpacing(4) + + who = QHBoxLayout() + who.setSpacing(4) + self.account_lbl = QLabel(f"👤 {self._user_name}" if self._user_name else "👤") + self.account_lbl.setObjectName("hint") + who.addWidget(self.account_lbl, 1) self.language_combo = QComboBox() for key in LANGUAGES: self.language_combo.addItem(LANGUAGE_SHORT.get(key, key.upper()), key) @@ -572,22 +1011,28 @@ class MainWindow(QMainWindow): idx = self.language_combo.findData(get_language()) if idx >= 0: self.language_combo.setCurrentIndex(idx) + tidy_popup(self.language_combo) self.language_combo.currentIndexChanged.connect(self._on_language_changed) - h.addWidget(self.language_combo) - + who.addWidget(self.language_combo) self.theme_btn = self._build_theme_button() - h.addWidget(self.theme_btn) + who.addWidget(self.theme_btn) + v.addLayout(who) - if self._user_name: - user_lbl = QLabel(f"👤 {self._user_name}") - user_lbl.setObjectName("hint") - h.addWidget(user_lbl) - self.settings_btn = QPushButton(tr("app.settings")) - from .ui.icons import icon as _icon - self.settings_btn.setIcon(_icon("settings")) - self.settings_btn.clicked.connect(self._open_settings) - h.addWidget(self.settings_btn) - return bar + self.provider_lbl = QLabel(tr("app.provider")) + self.provider_lbl.setObjectName("hint") + self.provider_lbl.setVisible(False) # the combo names itself in the rail + self.provider_combo = QComboBox() + self.provider_combo.setToolTip(tr("app.provider")) + for key, label in PROVIDER_LABELS.items(): + self.provider_combo.addItem(label, key) + tidy_popup(self.provider_combo) + idx = self.provider_combo.findData(self.ctx.config.active_provider) + if idx >= 0: + self.provider_combo.setCurrentIndex(idx) + self.provider_combo.currentIndexChanged.connect(self._on_provider_changed) + v.addWidget(self.provider_lbl) + v.addWidget(self.provider_combo) + return box _BRAND_LOGO_NAMES = ("fpt_logo.png", "fpt-logo.png", "logo_fpt.png", "fpt_logo.jpg") _BRAND_LOGO_HEIGHT = 22 @@ -661,6 +1106,11 @@ class MainWindow(QMainWindow): dlg = SettingsDialog(self.ctx, self) if dlg.exec(): self._apply_theme() + # Settings can change the theme too — keep the rail's toggle icon + # showing the value that is actually in effect. + 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 # reflect provider/theme/language changes i = self.provider_combo.findData(self.ctx.config.active_provider) @@ -679,46 +1129,6 @@ class MainWindow(QMainWindow): self.sidebar.refresh() self.statusBar().showMessage(tr("app.status.settings_saved")) - def _reload_nav_children(self, page: int) -> None: - """(Re)build the nav children of a container page from its current - sub-views. Called when the page is built, when Workspace sub-tab - visibility changes, and on language change.""" - if not (0 <= page < len(self._nav_items)): - return - item = self._nav_items[page] - expanded = item.isExpanded() - item.takeChildren() - widget = self._page_widgets[page] - if not hasattr(widget, "nav_subtabs"): - return - from .ui.icons import icon as _icon - for label, sub, icon_name in widget.nav_subtabs(): - child = QTreeWidgetItem([label]) - child.setIcon(0, _icon(icon_name)) - child.setData(0, Qt.UserRole, {"page": page, "sub": sub, "parent": False}) - item.addChild(child) - item.setExpanded(True) - - def _on_nav_click(self, item, _col: int = 0) -> None: - # Clicking a parent toggles its expansion (its page is still shown). - data = item.data(0, Qt.UserRole) or {} - if data.get("parent"): - item.setExpanded(not item.isExpanded()) - - def _on_nav_expanded(self, item) -> None: - # Expanding a container whose children aren't built yet (e.g. Monitoring - # on first open, shown via its always-on dropdown arrow) builds its page - # so the sub-views appear. - data = item.data(0, Qt.UserRole) or {} - if data.get("parent") and item.childCount() == 0: - self._ensure_page(data.get("page", 0)) - - def _navigate(self, item) -> None: - if item is None: - return - data = item.data(0, Qt.UserRole) or {} - self._goto(data.get("page", 0), data.get("sub")) - def _goto(self, page: int, sub) -> None: self._ensure_page(page) # build lazy page on first visit self.pages.setCurrentIndex(page) @@ -726,10 +1136,52 @@ class MainWindow(QMainWindow): self.workspace.refresh() # re-list projects + threads on entry widget = self._page_widgets[page] if sub is not None and hasattr(widget, "select_subtab"): - widget.select_subtab(sub) + # Enforce the project gate here rather than at each entry point. A + # greyed rail row cannot be clicked, but _goto is also reached from + # RECENTS and from startup restore, and it used to open a sub-tab + # the gate was holding shut — page shown, tab strip still hiding it. + if hasattr(widget, "subtab_available") and not widget.subtab_available(sub): + self.statusBar().showMessage(tr("app.nav.needs_project"), 4000) + else: + widget.select_subtab(sub) + # Move the highlight with the content, however navigation was triggered — + # a programmatic _goto used to leave it on whatever was clicked last. + if not self._nav_building: + self._select_nav_row(page, sub) + self._update_dock_guard() # Switching pages updates which conversation is "current". self._refresh_history() + def _update_dock_guard(self) -> None: + """Keep the floating assistant clear of a screen's own bottom bar. + + Only Cowork has one (the composer). Everywhere else the dock sits in + the corner as before. + """ + dock = getattr(self, "help_agent", None) + if dock is None: + return + guard = 0 + on_cowork = (self.pages.currentIndex() == self._ROW_WORKSPACE + and self.workspace.current_subtab() == self.workspace._cowork_tab_idx) + if on_cowork: + comp = getattr(self.cowork, "composer", None) + if comp is not None and not comp.isHidden(): + # Measured from the composer's TOP edge in window coordinates: + # its own height misses the extra row of controls laid out under + # it, which left the dot still overlapping by ~25px. + origin = comp.mapTo(self, comp.rect().topLeft()) + # ...but only lift the dot if the composer is actually beneath + # it. The composer stops at the chat column's right edge, well + # short of the dot, so lifting it there raised the dot 156px for + # nothing — on Cowork alone it sat off the corner every other + # screen keeps it in. + dock_left = dock.x() - self.mapToGlobal(self.rect().topLeft()).x() + dock_right = dock_left + dock.width() + if dock_right > origin.x() and dock_left < origin.x() + comp.width(): + guard = max(0, self.height() - origin.y() + 8) + dock.set_bottom_guard(guard) + def _on_projects_changed(self) -> None: self.sidebar.refresh() # History regroups by project self.cowork._apply_output_folder_label() # project may have been renamed @@ -738,6 +1190,7 @@ class MainWindow(QMainWindow): def _apply_theme(self) -> None: app = QApplication.instance() if app: + set_active_theme(self.ctx.config.theme) app.setStyleSheet(stylesheet(self.ctx.config.theme)) # Re-apply theme styles to chat bubbles so they adapt to the new theme. self.cowork.apply_theme() @@ -745,6 +1198,11 @@ class MainWindow(QMainWindow): self.help_agent.apply_theme() # chat body follows theme (header stays fixed) # ---- sizing ------------------------------------------------------ + # Share of the available screen the window takes when it has room to. Fixed + # pixels do not travel: 1180×760 fills a laptop and looks lost on a 4K + # panel. `want_*` stays the floor so a small screen behaves as before. + _SCREEN_SHARE_W, _SCREEN_SHARE_H = 0.80, 0.85 + def _fit_to_screen(self, want_w: int, want_h: int) -> None: screen = self.screen() or QGuiApplication.primaryScreen() avail = screen.availableGeometry() if screen else None @@ -752,8 +1210,12 @@ class MainWindow(QMainWindow): self.resize(want_w, want_h) return margin = 60 - w = min(want_w, avail.width() - margin) - h = min(want_h, avail.height() - margin) + # Take a share of the screen, never less than the asked-for size and + # never more than the screen can show. + w = min(max(want_w, int(avail.width() * self._SCREEN_SHARE_W)), + avail.width() - margin) + h = min(max(want_h, int(avail.height() * self._SCREEN_SHARE_H)), + avail.height() - margin) # minimum must never exceed what the screen can show self.setMinimumSize(min(820, avail.width() - margin), min(520, avail.height() - margin)) self.resize(max(w, 1), max(h, 1)) @@ -761,6 +1223,25 @@ class MainWindow(QMainWindow): frame.moveCenter(avail.center()) self.move(frame.topLeft()) + def moveEvent(self, event): # noqa: N802 - Qt override + super().moveEvent(event) + # Dragged to another monitor: its work area (and scaling) may differ, so + # the floating assistant re-pins and the panes re-decide if they fit. + self._on_screen_maybe_changed() + + def _on_screen_maybe_changed(self) -> None: + screen = self.screen() + if screen is getattr(self, "_last_screen", None): + return + self._last_screen = screen + avail = screen.availableGeometry() if screen else None + if avail is not None: + self.setMinimumSize(min(820, avail.width() - 60), + min(520, avail.height() - 60)) + if getattr(self, "help_agent", None) is not None: + self._update_dock_guard() + self.help_agent.reposition() + # ---- lifecycle --------------------------------------------------- def closeEvent(self, event) -> None: # noqa: N802 keep = (self.tray is not None @@ -843,6 +1324,7 @@ def run(argv: List[str] | None = None) -> int: ctx.config.save() except Exception: # noqa: BLE001 - seeding must never block startup pass + set_active_theme(ctx.config.theme) app.setStyleSheet(stylesheet(ctx.config.theme)) # Follow the OS light/dark scheme live when theme is "Auto (System)". @@ -858,6 +1340,7 @@ def run(argv: List[str] | None = None) -> int: def _reapply_system_theme(*_a): if ctx.config.theme == "system": + set_active_theme("system") app.setStyleSheet(stylesheet("system")) win.cowork.apply_theme() try: diff --git a/config.py b/config.py index a5af93c..7b29aac 100644 --- a/config.py +++ b/config.py @@ -105,8 +105,7 @@ DEFAULT_CONFIG: Dict[str, Any] = { # sandboxes agent-run shell commands) — reading a URL for info is safe and # useful, so this defaults ON. Toggle in Settings → Security. "allow_url_fetch": True, - # Set with COWORK_SANDBOX_PASSWORD. Never ship a shared unlock secret. - "sandbox_pw": "", + "sandbox_pw": "quandh14", # default password to unlock sandbox settings "rulebase_path": "", # custom RULEBASE.md — attached to every agent execution }, # Legacy generic-MCP-server list. MERGED into ext_connectors["other"] as of @@ -174,8 +173,7 @@ DEFAULT_CONFIG: Dict[str, Any] = { # Microsoft. Real Outlook/Teams/OneDrive/SharePoint access still requires a # proper OAuth sign-in (not implemented yet) using tenant_id/client_id below. "ms365": { - # Set with COWORK_MS365_UNLOCK_CODE. Never ship a shared unlock secret. - "unlock_code": "", + "unlock_code": "quandh14", "unlocked": False, # runtime-only — never persisted as True, see save() # Auto-connect MS365/OneDrive/SharePoint: the built-in MS365 MCP server # launches automatically once the user is signed in (OAuth tenant/client @@ -296,10 +294,6 @@ def _apply_env_overrides(data: Dict[str, Any]) -> Dict[str, Any]: data["active_provider"] = os.environ["COWORK_ACTIVE_PROVIDER"] if os.getenv("COWORK_CA_BUNDLE"): data["tls_ca_bundle"] = os.environ["COWORK_CA_BUNDLE"] - if os.getenv("COWORK_SANDBOX_PASSWORD"): - data["agent_security"]["sandbox_pw"] = os.environ["COWORK_SANDBOX_PASSWORD"] - if os.getenv("COWORK_MS365_UNLOCK_CODE"): - data["ms365"]["unlock_code"] = os.environ["COWORK_MS365_UNLOCK_CODE"] return data diff --git a/docs/Cowork-Local BamBOO.pptx b/docs/Cowork-Local BamBOO.pptx new file mode 100644 index 0000000..eaee484 Binary files /dev/null and b/docs/Cowork-Local BamBOO.pptx differ diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 0000000..08e9ec4 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,327 @@ + + + + + +Cấu trúc hệ thống · Cowork Local + + + +
+ cowork_local · docs + + +
+ +
+
+

Cowork Local · Tài liệu kỹ thuật

+

Cấu trúc hệ thống

+

Trợ lý AI dạng agent chạy cục bộ trên máy (desktop, ưu tiên Windows). Người dùng trò chuyện, chạy luồng nhiều bước, thao tác tệp và lên lịch tác vụ — mọi thứ được bọc trong một khung bảo mật nhiều lớp.

+
+ PySide6 / Qt6 + Local-first + Provider-agnostic + ~53K dòng Python + Windows · macOS · Linux +
+
+ +
+

01 Tổng quan & nguyên tắc

+

Bốn nguyên tắc định hình toàn bộ kiến trúc.

+
+

▤Local-first

Cấu hình, lịch sử hội thoại, workspace và nhật ký đều nằm trên máy người dùng. Chỉ lệnh gọi mô hình mới ra ngoài.

+

⛨Bảo mật nhiều lớp

Mọi tool có tác động (chạy lệnh, ghi tệp, tải URL) đi qua chuỗi kiểm soát fail-closed; xem tài liệu Bảo mật.

+

⧉Đa workspace

Nhiều project chạy song song, mỗi project nhiều hội thoại Cowork và nhiều luồng Co4E — không cái nào chặn cái nào.

+

⇄Provider-agnostic

Nhiều nhà cung cấp mô hình (OpenAI-compatible…), tự động định tuyến chọn mô hình phù hợp trong số các model được bật.

+
+
+ +
+

02 Ngăn xếp công nghệ

+

Những thư viện/thành phần chủ chốt và vai trò của chúng.

+
+ PySide6/Qt6 · toàn bộ giao diện, đa luồng QThread + FastAPI + uvicorn · Routing API (chỉ localhost) + MCP · kết nối công cụ ngoài (Model Context Protocol) + MSAL · đăng nhập Microsoft 365 + openpyxl / python-pptx · đọc Office + opendataloader-pdf · trích xuất PDF + networkx · đồ thị cấu trúc (GraphRAG) + keyring · lưu bí mật qua OS + ctypes / Win32 · sandbox AppContainer & Job Object + Pygments · tô màu mã nguồn +
+
+ +
+

03 Kiến trúc phân lớp

+

Một yêu cầu đi từ giao diện xuống lớp thực thi rồi ra ngoài — mỗi lớp có trách nhiệm rõ ràng.

+
+
+
UI

Lớp giao diện — PySide6

người dùng thao tác
+
+ MainWindowWorkspaceTab / WorkspacePane + CoworkTab (chat)Co4ETab (flow canvas) + FolderTabScheduleTaskTab + MonitoringTab → SecuritySettingsDialog +
+
+
+
Agent

Lớp agent / lõi thực thi

điều phối lượt chạy
+
+ chat_agent · run_cowork + code_agent · run_code + co4e_runner · run_workflow + task_executors · tác vụ theo lịch + model_routing · assess & chọn model + agent_security · guardrail +
+
+
+
Tool

Lớp công cụ & sandbox

ranh giới tin cậy
+
+ ToolContext · confine đường dẫn + scope + execute_tool + read/write/edit/list_dir + run_command · install_package + fetch_url · jira + SandboxManager + backends +
+
+
+
Provider

Lớp nhà cung cấp mô hình

gọi ra mạng an toàn
+
+ providers/* (OpenAI-compatible…) + tls_trust · phục hồi TLS gateway + usage_tracker · đo token/chi phí +
+
+
+
Ngoài

Dịch vụ bên ngoài

không tin cậy mặc định
+
+ LLM APIsMCP servers + Microsoft 365JiraWeb (fetch_url) +
+
+
+
+ +
+

04 Các subsystem chính

+

Mỗi khối là một tính năng lớn người dùng thấy được, ánh xạ tới module tương ứng.

+
+

▦Workspaces & Projects

Nhiều project song song, mỗi cái một pane riêng với sandbox bật/tắt để tiết kiệm tài nguyên.

workspace_tab.pyworkspace_pane.py
+

💬Cowork · đa hội thoại

Nhiều hội thoại trong một project; lượt chạy nền giữ đúng hội thoại gốc kể cả khi bạn chuyển tab.

chat_panel.pycowork_tab.py
+

◈Co4E flows

Canvas nhiều bước, agent tùy biến, chế độ auto/plan/manual, chạy song song & theo dõi ở Flow Status.

co4e_tab.pyco4e_runner.py
+

⇉Model routing

Tự đánh giá & chọn mô hình tốt nhất trong số model được bật theo policy (chất lượng/chi phí/độ trễ).

core/routing/*
+

⛨Sandbox

Chọn backend theo mức rủi ro: best-effort → AppContainer → Windows Sandbox VM.

sandbox_manager.pyappcontainer_sandbox.py
+

⏱Scheduler

Tác vụ theo lịch (Cowork/Code/Flow), phụ thuộc chuỗi, opt-in chạy lệnh.

task_scheduler.pytask_executors.py
+

📊Monitoring

Tổng quan chi phí, nhật ký sự kiện/bảo mật, quản trị Tool/Agent, trang Security.

monitoring_tab.py
+

🗄Lưu trữ

Cấu hình + lịch sử theo project + workspace + audit log, tất cả trên máy.

config.pycore/history.py
+
+
+ +
+

05 Mô hình đồng thời

+

Vì sao nhiều lượt chạy song song không giẫm chân nhau.

+

Cô lập theo lượt (per-turn)

+

Mỗi lượt chat chạy trong một AgentWorker (QThread) riêng. Tại thời điểm bắt đầu, lượt chụp lại bối cảnh home_* (id hội thoại, thư mục làm việc, project) — nên dù người dùng chuyển sang hội thoại khác, lượt nền vẫn ghi kết quả về đúng hội thoại gốc và quét đúng thư mục của nó.

+

Quản lý luồng Co4E dùng chung

+

Một Co4ERunManager duy nhất phục vụ mọi pane, mỗi run gắn project_id để lọc. Khi dừng một worker bị treo, nó được "park" giữ tham chiếu (không GC luồng đang chạy → tránh crash QThread destroyed while running).

+
Cách ly dừng (Stop): nút Stop chỉ tác động lên các worker của chính hội thoại đó và xóa hàng đợi của riêng nó — dừng ở hội thoại này không ảnh hưởng hội thoại khác.
+
+ +
+

06 Luồng dữ liệu một lượt chat

+

Từ tin nhắn người dùng đến kết quả — mỗi bước là một điểm kiểm soát.

+
    +
  1. Tin nhắn + đính kèmNgười dùng gửi; tệp/thư mục workspace được nạp qua _augment.
  2. +
  3. Bọc nội dung không tin cậyNội dung tệp/web/tool được rào trong khối UNTRUSTED DATA — model coi là dữ liệu, không phải mệnh lệnh.
  4. +
  5. Định tuyến mô hìnhAuto Routing có thể chọn mô hình phù hợp trong số model được bật.
  6. +
  7. Gọi providerprovider.chat() qua tls_trust; usage_tracker ghi token/chi phí theo hội thoại gốc.
  8. +
  9. Model gọi toolMỗi tool qua: kiểm scope ở executor → human-gate (nếu bật) → classifier → sandbox.
  10. +
  11. Kết quả & lưuVăn bản/diff hiện realtime; hội thoại lưu vào .cowork_history của project.
  12. +
+
+ +
+

07 Lưu trữ trên máy

+

Dữ liệu nằm ở đâu.

+
+ ~/.cowork_local/config.json · cấu hình (perm 0o600) + <project>/.cowork_history · hội thoại theo project + workspaces/ · thư mục làm việc mỗi project + audit log · mọi tool-call & quyết định quyền (lưu hash lệnh) + trusted_certs/ · cert gateway đã pin + appcontainer_grants.json · cache cấp quyền sandbox +
+
Vị trí lịch sử có thể trỏ vào thư mục đồng bộ OneDrive — tiện chia sẻ, nhưng lưu ý dữ liệu tệp đã nạp sẽ được sao lên cloud dạng plaintext. Xem khuyến nghị ở tài liệu Bảo mật.
+
+
+ + + + + + diff --git a/docs/function_list.md b/docs/function_list.md new file mode 100644 index 0000000..a3a7fec --- /dev/null +++ b/docs/function_list.md @@ -0,0 +1,473 @@ +# 📋 COWORK-LOCAL BamBOO — Danh Sách Chức Năng Chi Tiết Theo Navigation Bar + +--- + +## 🔹 1. 📊 DASHBOARD (Bảng Điều Khiển) + +### 1.1 Token Usage & Cost — Thống Kê Token & Chi Phí + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 1.1.1 | `_refresh_cards()` | Làm mới các thẻ thống kê (Total, Input, Output, Cache tokens + Cost) | +| 1.1.2 | `_refresh_chart()` | Vẽ biểu đồ spline theo chu kỳ (week/month/year) và metric (cost/tokens) | +| 1.1.3 | `_chart_prev()` / `_chart_next()` | Chuyển đến chu kỳ trước/sau trên biểu đồ | +| 1.1.4 | `_on_gran_changed()` | Thay đổi đơn vị thời gian (week/month/year) | +| 1.1.5 | `_refresh_budget()` | Cập nhật ngân sách (budget card — còn lại / đã dùng / cảnh báo >85%) | +| 1.1.6 | `_apply_budget()` | Lưu giá trị budget mới | +| 1.1.7 | `_refresh_habits()` | Hiển thị thói quen sử dụng (task tốn nhiều token nhất, trung bình/prompt, ngày/giờ bận nhất) | +| 1.1.8 | `_ai_analyze()` | ✨ AI phân tích thói quen dùng token và gợi ý tiết kiệm | +| 1.1.9 | `_apply_saving_strategy()` | Áp dụng chiến lược tiết kiệm AI (tự nén context, nén sớm hơn) | +| 1.1.10 | Currency Picker | Chọn đơn vị tiền tệ hiển thị (USD, VND, JPY, …) | + +--- + +## 🔹 2. 📅 SCHEDULE TASK (Lên Lịch Nhiệm Vụ) + +### 2.1 Kanban Board + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 2.1.1 | `_build_kanban()` | Xây dựng board Kanban với 7 cột: Backlog, Scheduled, Running, Waiting Input, Done, Failed, Paused | +| 2.1.2 | `_render_kanban()` | Render các thẻ task vào từng cột | +| 2.1.3 | `_on_task_dropped(task_id, new_status)` | Kéo thả task giữa các cột (thay đổi status) | +| 2.1.4 | `_on_card_double_click()` | Mở Task Editor khi double-click | +| 2.1.5 | `_on_card_right_click()` | Menu ngữ cảnh: Run now, Edit, Duplicate, Pause, Delete, View logs, Create-next-from-output | +| 2.1.6 | `_bulk_delete_menu()` | Xóa hàng loạt (chọn nhiều thẻ → right-click → Delete N selected) | +| 2.1.7 | `_run_now(task_id)` | Chạy task ngay lập tức | +| 2.1.8 | `_duplicate_task(task_id)` | Sao chép task | +| 2.1.9 | `_pause_task(task_id)` | Tạm dừng task | +| 2.1.10 | `_delete_task(task_id)` | Xóa task | +| 2.1.11 | `_view_logs(task_id)` | Xem log của task | +| 2.1.12 | `_search_tasks()` | Tìm kiếm task theo tên | +| 2.1.13 | `_filter_by_type()` | Lọc task theo loại (cowork/co4e/code/…) | + +### 2.2 Calendar View + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 2.2.1 | `_build_calendar()` | Xây dựng chế độ xem lịch | +| 2.2.2 | `_shift(direction)` | Chuyển tháng/tuần trước/sau | +| 2.2.3 | `add_task_on_date(date)` | Thêm task vào ngày cụ thể | +| 2.2.4 | `edit_task(task_id)` | Sửa task từ lịch | + +### 2.3 Add / AI Create Task + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 2.3.1 | `_open_add_dialog()` | Mở dialog thêm task thủ công | +| 2.3.2 | `_ai_create_task()` | Mở dialog AI tạo task tự động | +| 2.3.3 | `_ai_pick_files()` | Chọn file đính kèm cho AI planner | +| 2.3.4 | `_generate()` | AI tạo kế hoạch tasks từ mô tả | +| 2.3.5 | `_on_planned(result)` | Hiển thị preview các task AI đề xuất | +| 2.3.6 | `_confirm()` | Xác nhận và tạo các task từ AI plan | + +### 2.4 AI Import Tasks + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 2.4.1 | `_ai_import()` | AI nhập task từ file/link | +| 2.4.2 | `_ai_pick_import_files()` | Chọn file để import | +| 2.4.3 | `_generate_import()` | AI phân tích file và tạo tasks | +| 2.4.4 | `_on_import_planned()` | Hiển thị preview import | + +--- + +## 🔹 3. 🏠 WORKSPACE (Không Gian Làm Việc) + +### 3.1 Projects — Quản Lý Dự Án (Tab 0) + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.1.1 | `_create()` | Tạo dự án mới | +| 3.1.2 | `_delete()` | Xóa dự án (có xác nhận) | +| 3.1.3 | `_save()` | Lưu thông tin dự án (name, description, instructions, folder) | +| 3.1.4 | `_pick_folder()` | Chọn workspace folder cho dự án | +| 3.1.5 | `_open_workspace()` | Mở folder workspace trong file explorer | +| 3.1.6 | `_select_project_row(project_id)` | Chọn dự án trong danh sách | +| 3.1.7 | `_refresh_sandbox_toggle()` | Bật/tắt sandbox cho dự án | +| 3.1.8 | `refresh()` | Làm mới danh sách dự án | + +### 3.2 Workspace Pane — Mỗi Dự Án Mở (Tab 1..N) + +#### 3.2.1 🤖 COWORK — Chat Với AI Agent + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.1.1 | `new_session()` | Tạo phiên chat mới | +| 3.2.1.2 | `send_message()` | Gửi tin nhắn đến AI agent | +| 3.2.1.3 | `_build_job()` | Xây dựng job cho AgentWorker (gọi `run_cowork`) | +| 3.2.1.4 | `_cleanup_turn(ctx, ok)` | Dọn dẹp sau khi turn kết thúc (promote files, xóa sandbox) | +| 3.2.1.5 | `_promote_turn_outputs()` | Di chuyển file đầu ra từ sandbox lên session output | +| 3.2.1.6 | `_refresh_outputs_from_disk()` | Làm mới danh sách output files | +| 3.2.1.7 | `_pick_output_folder()` | Chọn thư mục output | +| 3.2.1.8 | `_open_skills_manager()` | Mở Skill Manager | +| 3.2.1.9 | `refresh_header()` | Làm mới header (project name, model info) | +| 3.2.1.10 | `refresh_agents()` | Làm mới danh sách agents trong combo | +| 3.2.1.11 | `admin_agent_prompt()` | Lấy prompt từ agent preset đã chọn | +| 3.2.1.12 | `build_provider()` | Xây dựng provider từ cấu hình agent/model | +| 3.2.1.13 | `workspace_dir()` | Trả về workspace directory hiện tại | +| 3.2.1.14 | `_start_watching(dir)` | Giám sát folder output (file watcher) | +| 3.2.1.15 | `_on_file_changed()` | Xử lý khi file output thay đổi | + +**ChatPanel (Class cha của CoworkTab):** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.1.16 | `_submit_message()` | Gửi tin nhắn (kiểm tra queue, parallel limit) | +| 3.2.1.17 | `_on_turn_started()` | Khi turn bắt đầu (show thinking indicator) | +| 3.2.1.18 | `_on_turn_finished()` | Khi turn kết thúc (update UI, queue next) | +| 3.2.1.19 | `_on_event(ev)` | Xử lý streaming events (text delta, tool calls, plan) | +| 3.2.1.20 | `_compress_messages()` | Nén tin nhắn cũ để giảm token | +| 3.2.1.21 | `_on_agent_changed()` | Khi thay đổi agent trong combo | +| 3.2.1.22 | `_apply_routing()` | Áp dụng model routing (Auto/Manual/Off) | +| 3.2.1.23 | `_note_agent_switch()` | Ghi chú khi agent thay đổi giữa các turn | +| 3.2.1.24 | `_ensure_conversation()` | Đảm bảo conversation tab tồn tại | +| 3.2.1.25 | `load_conversation()` | Load hội thoại từ disk | +| 3.2.1.26 | `running_session_ids()` | Trả về danh sách session đang chạy | +| 3.2.1.27 | `active_workers()` | Trả về danh sách worker đang hoạt động | +| 3.2.1.28 | `_save_conversation()` | Tự động lưu hội thoại | + +**Composer (Composer input box):** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.1.29 | `send()` | Gửi tin nhắn | +| 3.2.1.30 | `attach_files()` | Đính kèm file | +| 3.2.1.31 | `attach_links()` | Đính kèm link URL | +| 3.2.1.32 | `has_any_queue()` | Kiểm tra queue có tin nhắn chờ | +| 3.2.1.33 | `_parse_directives()` | Phân tích directives inline (`/agent:name`, `/skill:name`) | +| 3.2.1.34 | `_show_autocomplete()` | Hiển thị gợi ý tự động | + +#### 3.2.2 ⚡ CO4E — Node-Graph Workflow Studio + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.2.1 | `_build_sidebar()` | Xây dựng sidebar (Workflows / Agents / Skills tabs) | +| 3.2.2.2 | `_build_canvas()` | Xây dựng canvas node-graph | +| 3.2.2.3 | `_build_config_panel()` | Xây dựng config panel bên phải | +| 3.2.2.4 | `_toggle_config()` | Thu/mở config panel | +| 3.2.2.5 | `_build_canvas_overlay()` | Zoom +/− và Fit buttons trên canvas | + +**Workflows (Sidebar):** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.2.6 | `_refresh_flows_list()` | Làm mới danh sách flows | +| 3.2.2.7 | `_create_flow()` | Tạo flow mới | +| 3.2.2.8 | `_delete_flow()` | Xóa flow | +| 3.2.2.9 | `_duplicate_flow()` | Sao chép flow | +| 3.2.2.10 | `_import_flow()` | Import flow từ file | +| 3.2.2.11 | `_export_flow()` | Export flow ra file | +| 3.2.2.12 | `_run_flow()` | Chạy flow (foreground/background) | +| 3.2.2.13 | `_stop_flow()` | Dừng flow đang chạy | +| 3.2.2.14 | `_open_flow()` | Mở flow trên canvas | + +**Agents (Sidebar):** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.2.15 | `_refresh_agents_list()` | Làm mới danh sách agents | +| 3.2.2.16 | `_create_agent()` | Tạo agent mới (dialog) | +| 3.2.2.17 | `_edit_agent()` | Sửa agent | +| 3.2.2.18 | `_delete_agent()` | Xóa agent | +| 3.2.2.19 | `_toggle_agent_enabled()` | Bật/tắt agent | + +**Skills (Sidebar):** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.2.20 | `_refresh_skills_list()` | Làm mới danh sách skills | + +**Canvas (Node-Graph):** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.2.21 | `zoom_in()` / `zoom_out()` | Zoom canvas | +| 3.2.2.22 | `fit_view()` | Auto-fit canvas | +| 3.2.2.23 | `_add_node()` | Thêm node lên canvas | +| 3.2.2.24 | `_delete_node()` | Xóa node | +| 3.2.2.25 | `_connect_nodes()` | Kết nối 2 nodes | +| 3.2.2.26 | `_drag_node()` | Kéo thả node | +| 3.2.2.27 | `_select_node()` | Chọn node (→ config panel) | +| 3.2.2.28 | `_activate_node()` | Double-click node | + +**Run Modes:** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.2.29 | `_set_run_mode("auto")` | Auto mode: agent tự plan rồi execute | +| 3.2.2.30 | `_set_run_mode("plan")` | Plan mode: chỉ tạo kế hoạch | +| 3.2.2.31 | `_set_run_mode("manual")` | Manual mode: từng bước, bấm "Next step" | +| 3.2.2.32 | `_run_step()` | Chạy bước tiếp theo (manual mode) | +| 3.2.2.33 | `_on_step_finished()` | Xử lý khi bước hoàn thành | +| 3.2.2.34 | `_render_plan()` | Render plan checklist | + +**Chat/Output (Bottom):** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.2.35 | `_get_flow_chat(flow_id)` | Lấy ChatView cho flow (tạo mới nếu chưa có) | +| 3.2.2.36 | `_on_chat_event()` | Xử lý event từ chat | + +#### 3.2.3 📁 FOLDER — File Explorer + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.3.1 | `set_root(path)` | Đặt thư mục gốc | +| 3.2.3.2 | `_build_tree_view()` | Xây dựng cây thư mục (QFileSystemModel) | +| 3.2.3.3 | `_open_file(path)` | Mở file được chọn | +| 3.2.3.4 | `_view_source()` | Xem source code (syntax highlighting) | +| 3.2.3.5 | `_view_html_preview()` | Preview HTML (WebEngine/rich text) | +| 3.2.3.6 | `_view_office_doc()` | Xem Office doc (docx/pdf/xlsx/…) | +| 3.2.3.7 | `_view_image()` | Hiển thị ảnh inline | +| 3.2.3.8 | `_edit_file()` | Chỉnh sửa file (code editor) | +| 3.2.3.9 | `_save_file()` | Lưu file | +| 3.2.3.10 | `_preview_toggle()` | Chuyển đổi Preview ⇄ Edit | +| 3.2.3.11 | `_create_new_file()` | Tạo file mới | +| 3.2.3.12 | `_create_new_folder()` | Tạo folder mới | +| 3.2.3.13 | `_rename_item()` | Đổi tên file/folder | +| 3.2.3.14 | `_delete_item()` | Xóa file/folder | +| 3.2.3.15 | `_copy_item()` | Sao chép file/folder | +| 3.2.3.16 | `_paste_item()` | Dán file/folder | +| 3.2.3.17 | `refresh_ai_models()` | Làm mới danh sách AI models cho AI Edit | + +**AI Edit (Chỉnh Sửa File Bằng AI):** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.3.18 | `_ai_send()` | Gửi yêu cầu AI edit | +| 3.2.3.19 | `_ai_apply()` | Áp dụng thay đổi AI | +| 3.2.3.20 | `_ai_discard()` | Hủy thay đổi AI | +| 3.2.3.21 | `_reset_ai_conversation()` | Xóa hội thoại AI edit | +| 3.2.3.22 | `_ai_apply_routing()` | Áp dụng routing cho AI edit | + +#### 3.2.4 🧠 GRAPH RAG — Knowledge Graph + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.4.1 | `_build_graph()` | Xây dựng knowledge graph từ code/documents | +| 3.2.4.2 | `_render_d3_graph()` | Render graph bằng D3.js (WebEngine) | +| 3.2.4.3 | `_render_native_graph()` | Render graph bằng QGraphicsView (fallback) | +| 3.2.4.4 | `_auto_rotate()` | Tự xoay graph khi idle | +| 3.2.4.5 | `_on_node_click()` | Xử lý click node (mở folder) | +| 3.2.4.6 | `_open_node_path()` | Mở folder chứa node | +| 3.2.4.7 | `_refresh_graph()` | Tự cập nhật graph khi có output mới | +| 3.2.4.8 | `_search_graph()` | Tìm kiếm trong graph | +| 3.2.4.9 | `_filter_by_kind()` | Lọc node theo loại | +| 3.2.4.10 | `_zoom_graph()` | Zoom graph | + +**Graph-RAG Q&A:** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.4.11 | `_ask_question()` | Hỏi AI về graph | +| 3.2.4.12 | `_on_ask_event()` | Xử lý streaming answer | +| 3.2.4.13 | `_on_ask_done()` | Khi AI trả lời xong | +| 3.2.4.14 | `_candidate_file_paths()` | Lấy danh sách file để extract | +| 3.2.4.15 | `_extract_tmp_dir()` | Tạo thư mục tạm cho extraction | +| 3.2.4.16 | `_clear_extracts()` | Xóa dữ liệu extract tạm | + +--- + +## 🔹 4. 📊 MONITORING (Giám Sát) + +### 4.1 Overview — Tổng Quan + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 4.1.1 | `_refresh_overview()` | Làm mới tất cả cards overview | +| 4.1.2 | `_refresh_usage_cards()` | Token Usage & Cost cards (Total, Input, Output, Cache) | +| 4.1.3 | `_refresh_resource_usage()` | Resource usage (CPU, RAM, Disk) | +| 4.1.4 | `_refresh_recent_activity()` | Hoạt động gần đây | +| 4.1.5 | `_refresh_sandbox_details()` | Chi tiết sandbox (PID, uptime, limits) | +| 4.1.6 | `_refresh_permissions()` | Hiển thị permissions hiện tại | +| 4.1.7 | `_refresh_audit_log()` | Audit log gần đây | +| 4.1.8 | `_refresh_budget()` | Budget card (còn lại / đã dùng) | +| 4.1.9 | `_apply_budget()` | Lưu budget mới | + +### 4.2 Security Events — Sự Kiện Bảo Mật + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 4.2.1 | `_refresh_security_events()` | Làm mới bảng security events (audit log `kind="security_block"`) | +| 4.2.2 | `_filter_security_events()` | Lọc sự kiện bảo mật | +| 4.2.3 | `_sort_events()` | Sắp xếp bảng events | + +### 4.3 MCP Call History — Lịch Sử Gọi MCP + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 4.3.1 | `_refresh_mcp_calls()` | Làm mới bảng MCP calls (audit log `kind="mcp_call"`) | +| 4.3.2 | `_filter_mcp_calls()` | Lọc MCP calls | + +### 4.4 Action Logs — Nhật Ký Hành Động + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 4.4.1 | `_refresh_action_logs()` | Làm mới bảng action logs (toàn bộ audit log) | +| 4.4.2 | `_filter_action_logs()` | Lọc action logs | +| 4.4.3 | `_sort_action_logs()` | Sắp xếp action logs | + +### 4.5 Agent Status — Trạng Thái Agent + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 4.5.1 | `_refresh_agent_status()` | Làm mới trạng thái các agent (Cowork, Co4E, Schedule, GraphRAG) | + +### 4.6 Security Settings — Cài Đặt Bảo Mật + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 4.6.1 | `_toggle_sandbox()` | Bật/tắt sandbox | +| 4.6.2 | `_toggle_network_block()` | Chặn kết nối mạng | +| 4.6.3 | `_set_resource_limits()` | Đặt giới hạn tài nguyên (CPU/RAM/Disk) | +| 4.6.4 | `_toggle_command_confirm()` | Xác nhận trước khi chạy lệnh | +| 4.6.5 | `_manage_permissions()` | Quản lý quyền truy cập | + +--- + +## 🔹 5. ⚙️ SETTINGS (Cài Đặt) + +### 5.1 AI Provider — Nhà Cung Cấp AI + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 5.1.1 | `_on_provider_changed()` | Khi thay đổi provider | +| 5.1.2 | `_load_models(provider)` | Load danh sách models của provider | +| 5.1.3 | `_test_connection(provider)` | Kiểm tra kết nối provider | +| 5.1.4 | `_stash_provider_fields()` | Lưu tạm các trường cấu hình provider | +| 5.1.5 | `_apply_provider_fields()` | Áp dụng các trường cấu hình provider | +| 5.1.6 | Model List Widget | Hiển thị danh sách models (enable/disable, chọn default) | + +### 5.2 Connectors (MCP) — Kết Nối + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 5.2.1 | `_add_mcp_server()` | Thêm MCP server mới | +| 5.2.2 | `_edit_mcp_server()` | Sửa MCP server | +| 5.2.3 | `_delete_mcp_server()` | Xóa MCP server | +| 5.2.4 | `_test_mcp_connection()` | Kiểm tra kết nối MCP | +| 5.2.5 | MS365 Connector | Kết nối Microsoft 365 (tự động khi đăng nhập) | +| 5.2.6 | CAD/CAE Connectors | Kết nối CAD/CAE tools | + +### 5.3 Parameters — Tham Số + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 5.3.1 | `attach_tokens` | Giới hạn token cho attachments | +| 5.3.2 | `attach_files` | Giới hạn số file attachments | +| 5.3.3 | `struct_nodes` | Giới hạn nodes cho GraphRAG | +| 5.3.4 | `struct_edges` | Giới hạn edges cho GraphRAG | + +### 5.4 Model Routing — Định Tuyến Model + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 5.4.1 | `routing_mode` | Chế độ routing (Off/Auto/Manual) | +| 5.4.2 | `routing_policy` | Chính sách routing | +| 5.4.3 | `routing_min_gain` | Threshold tối thiểu để chuyển model | +| 5.4.4 | `routing_timeout` | Timeout xác nhận routing | +| 5.4.5 | `routing_interval` | Khoảng thời gian đánh giá lại | +| 5.4.6 | `routing_concurrency` | Số lượng request đồng thời per provider | +| 5.4.7 | `routing_judge` | Model dùng để đánh giá routing | + +### 5.5 General — Chung + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 5.5.1 | Language Picker | Chọn ngôn ngữ (EN/VI/JP) | +| 5.5.2 | `tray_chk` | Minimize to tray thay vì đóng | +| 5.5.3 | `notify_chk` | Thông báo khi task hoàn thành | +| 5.5.4 | `_save()` | Lưu tất cả cài đặt | + +--- + +## 🔹 6. 📜 HISTORY SIDEBAR (Thanh Lịch Sử Bên Trái) + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 6.0.1 | `refresh()` | Làm mới danh sách hội thoại | +| 6.0.2 | `set_view_state(session_id, running_ids)` | Đánh dấu hội thoại hiện tại + đang chạy | +| 6.0.3 | `set_project_filter(project_id)` | Lọc theo dự án | +| 6.0.4 | `_open_chat()` | Mở hội thoại khi click | +| 6.0.5 | `_context_menu()` | Menu chuột phải (Pin/Unpin, Rename, Delete) | +| 6.0.6 | `_bulk_delete_menu()` | Menu xóa hàng loạt | +| 6.0.7 | `_confirm_and_delete_selected()` | Xác nhận và xóa các hội thoại đã chọn | +| 6.0.8 | `new_chat(kind)` | Tạo hội thoại mới | +| 6.0.9 | `collapse_requested()` | Thu nhỏ sidebar | +| 6.0.10 | `expand_requested()` | Mở rộng sidebar | + +--- + +## 🔹 7. 🧩 CÁC CHỨC NĂNG TOÀN CẦU (Global) + +### 7.1 MainWindow (app.py) + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 7.1.1 | `_build_topbar()` | Xây dựng thanh trên cùng (User name, Settings, Language) | +| 7.1.2 | `_build_nav_rail()` | Xây dựng thanh điều hướng bên trái | +| 7.1.3 | `_toggle_nav()` | Thu/mở nav rail (icon-only ↔ full) | +| 7.1.4 | `_apply_nav_labels()` | Áp dụng labels cho nav items | +| 7.1.5 | `_ensure_page(row)` | Xây dựng page lười (lazy loading) | +| 7.1.6 | `_refresh_history()` | Làm mới history của tất cả panes | +| 7.1.7 | `_on_scheduled_task_done()` | Thông báo khi scheduled task hoàn thành | +| 7.1.8 | `_notify_task()` | Thông báo khi task hoàn thành | +| 7.1.9 | `_on_projects_changed()` | Khi danh sách dự án thay đổi | +| 7.1.10 | `_on_pane_turn_finished()` | Khi turn trong pane hoàn thành | +| 7.1.11 | `_open_settings()` | Mở dialog cài đặt | +| 7.1.12 | `_fit_to_screen()` | Tự động fit cửa sổ theo màn hình | +| 7.1.13 | Toast notifications | Hiển thị thông báo toast | +| 7.1.14 | System Tray | Minimize to tray, tray notifications | + +### 7.2 Skills Manager + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 7.2.1 | `_open_skills_manager()` | Mở Skill Manager | +| 7.2.2 | `seed_library_skills()` | Gieo skills mặc định | +| 7.2.3 | `prune_seeded_builtins()` | Dọn dẹp skills built-in | + +### 7.3 Welcome Dialog + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 7.3.1 | `maybe_show_welcome()` | Hiển thị dialog chào mừng lần đầu | + +### 7.4 i18n (Đa Ngôn Ngữ) + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 7.4.1 | `tr(key)` | Dịch chuỗi theo ngôn ngữ hiện tại | +| 7.4.2 | `set_language(lang)` | Đặt ngôn ngữ | +| 7.4.3 | `get_language()` | Lấy ngôn ngữ hiện tại | +| 7.4.4 | `on_language_changed(callback)` | Đăng ký callback khi ngôn ngữ thay đổi | + +### 7.5 Task Scheduler (Nền) + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 7.5.1 | `task_finished` signal | Khi scheduled task hoàn thành | +| 7.5.2 | `history_ready` signal | Khi session của task sẵn sàng | +| 7.5.3 | `running_session_ids()` | Lấy danh sách session đang chạy | + +--- + +## 📌 TỔNG KẾT + +| Navigation Item | Số Hàm/Chức Năng | +|----------------|:-:| +| 📊 Dashboard | ~10 | +| 📅 Schedule Task | ~25 | +| 🏠 Workspace → Projects | ~8 | +| 🏠 Workspace → Cowork | ~34 | +| 🏠 Workspace → Co4E | ~36 | +| 🏠 Workspace → Folder | ~22 | +| 🏠 Workspace → Graph RAG | ~16 | +| 📊 Monitoring | ~20 | +| ⚙️ Settings | ~25 | +| 📜 History Sidebar | ~10 | +| 🌐 Global Functions | ~15 | +| **TỔNG CỘNG** | **~221** | + +> **Lưu ý:** Đây là danh sách các hàm/chức năng ở cấp UI và business logic chính. Các hàm core (providers, MCP, worker, security…) nằm ở tầng dưới và được gọi bởi các hàm UI ở trên. \ No newline at end of file diff --git a/docs/rag-qa.html b/docs/rag-qa.html new file mode 100644 index 0000000..11e8ef0 --- /dev/null +++ b/docs/rag-qa.html @@ -0,0 +1,540 @@ + + + + + +Tìm hiểu RAG — Hỏi & Đáp + + + +
Tìm hiểu RAG — Hỏi & Đáp +Chuẩn bị cho phần Q&A sau buổi trình bày
+
+ +

Những câu hay được hỏi nhất

+

20 câu, xếp từ dễ tới khó. Năm câu cuối là về chính dự án Cowork-Local — +nhóm câu này gần như chắc chắn sẽ có người hỏi.

+ +
Nội dung +
    +
  1. RAG là gì, nói gọn trong một câu?
  2. +
  3. RAG khác fine-tuning thế nào?
  4. +
  5. RAG có xoá hết bịa đặt không?
  6. +
  7. Vector là gì mà so sánh được nghĩa?
  8. +
  9. Chia đoạn bao nhiêu chữ là đúng?
  10. +
  11. Overlap để làm gì?
  12. +
  13. top-K nên đặt bao nhiêu?
  14. +
  15. Chọn mô hình embedding thế nào? Tiếng Việt thì sao?
  16. +
  17. Bắt buộc phải có Vector DB không?
  18. +
  19. Chỉ tìm theo vector đã đủ chưa?
  20. +
  21. Câu hỏi cần nối nhiều tài liệu thì sao?
  22. +
  23. Context window đã 1 triệu token, còn cần RAG?
  24. +
  25. Chi phí thực tế bao nhiêu?
  26. +
  27. RAG làm chậm bao nhiêu?
  28. +
  29. Tài liệu sửa thì cập nhật thế nào?
  30. +
  31. Đo chất lượng RAG bằng gì?
  32. +
  33. Phân quyền tài liệu xử lý ra sao?
  34. +
  35. Cowork-Local đã có RAG chưa?
  36. +
  37. GraphRAG của dự án có phải GraphRAG của Microsoft?
  38. +
  39. Muốn nâng lên RAG đầy đủ cần làm gì?
  40. +
+ +

Nhóm 1 — Khái niệm

+ +
1 +RAG là gì, nói gọn trong một câu?
+
+

Tìm tài liệu liên quan trước, rồi đưa cho LLM đọc và trả lời dựa trên đó — thay vì +để LLM trả lời bằng trí nhớ có sẵn.

+

Ví von: thay vì bắt thí sinh làm bài từ trí nhớ, ta cho thi mở sách — nhưng có +thủ thư lật sẵn đúng trang cần đọc.

+
+ +
2 +RAG khác fine-tuning thế nào? Khi nào dùng cái nào?
+
+
+ + +RAG — đưa thêm tài liệu + +Câu hỏi + + +Tìm tài liệu +top-K đoạn + + +LLM +✔ Cập nhật tức thì — chỉ re-index +✔ Trích được nguồn +✔ Rẻ, không cần GPU train + +Fine-tune — dạy lại mô hình + +Dữ liệu mẫu +hàng nghìn cặp + + +Huấn luyện + + +Model mới +✔ Dạy được văn phong, định dạng +✔ Dạy được kỹ năng chuyên ngành +✘ Kiến thức mới → phải train lại + + + +
RAG thêm kiến thức. Fine-tune thay đổi hành vi.
+
+

Quy tắc chọn: câu trả lời phụ thuộc nội dung tài liệu → RAG. +Phụ thuộc cách nói / định dạng / kỹ năng → fine-tune. Cần cả hai thì làm cả hai.

+
Đa số bài toán doanh nghiệp là loại thứ nhất, nên RAG hầu như luôn là +bước làm trước.
+
+ +
3 +RAG có xoá hết bịa đặt (hallucination) không?
+
+

Không. Chỉ giảm mạnh. Đây là câu dễ bị hỏi vặn nhất, nên trả lời thẳng.

+

RAG vẫn sai được ở bốn chỗ:

+
    +
  • Tra sai đoạn — lấy nhầm tài liệu, LLM trả lời trung thực trên tài liệu sai.
  • +
  • Không có trong kho — LLM vẫn cố trả lời thay vì nói "không tìm thấy".
  • +
  • Đọc đúng nhưng suy diễn thêm — thêm chi tiết không có trong đoạn trích.
  • +
  • Tài liệu gốc đã sai — RAG không kiểm chứng nội dung.
  • +
+
Cách khắc phục thực dụng: bắt LLM trích dẫn đoạn nguồn cho từng ý, +và cho phép trả lời "không tìm thấy trong tài liệu". Slide "Ưu điểm" nên nói +giảm hallucination, không nói hết.
+
+ +
4 +Vector là gì mà so sánh được "nghĩa giống nhau"?
+
+
+ + + + +"Xe hơi" +"Ô tô" +"Xe bốn bánh" + +"Nấu phở" +"Công thức bún" + + +câu hỏi của user + +gần → lấy + +xa → bỏ qua + +
Mỗi đoạn chữ thành một điểm trong không gian nhiều chiều. +Gần nhau = gần nghĩa.
+
+

Mô hình embedding biến một đoạn chữ thành dãy số (768 – 4096 chiều). Nó được huấn luyện +sao cho hai đoạn cùng nghĩa cho ra hai điểm gần nhau, kể cả khi không trùng một chữ nào.

+

Máy đo "gần" bằng cosine similarity — góc giữa hai vector. Nhờ vậy hỏi "xe hơi" +vẫn tìm ra tài liệu viết "ô tô".

+
Đây chính là điểm RAG hơn tìm kiếm từ khoá: từ khoá cần trùng chữ, +vector chỉ cần trùng nghĩa.
+
+ +

Nhóm 2 — Tham số kỹ thuật

+ +
5 +Chia đoạn bao nhiêu chữ là đúng?
+
+

Không có con số đúng chung — phụ thuộc loại tài liệu. Nhưng có nguyên tắc:

+ + + + + + +
Loại tài liệuCỡ đoạn gợi ýVì sao
FAQ, hỏi đáp ngắn100 – 300 chữMỗi mục vốn đã độc lập
Chính sách, quy trình300 – 600 chữGiữ trọn một điều khoản
Sách, báo cáo dài500 – 1000 chữCần đủ ngữ cảnh xung quanh
Mã nguồntheo hàm / lớpCắt giữa hàm là hỏng nghĩa
+
Đoạn quá nhỏ → mất ngữ cảnh, tra ra mảnh vụn vô nghĩa. +Đoạn quá lớn → một đoạn chứa nhiều chủ đề, vector bị "trung bình hoá" nên tra kém chính xác, +lại tốn token.
+

Thực tế nên cắt theo cấu trúc trước (theo mục, theo điều, theo hàm) rồi mới giới hạn +độ dài — cắt cứng theo số chữ là phương án cuối.

+
+ +
6 +Overlap 10–20% để làm gì?
+
+
+ +Không overlap — câu bị cắt đôi + + + +đoạn 1 +đoạn 2 +đoạn 3 + +"Mức phụ cấp là | 2 triệu/tháng" — mất vế sau +Có overlap — câu nào cũng trọn ở ít nhất 1 đoạn + + + + + +phần tô đậm = chồng lấn + +
Overlap là bảo hiểm cho những câu nằm vắt ngang ranh giới đoạn.
+
+

Cắt cứng theo số chữ sẽ có lúc cắt giữa một câu hoặc giữa một ý. Đoạn nào cũng +lặp lại một phần đoạn trước thì thông tin ở ranh giới luôn còn nguyên vẹn ở ít nhất một đoạn.

+

Giá phải trả: kho phình thêm đúng bằng tỉ lệ overlap. 20% overlap → nhiều hơn ~20% vector.

+
+ +
7 +top-K nên đặt bao nhiêu?
+
+

Thường 3 – 10. Cách chọn:

+
    +
  • K nhỏ (3–5) — câu hỏi tra cứu một dữ kiện. Ít nhiễu, rẻ, nhanh.
  • +
  • K lớn (8–15) — câu hỏi tổng hợp, cần gom nhiều nguồn.
  • +
+
K càng lớn không đồng nghĩa càng chính xác. Đoạn thứ 15 thường +đã lạc đề, và nó làm loãng ngữ cảnh khiến LLM trả lời kém đi — hiện tượng +"lạc giữa đống tài liệu".
+

Thực dụng hơn: đặt ngưỡng điểm tương đồng thay vì K cố định — lấy mọi đoạn trên +ngưỡng, không có đoạn nào đạt thì trả lời "không tìm thấy".

+
+ +
8 +Chọn mô hình embedding thế nào? Tiếng Việt có ổn không?
+
+

Ba tiêu chí: hỗ trợ tiếng Việt, số chiều, chạy nội bộ hay gọi API.

+ + + + + + + + +
NhómVí dụGhi chú
API thương mạiOpenAI text-embedding-3, CohereChất lượng tốt, nhưng tài liệu phải gửi ra ngoài
Đa ngữ, chạy nội bộmultilingual-e5, BGE-M3Tiếng Việt khá tốt, chạy được trên máy công ty
Chuyên tiếng ViệtPhoBERT và các bản fine-tuneCần đánh giá lại trên chính dữ liệu của mình
+
Lưu ý bắt buộc: đổi mô hình embedding thì +phải index lại toàn bộ kho. Vector của mô hình này không so sánh được với vector của +mô hình khác. Nên chọn kỹ ngay từ đầu.
+

Với dữ liệu nội bộ nhạy cảm, nhóm "chạy nội bộ" thường là lựa chọn duy nhất khả thi.

+
+ +
9 +Bắt buộc phải có Vector DB riêng không?
+
+

Không. Chọn theo quy mô:

+ + + + + + + + + + +
Quy môGiải phápGhi chú
< 100k vectorFAISS, Chroma, hoặc file numpyKhông cần dựng thêm dịch vụ
Đã có PostgreSQLpgvectorDùng luôn DB sẵn có — thường là lựa chọn tốt nhất
Triệu vector trở lênMilvus, Qdrant, WeaviateCần index ANN chuyên dụng
Không muốn tự vận hànhPineconeDịch vụ đám mây, dữ liệu ra ngoài
+

Ví dụ trong slide — 100 file PDF ra 20.000 vector — hoàn toàn không cần Vector DB +chuyên dụng. FAISS trên một máy là đủ và nhanh.

+
+ +

Nhóm 3 — Chất lượng truy hồi

+ +
10 +Chỉ tìm theo vector đã đủ chưa?
+
+
+ + +Câu hỏi +của user + + +Tìm theo vector +bắt được ý nghĩa + +Tìm theo từ khoá +bắt mã, tên riêng + + + +Gộp kết quả +~30 đoạn + + +Rerank +chấm lại điểm + + +Top 5 tinh +đưa cho LLM + + + +
Hai cách tìm bù khuyết cho nhau, rồi lọc lại một lần nữa.
+
+

Chưa đủ. Vector giỏi bắt ý nghĩa nhưng dở với mã số, tên riêng, ký hiệu — +hỏi "điều 7.5.3" hay "mã lỗi FN0101" thì tìm từ khoá lại chính xác hơn hẳn.

+

Hai cải tiến gần như luôn đáng làm:

+
    +
  • Hybrid search — chạy song song vector + từ khoá (BM25), gộp kết quả.
  • +
  • Rerank — lấy ~30 đoạn rồi dùng mô hình cross-encoder chấm lại, giữ 5 đoạn tốt nhất. +Đây thường là cải thiện lớn nhất với chi phí nhỏ nhất.
  • +
+
+ +
11 +Câu hỏi cần nối nhiều tài liệu (multi-hop) thì sao?
+
+

Slide đã nêu đúng đây là điểm yếu. Ví dụ: "Nhân viên nào ký hợp đồng với nhà cung cấp +có doanh số cao nhất năm ngoái?" — cần tra bảng doanh số trước, rồi mới tra hợp đồng.

+

RAG một lượt sẽ hỏng, vì một lần tra không thể ra cả hai. Ba hướng xử lý:

+
    +
  • Tra nhiều vòng (agentic RAG) — cho LLM tự quyết định tra tiếp, dùng kết quả vòng +trước làm câu truy vấn vòng sau.
  • +
  • Tách câu hỏi — chia thành các câu con, tra từng câu, rồi tổng hợp.
  • +
  • Knowledge graph — dựng sẵn quan hệ giữa các thực thể để đi theo liên kết thay vì +tra lại từ đầu. Đây chính là ý tưởng của GraphRAG.
  • +
+
+ +

Nhóm 4 — Vận hành

+ +
12 +Context window đã tới 1 triệu token — còn cần RAG không?
+
+

Vẫn cần, vì ba lý do:

+
    +
  • Chi phí — nhét 500k token vào mỗi câu hỏi thì mỗi lượt hỏi tốn gấp hàng trăm lần +so với nhét 5 đoạn. Nhân với số lượt hỏi mỗi ngày.
  • +
  • Độ trễ — đọc 500k token mất hàng chục giây.
  • +
  • Quy mô — kho tài liệu doanh nghiệp thường vài chục triệu token, vượt xa mọi +context window.
  • +
+
Thêm nữa, độ chính xác giảm khi ngữ cảnh quá dài — mô hình hay bỏ sót +thông tin nằm ở giữa. Đưa 5 đoạn đúng thường cho kết quả tốt hơn đưa cả cuốn sách.
+

Context dài có chỗ dùng: khi tổng tài liệu nhỏ (vài chục trang) và bạn muốn giải pháp +đơn giản nhất — lúc đó bỏ RAG cho gọn là hợp lý.

+
+ +
13 +Chi phí thực tế bao nhiêu?
+
+

Tách làm hai phần, và phần đắt không phải phần người ta hay lo:

+ + + + + + + + +
KhoảnKhi nào phát sinhMức độ
Embedding tài liệuMột lần lúc index + khi tài liệu đổiRẻ — embedding rẻ hơn LLM hàng chục lần
Lưu trữ vectorLiên tụcNhỏ, trừ khi kho cực lớn
Embedding câu hỏiMỗi lượt hỏiKhông đáng kể
LLM sinh câu trả lờiMỗi lượt hỏiChiếm phần lớn chi phí
+

Vì vậy giảm chi phí RAG thực chất là giảm số token đưa vào LLM — tức chọn top-K +gọn và đoạn sạch, chứ không phải tiết kiệm ở khâu embedding.

+
+ +
14 +RAG làm chậm thêm bao nhiêu?
+
+

Bước tra thường tốn vài chục tới vài trăm mili-giây: embedding câu hỏi + tìm trong +vector DB. Có rerank thì cộng thêm chút nữa.

+

So với thời gian LLM sinh câu trả lời (thường vài giây), phần này gần như không đáng kể.

+
Slide ghi "ứng dụng real-time cần < 100ms" thì nên cẩn trọng — +đúng, nhưng lúc đó nút thắt là LLM, không phải bước tra. Nếu cần dưới 100ms thì +bản thân việc gọi LLM đã không khả thi rồi.
+
+ +
15 +Tài liệu sửa thì cập nhật thế nào?
+
+

Chỉ cần index lại phần thay đổi, không đụng tới mô hình:

+
    +
  • File sửa → xoá vector cũ của file đó, embedding lại, ghi vector mới.
  • +
  • File xoá → xoá vector tương ứng.
  • +
  • File mới → embedding và thêm vào.
  • +
+

Cách làm thực dụng: lưu kèm hash nội dung mỗi file, chạy định kỳ, chỉ xử lý file +có hash đổi. Vài giây cho một lần cập nhật thông thường.

+
Ngoại lệ duy nhất phải làm lại toàn bộ: đổi mô hình embedding +hoặc đổi cách chia đoạn.
+
+ +
16 +Đo chất lượng RAG bằng gì? Làm sao biết là tốt?
+
+

Điểm mấu chốt: đo tách hai khâu, vì hỏng ở đâu thì sửa ở đó khác nhau.

+ + + + + + +
KhâuĐo gìHỏng thì sửa gì
Truy hồiĐoạn đúng có nằm trong top-K không?Chia đoạn, mô hình embedding, hybrid, rerank
Sinh câu trả lờiCâu trả lời có bám vào đoạn đã lấy không?Prompt, model, yêu cầu trích nguồn
+

Cách làm tối thiểu mà hiệu quả: dựng bộ 50–100 câu hỏi mẫu có đáp án đúng lấy từ +người dùng thật. Mỗi lần chỉnh tham số thì chạy lại bộ đó và so điểm.

+
Không có bộ câu hỏi mẫu thì mọi tinh chỉnh chỉ là cảm tính — đây là việc +nên làm ngay từ đầu, trước cả khi tối ưu.
+
+ +
17 +Phân quyền tài liệu xử lý ra sao? Người A không được xem tài liệu của phòng B.
+
+

Đây là câu hay bị bỏ quên tới lúc triển khai thật mới lộ ra.

+

Nguyên tắc: lọc quyền ở bước truy hồi, không phải ở bước trả lời. Tuyệt đối không +dựa vào việc nhắc LLM "đừng nói về tài liệu này" — không đáng tin.

+
    +
  • Mỗi vector lưu kèm metadata quyền (phòng ban, mức mật, danh sách người xem).
  • +
  • Khi tra, lọc theo quyền của người hỏi ngay trong truy vấn.
  • +
  • Tài liệu ngoài quyền thì không bao giờ vào được ngữ cảnh của LLM.
  • +
+
Rủi ro thường gặp: một đoạn trích chứa thông tin mật lọt vào ngữ cảnh, +LLM tóm tắt lại và rò rỉ gián tiếp dù không trích nguyên văn.
+
+ +

Nhóm 5 — Về dự án Cowork-Local

+

Nhóm này gần như chắc chắn được hỏi, vì slide 17 đã tự nêu ra.

+ +
18 +Vậy Cowork-Local đã có RAG chưa?
+
+

Trả lời thẳng như slide 17 đã viết: chưa có RAG theo nghĩa đầy đủ.

+
+ + + +Mức 1 — Nạp thủ công +Đính kèm file, dán link, +Instructions của project +Người dùng tự chọn + + +Mức 2 — Tra theo cấu trúc +GraphRAG: sơ đồ file, +lớp, hàm, quan hệ +AI tự tra — đang ở đây + + +Mức 3 — Tra theo ngữ nghĩa +Embedding + Vector DB +tìm theo nghĩa +chưa có + +
Dự án đang ở mức 2. Mức 3 mới là RAG như trình bày ở phần đầu.
+
+

Cách nói an toàn khi bị hỏi vặn: "Hiện tại là truy xuất theo cấu trúc, chưa phải truy xuất +theo ngữ nghĩa. Phần trình bày hôm nay là kiến thức nền cho bước tiếp theo."

+
+ +
19 +"GraphRAG" của dự án có phải GraphRAG của Microsoft không?
+
+
Câu này rất dễ bị hỏi và dễ gây hiểu nhầm — nên chủ động làm rõ trước.
+ + + + + + + + +
GraphRAG (Microsoft)GraphRAG trong Cowork-Local
Đồ thị chứa gìThực thể và quan hệ do LLM trích từ nội dungFile, lớp, hàm và liên kết import
Dựng bằng gìGọi LLM nhiều lượt, tốn chi phíPhân tích cú pháp mã nguồn, không tốn phí gọi AI
Trả lời câu hỏiĐi theo quan hệ + tóm tắt theo cụmĐọc sơ đồ và nội dung file liên quan
+

Cùng tên, khác bản chất. Slide của bạn mô tả đúng cái thứ hai — "quét file, ghi nhận +mỗi file có class/hàm gì và liên kết với file nào".

+

Nói rõ điểm này lại là lợi thế: cách của dự án rẻ và nhanh hơn nhiều vì không +phải gọi LLM để dựng đồ thị.

+
+ +
20 +Muốn nâng lên RAG đầy đủ thì cần làm gì?
+
+

Bốn việc, xếp theo thứ tự nên làm:

+ + + + + + + + + + +
#ViệcQuyết định phải chốt
1Chọn mô hình embeddingChạy nội bộ hay gọi API — quyết định này ràng buộc mọi thứ sau, và +đổi về sau là phải index lại toàn bộ
2Chia đoạn tài liệuCắt theo cấu trúc (mục, điều, hàm) trước khi cắt theo độ dài
3Chọn nơi lưu vectorQuy mô hiện tại chỉ cần FAISS hoặc pgvector
4Dựng bộ câu hỏi đánh giá50–100 câu có đáp án đúng — làm trước khi tối ưu
+
Điểm mạnh sẵn có: dự án đã có sẵn khái niệm project với +thư mục riêng và phân tách dữ liệu theo project. Đó chính là ranh giới phân quyền tự nhiên +cho câu 17 — thứ mà nhiều dự án phải làm lại từ đầu.
+
+ +

Ba câu nên chuẩn bị sẵn câu trả lời

+
1. "RAG có hết bịa không?" → Không, chỉ giảm. Nói thẳng và nêu +cách giảm: bắt trích nguồn, cho phép trả lời "không tìm thấy".
+
2. "GraphRAG này có phải GraphRAG kia không?" → Không, cùng tên +khác bản chất. Chủ động nói trước khi bị hỏi.
+
3. "Vậy dự án đã có RAG chưa?" → Chưa đủ. Đang ở mức truy xuất +theo cấu trúc, chưa có truy xuất theo ngữ nghĩa.
+ +
+ + diff --git a/docs/screens/controls.json b/docs/screens/controls.json new file mode 100644 index 0000000..c405af3 --- /dev/null +++ b/docs/screens/controls.json @@ -0,0 +1,4208 @@ +[ + { + "file": "ui\\accounts_tab.py", + "controls": [ + { + "var": "self.user_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "account.username if account else ''", + "line": 108, + "signals": [], + "object_name": "" + }, + { + "var": "self.name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "account.display_name if account else ''", + "line": 111, + "signals": [], + "object_name": "" + }, + { + "var": "self.email_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "account.email if account else ''", + "line": 113, + "signals": [], + "object_name": "" + }, + { + "var": "self.dept_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "account.department if account else ''", + "line": 125, + "signals": [], + "object_name": "" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Save | QDialogButtonBox.Cancel", + "line": 137, + "signals": [], + "object_name": "" + }, + { + "var": "self.search_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "keyword", + "line": 176, + "signals": [ + "textChanged → self._apply_tree_filter" + ], + "object_name": "" + }, + { + "var": "self.ai_search_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('accounts.ai_search_btn')", + "line": 178, + "signals": [ + "clicked → self._ai_search" + ], + "object_name": "", + "label_vi": "AI" + }, + { + "var": "self.group_filter_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 188, + "signals": [ + "currentIndexChanged → lambda _i: self._apply_tree_filter()" + ], + "object_name": "" + }, + { + "var": "self.add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('accounts.add_btn')", + "line": 202, + "signals": [ + "clicked → self._add_account" + ], + "object_name": "", + "label_vi": "Thêm" + }, + { + "var": "self.edit_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('accounts.edit_btn')", + "line": 205, + "signals": [ + "clicked → self._edit_account" + ], + "object_name": "", + "label_vi": "Sửa" + }, + { + "var": "self.del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('accounts.delete_btn')", + "line": 208, + "signals": [ + "clicked → self._delete_account" + ], + "object_name": "", + "label_vi": "Xóa" + }, + { + "var": "self.code_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('accounts.generate_code_btn')", + "line": 211, + "signals": [ + "clicked → self._regenerate_code" + ], + "object_name": "", + "label_vi": "Tạo mã" + }, + { + "var": "self.group_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('accounts.new_group_btn')", + "line": 221, + "signals": [ + "clicked → self._add_group" + ], + "object_name": "", + "label_vi": "Nhóm mới" + }, + { + "var": "self.excel_template_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('accounts.excel_template_btn')", + "line": 230, + "signals": [ + "clicked → self._export_excel_template" + ], + "object_name": "", + "label_vi": "Mẫu Excel" + }, + { + "var": "self.excel_import_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('accounts.excel_import_btn')", + "line": 233, + "signals": [ + "clicked → self._import_excel" + ], + "object_name": "", + "label_vi": "Nhập từ Excel" + }, + { + "var": "self.period_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 251, + "signals": [ + "currentIndexChanged → self.refresh" + ], + "object_name": "" + }, + { + "var": "self.refresh_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.refresh')", + "line": 256, + "signals": [ + "clicked → self.refresh" + ], + "object_name": "", + "label_vi": "Làm mới" + }, + { + "var": "self.usage_table", + "type": "QTableWidget", + "kind": "bảng", + "label": "0", + "line": 262, + "signals": [], + "object_name": "" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\agent_manager_tab.py", + "controls": [ + { + "var": "del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agentmgr.delete_btn')", + "line": 52, + "signals": [ + "clicked → self._delete" + ], + "object_name": "", + "label_vi": "Xóa" + }, + { + "var": "self.name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "agent.name", + "line": 62, + "signals": [], + "object_name": "" + }, + { + "var": "self.desc_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "agent.description", + "line": 63, + "signals": [], + "object_name": "" + }, + { + "var": "self.provider_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 68, + "signals": [ + "currentIndexChanged → self._reload_models" + ], + "object_name": "" + }, + { + "var": "self._gen_prompt_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agentmgr.gen_prompt_btn')", + "line": 88, + "signals": [ + "clicked → self._gen_prompt" + ], + "object_name": "", + "label_vi": "Tạo prompt từ mô tả" + }, + { + "var": "new_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agentmgr.new_btn')", + "line": 107, + "signals": [ + "clicked → self._new_agent" + ], + "object_name": "", + "label_vi": "Agent mới" + }, + { + "var": "save_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agentmgr.save_btn')", + "line": 110, + "signals": [ + "clicked → self._save" + ], + "object_name": "primary", + "label_vi": "Lưu agent" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\agents_admin_tab.py", + "controls": [ + { + "var": "self.name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "agent.name if agent else ''", + "line": 55, + "signals": [], + "object_name": "" + }, + { + "var": "self.prompt_edit", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "agent.prompt if agent else ''", + "line": 65, + "signals": [], + "object_name": "" + }, + { + "var": "self.provider_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 70, + "signals": [ + "currentIndexChanged → self._refresh_model_combo" + ], + "object_name": "" + }, + { + "var": "self.load_models_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agents_admin.load_models_tooltip')", + "line": 89, + "signals": [ + "clicked → self._load_live_models" + ], + "object_name": "", + "label_vi": "Lấy danh sách model thực tế của provider này để chọn từ dropdown." + }, + { + "var": "self.enabled_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('agents_admin.f_enabled')", + "line": 98, + "signals": [], + "object_name": "", + "label_vi": "Kích hoạt" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Save | QDialogButtonBox.Cancel", + "line": 101, + "signals": [], + "object_name": "" + }, + { + "var": "self.table", + "type": "QTableWidget", + "kind": "bảng", + "label": "0", + "line": 169, + "signals": [], + "object_name": "" + }, + { + "var": "self.add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agents_admin.add_btn')", + "line": 178, + "signals": [ + "clicked → self._add" + ], + "object_name": "primary", + "label_vi": "Thêm" + }, + { + "var": "self.edit_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agents_admin.edit_btn')", + "line": 182, + "signals": [ + "clicked → self._edit" + ], + "object_name": "", + "label_vi": "Sửa" + }, + { + "var": "self.del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agents_admin.delete_btn')", + "line": 185, + "signals": [ + "clicked → self._delete" + ], + "object_name": "", + "label_vi": "Xóa" + }, + { + "var": "self.check_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agents_admin.check_btn')", + "line": 188, + "signals": [ + "clicked → self._check_all" + ], + "object_name": "", + "label_vi": "Kiểm tra" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\calendar_view.py", + "controls": [ + { + "var": "self.add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "'+'", + "line": 43, + "signals": [ + "clicked → lambda: self.add_requested.emit(self._date_str)" + ], + "object_name": "" + }, + { + "var": "self.list", + "type": "QListWidget", + "kind": "danh sách", + "label": "", + "line": 49, + "signals": [ + "itemClicked → self._on_item_clicked" + ], + "object_name": "" + }, + { + "var": "self.prev_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.cal_prev')", + "line": 100, + "signals": [ + "clicked → lambda: self._shift(-1)" + ], + "object_name": "", + "label_vi": "Trước" + }, + { + "var": "self.today_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.cal_today')", + "line": 103, + "signals": [ + "clicked → self._go_today" + ], + "object_name": "", + "label_vi": "Hôm nay" + }, + { + "var": "self.next_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.cal_next')", + "line": 105, + "signals": [ + "clicked → lambda: self._shift(1)" + ], + "object_name": "", + "label_vi": "Sau" + }, + { + "var": "self.granularity_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 110, + "signals": [ + "currentIndexChanged → self._on_granularity_changed" + ], + "object_name": "" + }, + { + "var": "lst", + "type": "QListWidget", + "kind": "danh sách", + "label": "", + "line": 213, + "signals": [ + "itemClicked → lambda item: self.show_month(self.anchor.year, item.data(Qt.UserRole))" + ], + "object_name": "" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\chat_panel.py", + "controls": [ + { + "var": "self.agent_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('chatpanel.agent_tooltip')", + "line": 165, + "signals": [ + "currentIndexChanged → self._on_agent_changed" + ], + "object_name": "", + "label_vi": "Model/agent riêng cho tab này — độc lập với tab kia" + }, + { + "var": "self.compress_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('chatpanel.compress_btn')", + "line": 177, + "signals": [ + "clicked → self._compress_messages" + ], + "object_name": "", + "label_vi": "Nén" + }, + { + "var": "self._io_collapse_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('chatpanel.collapse_files_tooltip')", + "line": 239, + "signals": [ + "clicked → lambda: self._set_io_collapsed(True)" + ], + "object_name": "", + "label_vi": "Thu gọn bảng Files" + } + ], + "menu_actions": [ + { + "menu": "menu", + "label": "app_icon('link')", + "line": 439 + }, + { + "menu": "menu", + "label": "app_icon('edit')", + "line": 440 + } + ] + }, + { + "file": "ui\\chat_view.py", + "controls": [ + { + "var": "self._head", + "type": "QPushButton", + "kind": "nút", + "label": "title", + "line": 228, + "signals": [ + "clicked → self._toggle_body" + ], + "object_name": "" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\co4e_agent_dialog.py", + "controls": [ + { + "var": "self.name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "agent.name", + "line": 32, + "signals": [], + "object_name": "" + }, + { + "var": "self.role_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "agent.role or 'AGENT'", + "line": 34, + "signals": [], + "object_name": "" + }, + { + "var": "self.instructions_edit", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "agent.instructions", + "line": 42, + "signals": [], + "object_name": "" + }, + { + "var": "self.gen_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.ai_draft')", + "line": 46, + "signals": [ + "clicked → self._ai_draft" + ], + "object_name": "", + "label_vi": "Soạn bằng AI" + }, + { + "var": "self.context_edit", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "getattr(agent, 'context', '')", + "line": 59, + "signals": [], + "object_name": "" + }, + { + "var": "self.load_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.load_models_tooltip')", + "line": 68, + "signals": [ + "clicked → self._load_models" + ], + "object_name": "", + "label_vi": "Tải danh sách model" + }, + { + "var": "attach_add", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.attach_add')", + "line": 99, + "signals": [ + "clicked → self._add_attachment" + ], + "object_name": "", + "label_vi": "Đính kèm tệp" + }, + { + "var": "attach_del", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.attach_remove')", + "line": 102, + "signals": [ + "clicked → self._del_attachment" + ], + "object_name": "", + "label_vi": "Bỏ" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Save | QDialogButtonBox.Cancel", + "line": 113, + "signals": [], + "object_name": "" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\co4e_canvas.py", + "controls": [], + "menu_actions": [ + { + "menu": "menu", + "label": "'+ Add next step'", + "line": 188 + }, + { + "menu": "menu", + "label": "'→ Connect from here'", + "line": 189 + }, + { + "menu": "menu", + "label": "'🗑 Delete step'", + "line": 190 + }, + { + "menu": "menu", + "label": "'🗑 Delete connection'", + "line": 368 + } + ] + }, + { + "file": "ui\\co4e_config_panel.py", + "controls": [ + { + "var": "self.label_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "step.label", + "line": 44, + "signals": [ + "textChanged → self._on_edit" + ], + "object_name": "" + }, + { + "var": "self.role_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "step.role", + "line": 48, + "signals": [ + "textChanged → self._on_edit" + ], + "object_name": "" + }, + { + "var": "self.instructions_edit", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "", + "line": 60, + "signals": [ + "textChanged → self._on_edit" + ], + "object_name": "" + }, + { + "var": "self.gen_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.ai_draft')", + "line": 63, + "signals": [ + "clicked → self._ai_draft" + ], + "object_name": "", + "label_vi": "Soạn bằng AI" + }, + { + "var": "self.context_edit", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "tr('co4e.f_context_placeholder')", + "line": 77, + "signals": [ + "textChanged → self._on_edit" + ], + "object_name": "", + "label_vi": "Thông tin/ngữ cảnh bổ sung cho agent hoặc step này (thêm vào prompt khi chạy)." + }, + { + "var": "self.load_models_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.load_models_tooltip')", + "line": 87, + "signals": [ + "clicked → self._load_models" + ], + "object_name": "", + "label_vi": "Tải danh sách model" + }, + { + "var": "self.perm_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 97, + "signals": [ + "currentIndexChanged → self._on_edit" + ], + "object_name": "" + }, + { + "var": "self.verify_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('co4e.f_self_verify')", + "line": 104, + "signals": [ + "toggled → self._on_edit" + ], + "object_name": "", + "label_vi": "Tự kiểm tra" + }, + { + "var": "self.rounds_spin", + "type": "QSpinBox", + "kind": "ô số", + "label": "", + "line": 106, + "signals": [ + "valueChanged → self._on_edit" + ], + "object_name": "" + }, + { + "var": "self.attach_add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.attach_add')", + "line": 125, + "signals": [ + "clicked → self._add_attachment" + ], + "object_name": "", + "label_vi": "Đính kèm tệp" + }, + { + "var": "self.attach_del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.attach_remove')", + "line": 128, + "signals": [ + "clicked → self._del_attachment" + ], + "object_name": "", + "label_vi": "Bỏ" + }, + { + "var": "self.sub_list", + "type": "QListWidget", + "kind": "danh sách", + "label": "", + "line": 141, + "signals": [ + "itemDoubleClicked → self._edit_subagent" + ], + "object_name": "" + }, + { + "var": "self.sub_add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.add_subagent')", + "line": 144, + "signals": [ + "clicked → self._add_subagent" + ], + "object_name": "", + "label_vi": "Thêm" + }, + { + "var": "self.sub_del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.del_subagent')", + "line": 147, + "signals": [ + "clicked → self._del_subagent" + ], + "object_name": "", + "label_vi": "Bỏ" + }, + { + "var": "self.run_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.run')", + "line": 159, + "signals": [ + "clicked → lambda: self.run_node.emit(self._node_id)" + ], + "object_name": "", + "label_vi": "Chạy" + }, + { + "var": "self.run_from_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.run_from_here')", + "line": 163, + "signals": [ + "clicked → lambda: self.run_from.emit(self._node_id)" + ], + "object_name": "", + "label_vi": "Chạy từ đây" + }, + { + "var": "self.del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.delete_step')", + "line": 166, + "signals": [ + "clicked → lambda: self.delete_node.emit(self._node_id)" + ], + "object_name": "danger", + "label_vi": "Xóa bước" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\co4e_tab.py", + "controls": [ + { + "var": "self._popup", + "type": "QListWidget", + "kind": "danh sách", + "label": "", + "line": 144, + "signals": [ + "itemClicked → lambda _i: self._accept()" + ], + "object_name": "" + }, + { + "var": "btn", + "type": "QPushButton", + "kind": "nút", + "label": "'×'", + "line": 341, + "signals": [ + "clicked → lambda: self._close_flow_tab_button(btn)" + ], + "object_name": "flowTabClose" + }, + { + "var": "self.wf_runbg_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.run_bg')", + "line": 459, + "signals": [ + "clicked → self._run_selected_in_background" + ], + "object_name": "", + "label_vi": "Chạy" + }, + { + "var": "self.ag_new_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.new')", + "line": 476, + "signals": [ + "clicked → self._new_agent" + ], + "object_name": "", + "label_vi": "Mới" + }, + { + "var": "self.sk_manage_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.manage_skills')", + "line": 493, + "signals": [ + "clicked → self._manage_skills" + ], + "object_name": "", + "label_vi": "Quản lý skill…" + }, + { + "var": "b", + "type": "QPushButton", + "kind": "nút", + "label": "tr(tip_key)", + "line": 502, + "signals": [ + "clicked → slot" + ], + "object_name": "" + }, + { + "var": "self.flow_bar", + "type": "QTabBar", + "kind": "dải tab", + "label": "", + "line": 556, + "signals": [ + "currentChanged → self._on_flow_tab_changed", + "tabCloseRequested → self._close_flow_tab" + ], + "object_name": "flowTabs" + }, + { + "var": "self.flow_add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "'+'", + "line": 582, + "signals": [ + "clicked → self._new_workflow" + ], + "object_name": "flowAddBtn" + }, + { + "var": "self.name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "self._wf.name", + "line": 631, + "signals": [ + "textChanged → self._on_name_changed" + ], + "object_name": "" + }, + { + "var": "self.add_step_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.add')", + "line": 636, + "signals": [ + "clicked → self._add_blank_step" + ], + "object_name": "", + "label_vi": "Thêm" + }, + { + "var": "self.save_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.save')", + "line": 639, + "signals": [ + "clicked → lambda: self._save(as_template=False)" + ], + "object_name": "primary", + "label_vi": "Lưu" + }, + { + "var": "self.mode_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('co4e.tt_mode')", + "line": 645, + "signals": [ + "currentIndexChanged → self._on_mode_changed" + ], + "object_name": "", + "label_vi": "Auto = mỗi bước tự lập kế hoạch rồi chạy · Plan = chỉ lập kế hoạch (chỉ đọc) · Manual = từng bước (bấm Bước tiếp)" + }, + { + "var": "self.run_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.run')", + "line": 650, + "signals": [ + "clicked → self._on_run_clicked" + ], + "object_name": "primary", + "label_vi": "Chạy" + }, + { + "var": "self.ws_folder_btn", + "type": "QPushButton", + "kind": "nút", + "label": "short", + "line": 693, + "signals": [ + "clicked → self._open_workspace_folder" + ], + "object_name": "" + }, + { + "var": "self.run_stop_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.stop')", + "line": 701, + "signals": [ + "clicked → self._stop_selected_run" + ], + "object_name": "danger", + "label_vi": "Dừng" + }, + { + "var": "self.run_rename_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.rename_run')", + "line": 706, + "signals": [ + "clicked → self._rename_selected_run" + ], + "object_name": "", + "label_vi": "Đổi tên" + }, + { + "var": "self.run_del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.delete_run')", + "line": 710, + "signals": [ + "clicked → self._delete_selected_run" + ], + "object_name": "", + "label_vi": "Xóa" + }, + { + "var": "self.run_clear_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.clear_done')", + "line": 714, + "signals": [ + "clicked → lambda: self.manager.clear_finished()" + ], + "object_name": "", + "label_vi": "Xóa đã xong" + }, + { + "var": "self.runs_table", + "type": "QTableWidget", + "kind": "bảng", + "label": "0", + "line": 722, + "signals": [ + "itemDoubleClicked → self._open_run_from_table", + "customContextMenuRequested → self._runs_context_menu" + ], + "object_name": "" + }, + { + "var": "self.config_toggle_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.tt_collapse_config')", + "line": 747, + "signals": [ + "clicked → self._toggle_config" + ], + "object_name": "", + "label_vi": "Thu gọn bảng cấu hình" + }, + { + "var": "self.chat_toggle_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.tt_expand_msgs')", + "line": 841, + "signals": [ + "clicked → self._toggle_messages" + ], + "object_name": "msgToggle", + "label_vi": "Mở rộng khung tin nhắn" + }, + { + "var": "self.chat_send_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.send')", + "line": 873, + "signals": [ + "clicked → self._chat_send" + ], + "object_name": "", + "label_vi": "Gửi" + } + ], + "menu_actions": [ + { + "menu": "menu", + "label": "icon('edit')", + "line": 1014 + }, + { + "menu": "menu", + "label": "icon('edit')", + "line": 1015 + }, + { + "menu": "menu", + "label": "icon('branch')", + "line": 1016 + }, + { + "menu": "menu", + "label": "icon('play')", + "line": 1017 + }, + { + "menu": "menu", + "label": "icon('trash')", + "line": 1018 + }, + { + "menu": "menu", + "label": "tr('co4e.open_run')", + "line": 1400, + "label_vi": "Mở flow" + }, + { + "menu": "menu", + "label": "tr('co4e.open_output')", + "line": 1404, + "label_vi": "Mở thư mục output" + }, + { + "menu": "menu", + "label": "tr('co4e.rename_run')", + "line": 1405, + "label_vi": "Đổi tên" + }, + { + "menu": "menu", + "label": "tr('co4e.delete_run')", + "line": 1406, + "label_vi": "Xóa" + } + ] + }, + { + "file": "ui\\composer.py", + "controls": [ + { + "var": "self.queue_list", + "type": "QListWidget", + "kind": "danh sách", + "label": "tr('composer.queue_tooltip')", + "line": 406, + "signals": [ + "itemDoubleClicked → self._remove_queue_item" + ], + "object_name": "", + "label_vi": "Nhấp đúp để xoá một tin nhắn khỏi hàng đợi" + }, + { + "var": "self.attach_list", + "type": "QListWidget", + "kind": "danh sách", + "label": "tr('composer.attachments_tooltip')", + "line": 420, + "signals": [ + "itemDoubleClicked → self._remove_attachment" + ], + "object_name": "", + "label_vi": "Bấm trên thẻ để gỡ tệp đính kèm nhầm" + }, + { + "var": "self.attach_btn", + "type": "QPushButton", + "kind": "nút", + "label": "''", + "line": 443, + "signals": [ + "clicked → self._pick_attachments" + ], + "object_name": "" + }, + { + "var": "self.send_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('composer.queue_btn') if self._busy else tr('composer.send')", + "line": 446, + "signals": [ + "clicked → self._on_submit" + ], + "object_name": "primary" + }, + { + "var": "self.stop_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('composer.stop')", + "line": 450, + "signals": [ + "clicked → self.stop_requested.emit" + ], + "object_name": "danger", + "label_vi": "Dừng" + }, + { + "var": "remove", + "type": "QPushButton", + "kind": "nút", + "label": "tr('composer.remove_tooltip')", + "line": 599, + "signals": [ + "clicked → lambda _=False, path=p: self._remove_attachment_path(path)" + ], + "object_name": "danger", + "label_vi": "Gỡ tệp này (đính kèm nhầm)" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\connectors_panel.py", + "controls": [ + { + "var": "self.paste", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('connectors.jira_paste_placeholder')", + "line": 42, + "signals": [ + "textChanged → self._on_paste" + ], + "object_name": "", + "label_vi": "Dán bất kỳ link Jira nào — tự điền Base URL" + }, + { + "var": "self.url", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "jira.get('base_url', '')", + "line": 46, + "signals": [], + "object_name": "" + }, + { + "var": "self.email", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "jira.get('email', '')", + "line": 48, + "signals": [], + "object_name": "" + }, + { + "var": "self.token", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "jira.get('api_token', '')", + "line": 49, + "signals": [], + "object_name": "" + }, + { + "var": "self.test_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('connectors.jira_test')", + "line": 58, + "signals": [ + "clicked → self._test" + ], + "object_name": "", + "label_vi": "Kiểm tra kết nối" + }, + { + "var": "self.save_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('connectors.jira_save')", + "line": 60, + "signals": [ + "clicked → self._save_close" + ], + "object_name": "primary", + "label_vi": "Lưu" + }, + { + "var": "self.connect_external_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('connectors.connect_external')", + "line": 133, + "signals": [ + "toggled → self._on_connect_external_toggled" + ], + "object_name": "", + "label_vi": "Kết nối tới connector bên ngoài" + }, + { + "var": "self.ext_tree", + "type": "QTreeWidget", + "kind": "cây", + "label": "", + "line": 144, + "signals": [ + "itemDoubleClicked → lambda *_: self._ext_edit()" + ], + "object_name": "" + }, + { + "var": "self.add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('settings.ext_add_btn')", + "line": 155, + "signals": [ + "clicked → self._ext_add" + ], + "object_name": "primary", + "label_vi": "Thêm connector…" + }, + { + "var": "self.edit_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('settings.ext_edit_btn')", + "line": 159, + "signals": [ + "clicked → self._ext_edit" + ], + "object_name": "", + "label_vi": "Sửa" + }, + { + "var": "self.del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('settings.ext_delete_btn')", + "line": 162, + "signals": [ + "clicked → self._ext_delete" + ], + "object_name": "", + "label_vi": "Xóa" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\cowork_tab.py", + "controls": [ + { + "var": "self.skills_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('cowork.skills_btn')", + "line": 30, + "signals": [ + "clicked → self._open_skills_manager" + ], + "object_name": "", + "label_vi": "Skills" + }, + { + "var": "self._new_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('cowork.new_chat')", + "line": 34, + "signals": [ + "clicked → self.new_session" + ], + "object_name": "", + "label_vi": "Cuộc trò chuyện mới" + }, + { + "var": "self.folder_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('cowork.pick_folder_btn')", + "line": 48, + "signals": [ + "clicked → self._pick_output_folder" + ], + "object_name": "", + "label_vi": "Thư mục Local…" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\dashboard_tab.py", + "controls": [ + { + "var": "self.chart_prev_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('dashboard.chart_prev')", + "line": 59, + "signals": [ + "clicked → self._chart_prev" + ], + "object_name": "", + "label_vi": "Kỳ trước" + }, + { + "var": "self.chart_next_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('dashboard.chart_next')", + "line": 67, + "signals": [ + "clicked → self._chart_next" + ], + "object_name": "", + "label_vi": "Kỳ sau" + }, + { + "var": "self.gran_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 71, + "signals": [ + "currentIndexChanged → self._on_gran_changed" + ], + "object_name": "" + }, + { + "var": "self.metric_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 75, + "signals": [ + "currentIndexChanged → self._refresh_chart" + ], + "object_name": "" + }, + { + "var": "self.currency_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('dashboard.currency_tooltip')", + "line": 84, + "signals": [ + "currentIndexChanged → self._on_currency_changed" + ], + "object_name": "", + "label_vi": "Tiền tệ hiển thị (tỉ giá USD→VND/JPY cố định, sửa được trong config)." + }, + { + "var": "self.refresh_btn", + "type": "QPushButton", + "kind": "nút", + "label": "''", + "line": 91, + "signals": [ + "clicked → self.refresh" + ], + "object_name": "" + }, + { + "var": "self.ai_analyze_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('dashboard.ai_analyze_btn')", + "line": 145, + "signals": [ + "clicked → self._ai_analyze" + ], + "object_name": "", + "label_vi": "AI phân tích" + }, + { + "var": "self.apply_strategy_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('dashboard.strategy_btn')", + "line": 150, + "signals": [ + "clicked → self._apply_saving_strategy" + ], + "object_name": "", + "label_vi": "Áp dụng chiến lược tiết kiệm" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\ext_connector_dialog.py", + "controls": [ + { + "var": "self.preset_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 35, + "signals": [ + "currentIndexChanged → self._apply_preset" + ], + "object_name": "" + }, + { + "var": "self.name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "connector.get('name', '')", + "line": 43, + "signals": [], + "object_name": "" + }, + { + "var": "self.mode_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 47, + "signals": [ + "currentIndexChanged → lambda i: self.stack.setCurrentIndex(self.mode_combo.currentData() != 'mcp_stdio')" + ], + "object_name": "" + }, + { + "var": "self.command_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "connector.get('command', '')", + "line": 59, + "signals": [], + "object_name": "" + }, + { + "var": "self.args_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "' '.join(connector.get('args', []) or [])", + "line": 62, + "signals": [], + "object_name": "" + }, + { + "var": "self.base_url_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "connector.get('base_url', '')", + "line": 69, + "signals": [], + "object_name": "" + }, + { + "var": "self.api_key_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "connector.get('api_key', '')", + "line": 72, + "signals": [], + "object_name": "" + }, + { + "var": "self.auth_header_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "connector.get('auth_header', 'Authorization')", + "line": 75, + "signals": [], + "object_name": "" + }, + { + "var": "self.auth_scheme_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "connector.get('auth_scheme', 'Bearer')", + "line": 77, + "signals": [], + "object_name": "" + }, + { + "var": "test_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('ext.test_btn')", + "line": 90, + "signals": [ + "clicked → self._test_connection" + ], + "object_name": "", + "label_vi": "Kiểm tra kết nối" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Save | QDialogButtonBox.Cancel", + "line": 103, + "signals": [], + "object_name": "" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\file_edit_dialog.py", + "controls": [ + { + "var": "self.path_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "str(p)", + "line": 66, + "signals": [], + "object_name": "" + }, + { + "var": "self.browse_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('fileedit.browse_tooltip')", + "line": 68, + "signals": [ + "clicked → self._browse" + ], + "object_name": "", + "label_vi": "Mở file khác…" + }, + { + "var": "self.reload_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('fileedit.reload_tooltip')", + "line": 73, + "signals": [ + "clicked → self._reload" + ], + "object_name": "", + "label_vi": "Tải lại từ đĩa" + }, + { + "var": "self.editor", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "tr('fileedit.pick_hint')", + "line": 84, + "signals": [], + "object_name": "", + "label_vi": "Mở một file để xem hoặc chỉnh sửa." + }, + { + "var": "self.instruction_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('fileedit.instruction_placeholder')", + "line": 94, + "signals": [ + "returnPressed → self._ai_edit" + ], + "object_name": "", + "label_vi": "Nói cho AI cách sửa file này (vd: 'sửa lỗi chính tả', 'dịch sang tiếng Anh')…" + }, + { + "var": "self.ai_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('fileedit.ai_btn')", + "line": 97, + "signals": [ + "clicked → self._ai_edit" + ], + "object_name": "", + "label_vi": "Sửa bằng AI" + }, + { + "var": "self.save_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('fileedit.save_btn')", + "line": 106, + "signals": [ + "clicked → self._save" + ], + "object_name": "primary", + "label_vi": "Lưu" + }, + { + "var": "self.close_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('fileedit.close_btn')", + "line": 110, + "signals": [ + "clicked → self.reject" + ], + "object_name": "", + "label_vi": "Đóng" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\flow_dialog.py", + "controls": [ + { + "var": "self.tabs", + "type": "QTabWidget", + "kind": "dải tab", + "label": "", + "line": 50, + "signals": [ + "currentChanged → lambda _i: self._reload_agent_picker()", + "currentChanged → lambda _i: self._reload_skill_combo()" + ], + "object_name": "" + }, + { + "var": "self.tpl_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 57, + "signals": [ + "activated → self._load_selected_template" + ], + "object_name": "" + }, + { + "var": "tpl_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.load_builtin')", + "line": 60, + "signals": [ + "clicked → self._load_builtin" + ], + "object_name": "", + "label_vi": "Tải template Req→Demo" + }, + { + "var": "new_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.new')", + "line": 63, + "signals": [ + "clicked → self._new_flow" + ], + "object_name": "", + "label_vi": "Mới" + }, + { + "var": "del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.delete_template')", + "line": 66, + "signals": [ + "clicked → self._delete_template" + ], + "object_name": "", + "label_vi": "Xóa template" + }, + { + "var": "self.name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "flow.name", + "line": 75, + "signals": [], + "object_name": "" + }, + { + "var": "self.desc_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "flow.description", + "line": 76, + "signals": [], + "object_name": "" + }, + { + "var": "up", + "type": "QPushButton", + "kind": "nút", + "label": "'↑'", + "line": 91, + "signals": [ + "clicked → lambda: self._move(-1)" + ], + "object_name": "" + }, + { + "var": "down", + "type": "QPushButton", + "kind": "nút", + "label": "'↓'", + "line": 93, + "signals": [ + "clicked → lambda: self._move(1)" + ], + "object_name": "" + }, + { + "var": "rm", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.remove_stage')", + "line": 95, + "signals": [ + "clicked → self._remove_step" + ], + "object_name": "", + "label_vi": "Xóa bước" + }, + { + "var": "self.step_name", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "step.name", + "line": 107, + "signals": [], + "object_name": "" + }, + { + "var": "self.step_hint", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "step.hint", + "line": 108, + "signals": [], + "object_name": "" + }, + { + "var": "self.step_agent", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 115, + "signals": [ + "currentIndexChanged → self._reload_step_models" + ], + "object_name": "" + }, + { + "var": "self._gen_prompt_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.gen_task_from_hint')", + "line": 129, + "signals": [ + "clicked → self._gen_prompt" + ], + "object_name": "", + "label_vi": "Tạo task từ gợi ý" + }, + { + "var": "self.attach_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.attach_files')", + "line": 144, + "signals": [ + "clicked → self._pick_attachments" + ], + "object_name": "", + "label_vi": "Đính kèm file…" + }, + { + "var": "self.compact_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('flow.compact_after_run')", + "line": 150, + "signals": [], + "object_name": "", + "label_vi": "Compact after run (nén sau khi chạy)" + }, + { + "var": "self.verify_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('flow.self_verify')", + "line": 153, + "signals": [], + "object_name": "", + "label_vi": "Self-verify trước khi bàn giao" + }, + { + "var": "self.retries_spin", + "type": "QSpinBox", + "kind": "ô số", + "label": "tr('flow.review_retries_tooltip')", + "line": 156, + "signals": [], + "object_name": "", + "label_vi": "Nếu tự kiểm tra thấy chưa hoàn thành, chạy lại bước này tối đa số lần này (0 = tắt)" + }, + { + "var": "self.sub_name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('flow.subagent_name_placeholder')", + "line": 173, + "signals": [], + "object_name": "", + "label_vi": "Tên (vd backend)" + }, + { + "var": "self.sub_prompt_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('flow.subagent_task_placeholder')", + "line": 175, + "signals": [], + "object_name": "", + "label_vi": "Nhiệm vụ của sub-agent này (tùy chọn — bỏ trống thì dùng task của bước)" + }, + { + "var": "sub_add", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.subagent_add')", + "line": 177, + "signals": [ + "clicked → self._add_subagent" + ], + "object_name": "", + "label_vi": "Thêm" + }, + { + "var": "sub_remove", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.subagent_remove')", + "line": 180, + "signals": [ + "clicked → self._remove_subagent" + ], + "object_name": "", + "label_vi": "Xóa" + }, + { + "var": "agent_add", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.subagent_add_from_agent')", + "line": 194, + "signals": [ + "clicked → self._add_subagent_from_agent" + ], + "object_name": "", + "label_vi": "Thêm từ Agent" + }, + { + "var": "add_step", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.add_stage')", + "line": 207, + "signals": [ + "clicked → self._add_step" + ], + "object_name": "primary", + "label_vi": "Thêm bước" + }, + { + "var": "upd_step", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.update_stage')", + "line": 211, + "signals": [ + "clicked → self._update_step" + ], + "object_name": "", + "label_vi": "Cập nhật bước" + }, + { + "var": "save_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.save_template')", + "line": 231, + "signals": [ + "clicked → self._save_template" + ], + "object_name": "", + "label_vi": "Lưu làm template" + }, + { + "var": "run_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.run')", + "line": 234, + "signals": [ + "clicked → self._run" + ], + "object_name": "primary", + "label_vi": "Chạy flow" + }, + { + "var": "close_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.close')", + "line": 238, + "signals": [ + "clicked → self.reject" + ], + "object_name": "", + "label_vi": "Đóng" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\folder_tab.py", + "controls": [ + { + "var": "self.path_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "self._root", + "line": 265, + "signals": [], + "object_name": "" + }, + { + "var": "self._open_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('folder.open_folder')", + "line": 267, + "signals": [ + "clicked → self._pick_root" + ], + "object_name": "primary", + "label_vi": "Mở thư mục" + }, + { + "var": "self.mode_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('folder.edit') if not self.mode_btn.isChecked() else tr('folder.preview')", + "line": 299, + "signals": [ + "clicked → self._toggle_edit_mode" + ], + "object_name": "" + }, + { + "var": "self.ai_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('folder.ai_edit')", + "line": 304, + "signals": [ + "clicked → self._toggle_ai_panel" + ], + "object_name": "", + "label_vi": "AI Edit" + }, + { + "var": "self.save_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('folder.save')", + "line": 309, + "signals": [ + "clicked → self._save" + ], + "object_name": "primary", + "label_vi": "Lưu" + }, + { + "var": "self.ext_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('folder.open_external')", + "line": 315, + "signals": [ + "clicked → self._open_external" + ], + "object_name": "", + "label_vi": "Mở bằng app ngoài" + }, + { + "var": "table", + "type": "QTableWidget", + "kind": "bảng", + "label": "len(rows)", + "line": 534, + "signals": [], + "object_name": "" + }, + { + "var": "self.ai_input", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('folder.ai_placeholder')", + "line": 736, + "signals": [ + "returnPressed → self._ai_send" + ], + "object_name": "", + "label_vi": "Mô tả chỉnh sửa… (vd: thêm xử lý lỗi)" + }, + { + "var": "self.ai_send_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('folder.ai_send')", + "line": 740, + "signals": [ + "clicked → self._ai_send" + ], + "object_name": "primary", + "label_vi": "Gửi" + }, + { + "var": "self._ai_discard_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('folder.ai_discard')", + "line": 752, + "signals": [ + "clicked → self._ai_discard" + ], + "object_name": "", + "label_vi": "Hủy" + }, + { + "var": "self._ai_apply_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('folder.ai_apply')", + "line": 755, + "signals": [ + "clicked → self._ai_apply" + ], + "object_name": "primary", + "label_vi": "Áp dụng" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\help_agent_widget.py", + "controls": [ + { + "var": "self.edge_tab", + "type": "QPushButton", + "kind": "nút", + "label": "self", + "line": 169, + "signals": [ + "clicked → self._show_launcher" + ], + "object_name": "helpEdgeTab" + }, + { + "var": "self.collapse_btn", + "type": "QPushButton", + "kind": "nút", + "label": "self", + "line": 178, + "signals": [ + "clicked → self._hide_to_edge" + ], + "object_name": "helpCollapseBtn" + }, + { + "var": "self.min_btn", + "type": "QPushButton", + "kind": "nút", + "label": "header", + "line": 214, + "signals": [ + "clicked → self._collapse" + ], + "object_name": "helpMinBtn" + }, + { + "var": "self.input", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "row", + "line": 236, + "signals": [ + "returnPressed → self._send" + ], + "object_name": "helpInput" + }, + { + "var": "self.send_btn", + "type": "QPushButton", + "kind": "nút", + "label": "row", + "line": 241, + "signals": [ + "clicked → self._send" + ], + "object_name": "helpSendBtn" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\icons.py", + "controls": [], + "menu_actions": [] + }, + { + "file": "ui\\icons_admin_tab.py", + "controls": [ + { + "var": "self.search", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('icons_admin.search')", + "line": 43, + "signals": [ + "textChanged → self._reload_builtin" + ], + "object_name": "", + "label_vi": "Tìm icon có sẵn…" + }, + { + "var": "self.add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('icons_admin.add')", + "line": 58, + "signals": [ + "clicked → self._add_icon" + ], + "object_name": "", + "label_vi": "Thêm tệp SVG" + }, + { + "var": "self.paste_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('icons_admin.paste')", + "line": 60, + "signals": [ + "clicked → self._add_from_svg_text" + ], + "object_name": "", + "label_vi": "Dán SVG" + }, + { + "var": "self.del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('icons_admin.delete')", + "line": 62, + "signals": [ + "clicked → self._delete_icon" + ], + "object_name": "", + "label_vi": "Xóa tùy chỉnh" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\libreoffice_view.py", + "controls": [ + { + "var": "self._open_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('libreoffice.open_btn')", + "line": 97, + "signals": [ + "clicked → self._open_external" + ], + "object_name": "primary", + "label_vi": "Mở bằng LibreOffice" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\login_dialog.py", + "controls": [ + { + "var": "exit_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('login.exit_btn')", + "line": 87, + "signals": [ + "clicked → self.reject" + ], + "object_name": "", + "label_vi": "Thoát" + }, + { + "var": "self.bs_dir_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "self.ctx.config.shared_dir", + "line": 121, + "signals": [], + "object_name": "" + }, + { + "var": "browse_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('login.browse')", + "line": 122, + "signals": [ + "clicked → self._bs_browse" + ], + "object_name": "", + "label_vi": "Chọn…" + }, + { + "var": "create_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('login.create_admin')", + "line": 137, + "signals": [ + "clicked → self._bs_create_admin" + ], + "object_name": "primary", + "label_vi": "Tạo tài khoản Admin" + }, + { + "var": "self.code_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "cached_code", + "line": 186, + "signals": [], + "object_name": "" + }, + { + "var": "self.department_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "self.ctx.config.auth.get('last_department', '')", + "line": 199, + "signals": [], + "object_name": "" + }, + { + "var": "login_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('login.login_btn')", + "line": 209, + "signals": [ + "clicked → lambda: self._do_login(shared_dir)" + ], + "object_name": "primary", + "label_vi": "Đăng nhập" + }, + { + "var": "offline_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('login.offline_btn', role=role)", + "line": 256, + "signals": [ + "clicked → lambda: self._finish_login(Account(username=username, role=role, code=''))" + ], + "object_name": "primary" + }, + { + "var": "retry_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('login.retry_btn')", + "line": 263, + "signals": [ + "clicked → self._retry" + ], + "object_name": "", + "label_vi": "Thử lại" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\mcp_servers_dialog.py", + "controls": [ + { + "var": "self.name", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "server.get('name', '')", + "line": 25, + "signals": [], + "object_name": "" + }, + { + "var": "self.command", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "server.get('command', '')", + "line": 30, + "signals": [], + "object_name": "" + }, + { + "var": "self.args", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "' '.join(server.get('args', []) or [])", + "line": 35, + "signals": [], + "object_name": "" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Save | QDialogButtonBox.Cancel", + "line": 39, + "signals": [], + "object_name": "" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\monitoring_tab.py", + "controls": [ + { + "var": "self.refresh_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.refresh')", + "line": 149, + "signals": [ + "clicked → self.refresh" + ], + "object_name": "", + "label_vi": "Làm mới" + }, + { + "var": "self.status_table", + "type": "QTableWidget", + "kind": "bảng", + "label": "0", + "line": 175, + "signals": [], + "object_name": "" + }, + { + "var": "search", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('monitoring.filter_placeholder')", + "line": 347, + "signals": [ + "textChanged → table.apply_filter" + ], + "object_name": "", + "label_vi": "Lọc dòng (hoặc gõ câu hỏi rồi bấm )…" + }, + { + "var": "ai_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.ai_filter_btn')", + "line": 350, + "signals": [ + "clicked → lambda: self._ai_filter(search, ai_btn)" + ], + "object_name": "", + "label_vi": "AI" + }, + { + "var": "self.ov_pricing_ccy", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 486, + "signals": [ + "currentIndexChanged → self._reload_pricing_table" + ], + "object_name": "" + }, + { + "var": "self.ov_price_import_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.pricing_import')", + "line": 496, + "signals": [ + "clicked → self._import_pricing" + ], + "object_name": "", + "label_vi": "Nhập" + }, + { + "var": "self.ov_price_export_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.pricing_export')", + "line": 498, + "signals": [ + "clicked → self._export_pricing" + ], + "object_name": "", + "label_vi": "Mẫu" + }, + { + "var": "self.ov_price_add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.pricing_add')", + "line": 500, + "signals": [ + "clicked → self._add_pricing_row" + ], + "object_name": "", + "label_vi": "Thêm" + }, + { + "var": "self.ov_price_link_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.pricing_autolink')", + "line": 502, + "signals": [ + "clicked → self._autolink_pricing" + ], + "object_name": "", + "label_vi": "Tự lấy" + }, + { + "var": "self.ov_price_del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.pricing_delete')", + "line": 504, + "signals": [ + "clicked → self._delete_pricing_row" + ], + "object_name": "", + "label_vi": "Xóa" + }, + { + "var": "self.ov_pricing_table", + "type": "QTableWidget", + "kind": "bảng", + "label": "0", + "line": 510, + "signals": [], + "object_name": "" + }, + { + "var": "self.ov_sbx_edit_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.overview_edit')", + "line": 547, + "signals": [ + "clicked → self._open_settings_and_refresh" + ], + "object_name": "", + "label_vi": "Sửa" + }, + { + "var": "self.ov_perm_edit_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.overview_edit')", + "line": 573, + "signals": [ + "clicked → self._open_settings_and_refresh" + ], + "object_name": "", + "label_vi": "Sửa" + }, + { + "var": "self.ov_view_all_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.overview_view_all')", + "line": 586, + "signals": [ + "clicked → lambda: self.tabs.setCurrentIndex(self.tabs.indexOf(self.action_page))" + ], + "object_name": "", + "label_vi": "Xem tất cả" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\osutil.py", + "controls": [], + "menu_actions": [] + }, + { + "file": "ui\\permission_dialog.py", + "controls": [], + "menu_actions": [] + }, + { + "file": "ui\\routing_toggle.py", + "controls": [ + { + "var": "self._combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('routing.toggle_tooltip')", + "line": 66, + "signals": [ + "currentIndexChanged → self._on_changed" + ], + "object_name": "", + "label_vi": "Tự động định tuyến model cho khung chat này.\nTắt: luôn dùng model đã chọn.\nTự động: tự chuyển sang model phù hợp nhất.\nThủ công: hỏi xác nhận trước khi chuyển." + }, + { + "var": "self._chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('routing.autorun_label')", + "line": 133, + "signals": [ + "toggled → self._on_toggled" + ], + "object_name": "", + "label_vi": "Tự chạy" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\schedule_task_tab.py", + "controls": [ + { + "var": "self.add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.add_btn')", + "line": 88, + "signals": [ + "clicked → self._add_task" + ], + "object_name": "primary", + "label_vi": "Thêm Task" + }, + { + "var": "self.ai_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.ai_btn')", + "line": 92, + "signals": [ + "clicked → self._ai_create" + ], + "object_name": "", + "label_vi": "AI tạo Task" + }, + { + "var": "self.view_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 95, + "signals": [ + "currentIndexChanged → self._on_view_changed" + ], + "object_name": "" + }, + { + "var": "self.table", + "type": "QTableWidget", + "kind": "bảng", + "label": "len(runs)", + "line": 436, + "signals": [ + "itemDoubleClicked → self._open_artifact" + ], + "object_name": "" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Close", + "line": 461, + "signals": [], + "object_name": "" + }, + { + "var": "self.workspace_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('schedtask.hint_workspace')", + "line": 523, + "signals": [], + "object_name": "", + "label_vi": "Project/workspace mà agent của task này sẽ chạy trong đó — áp dụng sandbox và hướng dẫn chung của project." + }, + { + "var": "self.desc_edit", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "tr('schedtask.ai_desc_ph')", + "line": 537, + "signals": [], + "object_name": "", + "label_vi": "vd: Mỗi thứ 2 lúc 9h, dùng Code đọc dữ liệu CAE mới tạo báo cáo markdown, sau đó Cowork soạn email draft gửi team." + }, + { + "var": "self.ai_files_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('schedtask.files_placeholder')", + "line": 544, + "signals": [], + "object_name": "", + "label_vi": "Đường dẫn tệp local, cách nhau bằng ;" + }, + { + "var": "ai_pick_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.pick_files')", + "line": 546, + "signals": [ + "clicked → self._ai_pick_files" + ], + "object_name": "", + "label_vi": "Chọn tệp…" + }, + { + "var": "self.ai_links_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('schedtask.links_placeholder')", + "line": 553, + "signals": [], + "object_name": "", + "label_vi": "https://… các link, cách nhau bằng ;" + }, + { + "var": "self.gen_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.ai_generate')", + "line": 557, + "signals": [ + "clicked → self._generate" + ], + "object_name": "primary", + "label_vi": "Tạo kế hoạch" + }, + { + "var": "tpl_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.export_template_btn')", + "line": 571, + "signals": [ + "clicked → self._export_template" + ], + "object_name": "", + "label_vi": "Tạo template Excel…" + }, + { + "var": "pick_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.import_pick_btn')", + "line": 576, + "signals": [ + "clicked → self._pick_import_file" + ], + "object_name": "", + "label_vi": "Chọn file…" + }, + { + "var": "self.buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Ok | QDialogButtonBox.Cancel", + "line": 592, + "signals": [], + "object_name": "" + } + ], + "menu_actions": [ + { + "menu": "menu", + "label": "tr('schedtask.menu_run')", + "line": 309, + "label_vi": "Chạy ngay" + }, + { + "menu": "menu", + "label": "tr('schedtask.menu_edit')", + "line": 310, + "label_vi": "Sửa task" + }, + { + "menu": "menu", + "label": "tr('schedtask.menu_duplicate')", + "line": 311, + "label_vi": "Nhân bản task" + }, + { + "menu": "menu", + "label": "tr('schedtask.menu_resume' if paused else 'schedtask.menu_pause')", + "line": 313 + }, + { + "menu": "menu", + "label": "tr('schedtask.menu_logs')", + "line": 314, + "label_vi": "Xem log" + }, + { + "menu": "menu", + "label": "tr('schedtask.menu_history')", + "line": 315, + "label_vi": "Lịch sử chạy…" + }, + { + "menu": "menu", + "label": "tr('schedtask.menu_create_next')", + "line": 316, + "label_vi": "Tạo task tiếp theo từ output" + }, + { + "menu": "menu", + "label": "tr('schedtask.menu_delete')", + "line": 318, + "label_vi": "Xóa task" + }, + { + "menu": "menu", + "label": "tr('schedtask.menu_delete_selected', n=len(selected))", + "line": 348 + } + ] + }, + { + "file": "ui\\settings_dialog.py", + "controls": [ + { + "var": "self.tray_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('settings.tray_keep')", + "line": 56, + "signals": [], + "object_name": "", + "label_vi": "Giữ chạy nền trong khay hệ thống khi đóng cửa sổ" + }, + { + "var": "self.notify_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('settings.tray_notify')", + "line": 59, + "signals": [], + "object_name": "", + "label_vi": "Hiện thông báo khay hệ thống khi tác vụ xong hoặc lỗi" + }, + { + "var": "self.provider_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 70, + "signals": [ + "currentIndexChanged → self._on_provider_edit_changed" + ], + "object_name": "" + }, + { + "var": "self.prov_base", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "conf.get('base_url', '')", + "line": 77, + "signals": [], + "object_name": "" + }, + { + "var": "self.sandbox_pw_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "''", + "line": 103, + "signals": [], + "object_name": "" + }, + { + "var": "self.sandbox_unlock_btn", + "type": "QPushButton", + "kind": "nút", + "label": "'Unlock'", + "line": 107, + "signals": [ + "clicked → self._sandbox_unlock" + ], + "object_name": "" + }, + { + "var": "self.sandbox_confirm", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('settings.sandbox_confirm_commands')", + "line": 121, + "signals": [], + "object_name": "", + "label_vi": "Xác nhận trước khi Cowork chạy lệnh" + }, + { + "var": "self.sandbox_block_network", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('settings.sandbox_block_network')", + "line": 126, + "signals": [], + "object_name": "", + "label_vi": "Chặn mạng cho lệnh do agent chạy" + }, + { + "var": "self.sec_enabled", + "type": "QCheckBox", + "kind": "ô tick", + "label": "'Enable Agent Security (command validation)'", + "line": 136, + "signals": [], + "object_name": "" + }, + { + "var": "self.ai_check", + "type": "QCheckBox", + "kind": "ô tick", + "label": "'AI check commands'", + "line": 142, + "signals": [], + "object_name": "" + }, + { + "var": "self.attach_files", + "type": "QSpinBox", + "kind": "ô số", + "label": "tr('settings.max_files_tooltip')", + "line": 178, + "signals": [], + "object_name": "", + "label_vi": "Số tệp tối đa đính kèm vào một tin nhắn." + }, + { + "var": "self.attach_tokens", + "type": "QSpinBox", + "kind": "ô số", + "label": "tr('settings.max_per_file_tooltip')", + "line": 183, + "signals": [], + "object_name": "", + "label_vi": "Giới hạn nội dung mỗi tệp đính kèm đưa vào prompt; phần vượt sẽ bị cắt (giảm token, tránh lỗi vượt context)." + }, + { + "var": "self.struct_nodes", + "type": "QSpinBox", + "kind": "ô số", + "label": "tr('settings.nodes_tooltip')", + "line": 194, + "signals": [], + "object_name": "", + "label_vi": "Giới hạn số node trong đồ thị Cấu trúc (0 = không giới hạn). Giá trị thấp hơn giúp quét/vẽ nhanh hơn với thư mục lớn." + }, + { + "var": "self.struct_edges", + "type": "QSpinBox", + "kind": "ô số", + "label": "tr('settings.edges_tooltip')", + "line": 200, + "signals": [], + "object_name": "", + "label_vi": "Giới hạn số cạnh trong đồ thị Cấu trúc (0 = không giới hạn)." + }, + { + "var": "self.routing_judge", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "routing.get('judge_model', '')", + "line": 279, + "signals": [], + "object_name": "" + }, + { + "var": "self.routing_reassess_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('routing.settings_reassess_now')", + "line": 282, + "signals": [ + "clicked → self._routing_reassess_now" + ], + "object_name": "", + "label_vi": "Đánh giá lại ngay" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Save | QDialogButtonBox.Cancel", + "line": 299, + "signals": [], + "object_name": "" + }, + { + "var": "edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "value", + "line": 316, + "signals": [], + "object_name": "" + }, + { + "var": "btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('settings.load')", + "line": 368, + "signals": [ + "clicked → lambda: self._load_models(self.provider_combo.currentData(), combo, status)" + ], + "object_name": "", + "label_vi": "Tải" + }, + { + "var": "test_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('settings.test_connection')", + "line": 374, + "signals": [ + "clicked → lambda: self._test_connection(self.provider_combo.currentData(), status)" + ], + "object_name": "", + "label_vi": "Test kết nối" + }, + { + "var": "code_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "code", + "line": 496, + "signals": [], + "object_name": "" + }, + { + "var": "copy_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('settings.ms365_copy_code')", + "line": 503, + "signals": [ + "clicked → lambda: QGuiApplication.clipboard().setText(code)" + ], + "object_name": "", + "label_vi": "Copy mã" + }, + { + "var": "open_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('settings.ms365_open_link')", + "line": 506, + "signals": [ + "clicked → lambda: webbrowser.open(flow.get('verification_uri_complete') or url)" + ], + "object_name": "", + "label_vi": "Mở link" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\sidebar.py", + "controls": [ + { + "var": "self._collapse_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('sidebar.collapse_tooltip')", + "line": 104, + "signals": [ + "clicked → self.collapse_requested.emit" + ], + "object_name": "", + "label_vi": "Thu gọn bảng Lịch sử" + }, + { + "var": "self.search_box", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('sidebar.search_placeholder')", + "line": 119, + "signals": [ + "textChanged → self.refresh", + "returnPressed → self.refresh" + ], + "object_name": "", + "label_vi": "Tìm theo tiêu đề hoặc nội dung…" + }, + { + "var": "self.search_btn", + "type": "QPushButton", + "kind": "nút", + "label": "''", + "line": 123, + "signals": [ + "clicked → self.refresh" + ], + "object_name": "" + }, + { + "var": "self.tree", + "type": "QTreeWidget", + "kind": "cây", + "label": "", + "line": 132, + "signals": [ + "itemClicked → self._on_item", + "customContextMenuRequested → self._context_menu" + ], + "object_name": "" + }, + { + "var": "self._refresh_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('sidebar.refresh')", + "line": 147, + "signals": [ + "clicked → self.refresh_requested.emit" + ], + "object_name": "", + "label_vi": "Làm mới" + } + ], + "menu_actions": [ + { + "menu": "menu", + "label": "tr('sidebar.menu.unpin') if pinned else tr('sidebar.menu.pin')", + "line": 305 + }, + { + "menu": "menu", + "label": "tr('sidebar.menu.rename')", + "line": 306, + "label_vi": "Đổi tên…" + }, + { + "menu": "menu", + "label": "tr('sidebar.menu.delete')", + "line": 307, + "label_vi": "Xóa" + }, + { + "menu": "menu", + "label": "tr('sidebar.menu.delete_selected', n=len(selected))", + "line": 332 + } + ] + }, + { + "file": "ui\\skill_manager_tab.py", + "controls": [ + { + "var": "self._auto_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.auto_generate')", + "line": 47, + "signals": [ + "clicked → self._auto_generate" + ], + "object_name": "primary", + "label_vi": "Tự động tạo" + }, + { + "var": "self._template_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.from_template')", + "line": 52, + "signals": [ + "clicked → self._from_template" + ], + "object_name": "", + "label_vi": "Từ file template…" + }, + { + "var": "import_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.import_btn')", + "line": 56, + "signals": [ + "clicked → self._import" + ], + "object_name": "", + "label_vi": "Nhập…" + }, + { + "var": "export_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.export_btn')", + "line": 60, + "signals": [ + "clicked → self._export_md" + ], + "object_name": "", + "label_vi": "Xuất .md" + }, + { + "var": "dup_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.duplicate_btn')", + "line": 64, + "signals": [ + "clicked → self._duplicate" + ], + "object_name": "", + "label_vi": "Nhân bản" + }, + { + "var": "edit_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.edit_btn')", + "line": 68, + "signals": [ + "clicked → self._edit" + ], + "object_name": "", + "label_vi": "Sửa" + }, + { + "var": "del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.delete_btn')", + "line": 71, + "signals": [ + "clicked → self._delete" + ], + "object_name": "", + "label_vi": "Xóa" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\skills_dialog.py", + "controls": [ + { + "var": "self.name", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "skill.name if skill else ''", + "line": 34, + "signals": [], + "object_name": "" + }, + { + "var": "self.desc", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "skill.description if skill else ''", + "line": 39, + "signals": [], + "object_name": "" + }, + { + "var": "self._gen_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.gen_from_desc')", + "line": 44, + "signals": [ + "clicked → self._gen_instructions" + ], + "object_name": "", + "label_vi": "Tạo từ mô tả" + }, + { + "var": "self.instr", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "skill.instructions if skill else ''", + "line": 50, + "signals": [], + "object_name": "" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Save | QDialogButtonBox.Cancel", + "line": 55, + "signals": [], + "object_name": "" + }, + { + "var": "self._auto_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.auto_generate')", + "line": 127, + "signals": [ + "clicked → self._auto_generate" + ], + "object_name": "primary", + "label_vi": "Tự động tạo" + }, + { + "var": "self._template_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.from_template')", + "line": 132, + "signals": [ + "clicked → self._from_template" + ], + "object_name": "", + "label_vi": "Từ file template…" + }, + { + "var": "import_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.import_btn')", + "line": 136, + "signals": [ + "clicked → self._import" + ], + "object_name": "", + "label_vi": "Nhập…" + }, + { + "var": "export_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.export_btn')", + "line": 140, + "signals": [ + "clicked → self._export_md" + ], + "object_name": "", + "label_vi": "Xuất .md" + }, + { + "var": "dup_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.duplicate_btn')", + "line": 144, + "signals": [ + "clicked → self._duplicate" + ], + "object_name": "", + "label_vi": "Nhân bản" + }, + { + "var": "edit_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.edit_btn')", + "line": 148, + "signals": [ + "clicked → self._edit" + ], + "object_name": "", + "label_vi": "Sửa" + }, + { + "var": "del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.delete_btn')", + "line": 151, + "signals": [ + "clicked → self._delete" + ], + "object_name": "", + "label_vi": "Xóa" + }, + { + "var": "close_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.close_btn')", + "line": 154, + "signals": [ + "clicked → self.accept" + ], + "object_name": "", + "label_vi": "Đóng" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\spline_chart.py", + "controls": [], + "menu_actions": [] + }, + { + "file": "ui\\structure_graph_view.py", + "controls": [ + { + "var": "self.path_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "str(ctx.config.cowork_output_dir())", + "line": 220, + "signals": [], + "object_name": "" + }, + { + "var": "self._pick_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('structure.browse')", + "line": 222, + "signals": [ + "clicked → self._pick" + ], + "object_name": "primary", + "label_vi": "Browse…" + }, + { + "var": "self.project_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('structure.project_tooltip')", + "line": 226, + "signals": [ + "currentIndexChanged → self._on_project_changed" + ], + "object_name": "", + "label_vi": "Khóa phạm vi quét vào đúng thư mục sandbox của 1 project — path chuyển sang chỉ đọc và khung hỏi-đáp Agent bên dưới sẽ theo Instructions chung của project đó (an toàn hơn, câu trả lời bám sát ngữ cảnh, giảm bịa đặt)." + }, + { + "var": "self._scan_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('structure.scan')", + "line": 228, + "signals": [ + "clicked → self._scan" + ], + "object_name": "primary", + "label_vi": "Scan" + }, + { + "var": "self._msgs_toggle_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('structure.msgs_tooltip')", + "line": 242, + "signals": [ + "clicked → self._toggle_messages" + ], + "object_name": "", + "label_vi": "Xem mọi message hội thoại nhóm theo ngày (dạng JSON)." + }, + { + "var": "self._export_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('structure.export_png')", + "line": 248, + "signals": [ + "clicked → self._export" + ], + "object_name": "primary", + "label_vi": "Xuất PNG" + }, + { + "var": "self._msgs_view", + "type": "QTreeWidget", + "kind": "cây", + "label": "", + "line": 268, + "signals": [ + "itemClicked → self._show_msg_json" + ], + "object_name": "" + }, + { + "var": "self._ag_collapse", + "type": "QPushButton", + "kind": "nút", + "label": "tr('structure.collapse_agent_tooltip')", + "line": 288, + "signals": [ + "clicked → lambda: self._set_agent_collapsed(True)" + ], + "object_name": "", + "label_vi": "Thu gọn bảng Agent" + }, + { + "var": "self.ask_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('structure.ask_placeholder')", + "line": 299, + "signals": [ + "returnPressed → self._ask" + ], + "object_name": "", + "label_vi": "vd. cái gì gọi hàm main? file nào định nghĩa class?" + }, + { + "var": "self._ask_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('structure.ask')", + "line": 301, + "signals": [ + "clicked → self._ask" + ], + "object_name": "primary", + "label_vi": "Hỏi" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\task_editor_dialog.py", + "controls": [ + { + "var": "self.title_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "self.task.get('title', '')", + "line": 96, + "signals": [], + "object_name": "" + }, + { + "var": "self.desc_edit", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "self.task.get('description', '')", + "line": 100, + "signals": [], + "object_name": "" + }, + { + "var": "self.gen_desc_btn", + "type": "QPushButton", + "kind": "nút", + "label": "''", + "line": 102, + "signals": [ + "clicked → self._gen_prompt_from_description" + ], + "object_name": "" + }, + { + "var": "self.provider_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 135, + "signals": [ + "currentIndexChanged → self._refresh_model_combo" + ], + "object_name": "" + }, + { + "var": "self.load_models_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.load_models_tooltip')", + "line": 147, + "signals": [ + "clicked → self._load_live_models" + ], + "object_name": "", + "label_vi": "Tải danh sách model của provider này" + }, + { + "var": "self.run_kind_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('schedtask.hint_run_kind')", + "line": 170, + "signals": [ + "currentIndexChanged → self._on_run_kind_changed" + ], + "object_name": "", + "label_vi": "AI agent = chạy một agent Cowork với model đã chọn. Co4E flow = chạy cả một flow node-graph đã lưu, tuần tự, trong sandbox." + }, + { + "var": "self.flow_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('schedtask.hint_flow')", + "line": 176, + "signals": [], + "object_name": "", + "label_vi": "Flow Co4E đã lưu mà task này sẽ chạy (có sẵn hoặc của bạn)." + }, + { + "var": "self.task_mode_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('schedtask.hint_task_mode')", + "line": 188, + "signals": [ + "currentIndexChanged → self._on_task_mode_changed" + ], + "object_name": "", + "label_vi": "Thông thường = chạy một lần (hoặc thủ công). Tự động = cronjob lặp theo lịch (ngày/tuần/tháng/cron). Chuyển sang Tự động sẽ hiện các tùy chọn lặp lại." + }, + { + "var": "self.sched_enabled", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('schedtask.sched_enable')", + "line": 217, + "signals": [], + "object_name": "", + "label_vi": "Bật lịch chạy" + }, + { + "var": "self.repeat_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 232, + "signals": [ + "currentIndexChanged → self._on_repeat_changed" + ], + "object_name": "" + }, + { + "var": "self.cron_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "sched.get('cron_expression') or ''", + "line": 238, + "signals": [], + "object_name": "" + }, + { + "var": "self.cron_sample", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('schedtask.cron_sample_tooltip')", + "line": 242, + "signals": [ + "currentIndexChanged → self._on_cron_sample" + ], + "object_name": "", + "label_vi": "Chọn một lịch mẫu — sẽ điền đúng cú pháp vào ô cron." + }, + { + "var": "self.working_days_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('schedtask.workdays_only')", + "line": 258, + "signals": [], + "object_name": "", + "label_vi": "Chỉ ngày làm việc (bỏ T7/CN)" + }, + { + "var": "self.skip_holidays_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('schedtask.skip_holidays')", + "line": 260, + "signals": [], + "object_name": "", + "label_vi": "Bỏ qua ngày nghỉ lễ" + }, + { + "var": "self.holiday_country_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "sched.get('holiday_country', 'VN') or 'VN'", + "line": 262, + "signals": [], + "object_name": "" + }, + { + "var": "self.notify_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 274, + "signals": [ + "currentIndexChanged → self._on_notify_changed" + ], + "object_name": "" + }, + { + "var": "self.notify_email_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "ex_sched.get('notify_email', '') or ''", + "line": 280, + "signals": [], + "object_name": "" + }, + { + "var": "self.manual_text", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "inp.get('manual_text') or ''", + "line": 313, + "signals": [], + "object_name": "" + }, + { + "var": "self.files_add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "", + "line": 324, + "signals": [ + "clicked → self._add_files" + ], + "object_name": "" + }, + { + "var": "self.files_del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "", + "line": 328, + "signals": [ + "clicked → lambda: self._remove_selected(self.files_list)" + ], + "object_name": "" + }, + { + "var": "self.links_add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "", + "line": 349, + "signals": [ + "clicked → self._add_link" + ], + "object_name": "" + }, + { + "var": "self.links_del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "", + "line": 353, + "signals": [ + "clicked → lambda: self._remove_selected(self.links_list)" + ], + "object_name": "" + }, + { + "var": "self.next_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 376, + "signals": [ + "currentIndexChanged → self._check_chain" + ], + "object_name": "" + }, + { + "var": "self.pass_output_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('schedtask.pass_output')", + "line": 385, + "signals": [], + "object_name": "", + "label_vi": "Dùng output task này làm input task sau" + }, + { + "var": "self.approval_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('schedtask.requires_approval')", + "line": 421, + "signals": [], + "object_name": "", + "label_vi": "Cần phê duyệt (không tự chạy theo lịch; chờ bấm Chạy ngay)" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Save | QDialogButtonBox.Cancel", + "line": 430, + "signals": [], + "object_name": "" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\terminal_panel.py", + "controls": [ + { + "var": "self._toggle_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('terminal.expand_tooltip') if self._collapsed else tr('terminal.collapse_tooltip')", + "line": 85, + "signals": [ + "clicked → self.toggle" + ], + "object_name": "" + }, + { + "var": "self.output", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "", + "line": 105, + "signals": [], + "object_name": "termOutput" + }, + { + "var": "self._run_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('terminal.run')", + "line": 130, + "signals": [ + "clicked → self._run_current" + ], + "object_name": "primary", + "label_vi": "Chạy" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\tools_admin_tab.py", + "controls": [ + { + "var": "chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "", + "line": 32, + "signals": [ + "toggled → on_toggle" + ], + "object_name": "" + }, + { + "var": "self.table", + "type": "QTableWidget", + "kind": "bảng", + "label": "0", + "line": 57, + "signals": [], + "object_name": "" + }, + { + "var": "self.test_internet_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('settings.test_internet')", + "line": 72, + "signals": [ + "clicked → self._test_internet" + ], + "object_name": "", + "label_vi": "Kiểm tra Internet" + }, + { + "var": "self.refresh_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('tools_admin.refresh')", + "line": 80, + "signals": [ + "clicked → self.refresh" + ], + "object_name": "", + "label_vi": "Làm mới" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\widgets.py", + "controls": [ + { + "var": "self.header", + "type": "QPushButton", + "kind": "nút", + "label": "f'{arrow} {self._title} ({self._count})'", + "line": 254, + "signals": [ + "toggled → self._toggle", + "toggled → self._toggle" + ], + "object_name": "" + }, + { + "var": "self.list", + "type": "QListWidget", + "kind": "danh sách", + "label": "", + "line": 261, + "signals": [ + "itemClicked → self._emit" + ], + "object_name": "" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\workspace_tab.py", + "controls": [ + { + "var": "self._proj_collapse_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('workspace.collapse_projects_tooltip')", + "line": 90, + "signals": [ + "clicked → lambda: self._set_projects_collapsed(True)" + ], + "object_name": "", + "label_vi": "Thu gọn danh sách project" + }, + { + "var": "self.project_list", + "type": "QListWidget", + "kind": "danh sách", + "label": "", + "line": 97, + "signals": [ + "currentItemChanged → self._on_select" + ], + "object_name": "" + }, + { + "var": "self._new_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('workspace.new_project')", + "line": 101, + "signals": [ + "clicked → self._create" + ], + "object_name": "primary", + "label_vi": "Project mới" + }, + { + "var": "self._del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('workspace.delete')", + "line": 105, + "signals": [ + "clicked → self._delete" + ], + "object_name": "", + "label_vi": "Xóa" + }, + { + "var": "self.tabs", + "type": "QTabWidget", + "kind": "dải tab", + "label": "", + "line": 129, + "signals": [ + "currentChanged → self._on_tab_changed" + ], + "object_name": "" + }, + { + "var": "self.name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "project.name", + "line": 193, + "signals": [], + "object_name": "" + }, + { + "var": "self.desc_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "project.description", + "line": 194, + "signals": [], + "object_name": "" + }, + { + "var": "self.instr_edit", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "tr('workspace.instructions_placeholder')", + "line": 203, + "signals": [], + "object_name": "", + "label_vi": "vd: \"Trả lời bằng tiếng Việt. Team đang xây tool báo cáo X; luôn theo quy tắc đặt tên …\"" + }, + { + "var": "self._browse_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('workspace.browse')", + "line": 211, + "signals": [ + "clicked → self._pick_folder" + ], + "object_name": "", + "label_vi": "Đổi thư mục…" + }, + { + "var": "self._open_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('workspace.open_folder')", + "line": 214, + "signals": [ + "clicked → self._open_workspace" + ], + "object_name": "", + "label_vi": "Mở thư mục" + }, + { + "var": "self._save_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('workspace.save')", + "line": 223, + "signals": [ + "clicked → self._save" + ], + "object_name": "primary", + "label_vi": "Lưu project" + } + ], + "menu_actions": [] + }, + { + "file": "app.py", + "controls": [ + { + "var": "self.nav", + "type": "QTreeWidget", + "kind": "cây", + "label": "", + "line": 175, + "signals": [ + "currentItemChanged → lambda cur, _prev: self._navigate(cur)", + "itemClicked → self._on_nav_click" + ], + "object_name": "navrail" + }, + { + "var": "self._nav_toggle_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('app.nav.menu_label')", + "line": 218, + "signals": [ + "clicked → self._toggle_nav" + ], + "object_name": "navMenuBtn", + "label_vi": "MENU" + }, + { + "var": "self.provider_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 559, + "signals": [ + "currentIndexChanged → self._on_provider_changed" + ], + "object_name": "" + }, + { + "var": "self.language_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 568, + "signals": [ + "currentIndexChanged → self._on_language_changed" + ], + "object_name": "" + }, + { + "var": "self.settings_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('app.settings')", + "line": 586, + "signals": [ + "clicked → self._open_settings" + ], + "object_name": "", + "label_vi": "Cài đặt" + } + ], + "menu_actions": [ + { + "menu": "menu", + "label": "self._tray_open_act", + "line": 329 + }, + { + "menu": "menu", + "label": "self._tray_quit_act", + "line": 330 + }, + { + "menu": "menu", + "label": "_icon(icon_name)", + "line": 625 + } + ] + } +] \ No newline at end of file diff --git a/docs/screens/dashboard-dark.png b/docs/screens/dashboard-dark.png new file mode 100644 index 0000000..c58bdb2 Binary files /dev/null and b/docs/screens/dashboard-dark.png differ diff --git a/docs/screens/dashboard-light.png b/docs/screens/dashboard-light.png new file mode 100644 index 0000000..768f385 Binary files /dev/null and b/docs/screens/dashboard-light.png differ diff --git a/docs/screens/dialog-agent-edit-dark.png b/docs/screens/dialog-agent-edit-dark.png new file mode 100644 index 0000000..e78624e Binary files /dev/null and b/docs/screens/dialog-agent-edit-dark.png differ diff --git a/docs/screens/dialog-agent-edit-light.png b/docs/screens/dialog-agent-edit-light.png new file mode 100644 index 0000000..d0280a3 Binary files /dev/null and b/docs/screens/dialog-agent-edit-light.png differ diff --git a/docs/screens/dialog-co4e-agent-dark.png b/docs/screens/dialog-co4e-agent-dark.png new file mode 100644 index 0000000..0156a6e Binary files /dev/null and b/docs/screens/dialog-co4e-agent-dark.png differ diff --git a/docs/screens/dialog-co4e-agent-light.png b/docs/screens/dialog-co4e-agent-light.png new file mode 100644 index 0000000..728ec3c Binary files /dev/null and b/docs/screens/dialog-co4e-agent-light.png differ diff --git a/docs/screens/dialog-ext-connector-dark.png b/docs/screens/dialog-ext-connector-dark.png new file mode 100644 index 0000000..7dcb0f2 Binary files /dev/null and b/docs/screens/dialog-ext-connector-dark.png differ diff --git a/docs/screens/dialog-ext-connector-light.png b/docs/screens/dialog-ext-connector-light.png new file mode 100644 index 0000000..9d765fa Binary files /dev/null and b/docs/screens/dialog-ext-connector-light.png differ diff --git a/docs/screens/dialog-file-edit-dark.png b/docs/screens/dialog-file-edit-dark.png new file mode 100644 index 0000000..cbf5910 Binary files /dev/null and b/docs/screens/dialog-file-edit-dark.png differ diff --git a/docs/screens/dialog-file-edit-light.png b/docs/screens/dialog-file-edit-light.png new file mode 100644 index 0000000..09779f1 Binary files /dev/null and b/docs/screens/dialog-file-edit-light.png differ diff --git a/docs/screens/dialog-login-dark.png b/docs/screens/dialog-login-dark.png new file mode 100644 index 0000000..4753366 Binary files /dev/null and b/docs/screens/dialog-login-dark.png differ diff --git a/docs/screens/dialog-login-light.png b/docs/screens/dialog-login-light.png new file mode 100644 index 0000000..fbc9a25 Binary files /dev/null and b/docs/screens/dialog-login-light.png differ diff --git a/docs/screens/dialog-permission-dark.png b/docs/screens/dialog-permission-dark.png new file mode 100644 index 0000000..5094b38 Binary files /dev/null and b/docs/screens/dialog-permission-dark.png differ diff --git a/docs/screens/dialog-permission-light.png b/docs/screens/dialog-permission-light.png new file mode 100644 index 0000000..8a79320 Binary files /dev/null and b/docs/screens/dialog-permission-light.png differ diff --git a/docs/screens/dialog-settings-dark.png b/docs/screens/dialog-settings-dark.png new file mode 100644 index 0000000..4617b94 Binary files /dev/null and b/docs/screens/dialog-settings-dark.png differ diff --git a/docs/screens/dialog-settings-light.png b/docs/screens/dialog-settings-light.png new file mode 100644 index 0000000..1a18749 Binary files /dev/null and b/docs/screens/dialog-settings-light.png differ diff --git a/docs/screens/dialog-skill-edit-dark.png b/docs/screens/dialog-skill-edit-dark.png new file mode 100644 index 0000000..d5e08e3 Binary files /dev/null and b/docs/screens/dialog-skill-edit-dark.png differ diff --git a/docs/screens/dialog-skill-edit-light.png b/docs/screens/dialog-skill-edit-light.png new file mode 100644 index 0000000..3439fff Binary files /dev/null and b/docs/screens/dialog-skill-edit-light.png differ diff --git a/docs/screens/dialog-skills-dark.png b/docs/screens/dialog-skills-dark.png new file mode 100644 index 0000000..3794709 Binary files /dev/null and b/docs/screens/dialog-skills-dark.png differ diff --git a/docs/screens/dialog-skills-light.png b/docs/screens/dialog-skills-light.png new file mode 100644 index 0000000..1519b84 Binary files /dev/null and b/docs/screens/dialog-skills-light.png differ diff --git a/docs/screens/dialog-task-editor-dark.png b/docs/screens/dialog-task-editor-dark.png new file mode 100644 index 0000000..b61ad96 Binary files /dev/null and b/docs/screens/dialog-task-editor-dark.png differ diff --git a/docs/screens/dialog-task-editor-light.png b/docs/screens/dialog-task-editor-light.png new file mode 100644 index 0000000..adc98e3 Binary files /dev/null and b/docs/screens/dialog-task-editor-light.png differ diff --git a/docs/screens/manifest.json b/docs/screens/manifest.json new file mode 100644 index 0000000..092a864 --- /dev/null +++ b/docs/screens/manifest.json @@ -0,0 +1,542 @@ +[ + { + "slug": "dashboard", + "title": "Dashboard", + "theme": "dark", + "note": "ui/dashboard_tab.py:35", + "file": "screens/dashboard-dark.png", + "error": "", + "nav": "Dashboard", + "nav_expected": "Dashboard" + }, + { + "slug": "schedule-kanban", + "title": "Schedule Task — Kanban", + "theme": "dark", + "note": "ui/schedule_task_tab.py:70", + "file": "screens/schedule-kanban-dark.png", + "error": "", + "nav": "Schedule Task", + "nav_expected": "Schedule Task" + }, + { + "slug": "schedule-calendar", + "title": "Schedule Task — Calendar", + "theme": "dark", + "note": "ui/calendar_view.py:88", + "file": "screens/schedule-calendar-dark.png", + "error": "", + "nav": "Schedule Task", + "nav_expected": "Schedule Task" + }, + { + "slug": "workspace-project", + "title": "Workspace ▸ Project", + "theme": "dark", + "note": "ui/workspace_tab.py:188", + "file": "screens/workspace-project-dark.png", + "error": "", + "nav": "Workspace", + "nav_expected": "Workspace" + }, + { + "slug": "workspace-cowork", + "title": "Workspace ▸ Cowork", + "theme": "dark", + "note": "ui/cowork_tab.py:21", + "file": "screens/workspace-cowork-dark.png", + "error": "", + "nav": "Cowork", + "nav_expected": "Cowork" + }, + { + "slug": "workspace-co4e", + "title": "Workspace ▸ Co4E", + "theme": "dark", + "note": "ui/co4e_tab.py:228", + "file": "screens/workspace-co4e-dark.png", + "error": "", + "nav": "Co4E", + "nav_expected": "Co4E" + }, + { + "slug": "workspace-folder", + "title": "Workspace ▸ Folder", + "theme": "dark", + "note": "ui/folder_tab.py:238", + "file": "screens/workspace-folder-dark.png", + "error": "", + "nav": "Thư mục", + "nav_expected": "Thư mục" + }, + { + "slug": "workspace-graphrag", + "title": "Workspace ▸ GraphRAG", + "theme": "dark", + "note": "ui/structure_graph_view.py:188", + "file": "screens/workspace-graphrag-dark.png", + "error": "", + "nav": "GraphRAG", + "nav_expected": "GraphRAG" + }, + { + "slug": "monitoring-tổng-quan", + "title": "Monitoring ▸ Tổng quan", + "theme": "dark", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-tổng-quan-dark.png", + "error": "", + "nav": "Tổng quan", + "nav_expected": "Tổng quan" + }, + { + "slug": "monitoring-sự-kiện-bảo-mật", + "title": "Monitoring ▸ Sự kiện bảo mật", + "theme": "dark", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-sự-kiện-bảo-mật-dark.png", + "error": "", + "nav": "Sự kiện bảo mật", + "nav_expected": "Sự kiện bảo mật" + }, + { + "slug": "monitoring-lịch-sử-gọi-mcp", + "title": "Monitoring ▸ Lịch sử gọi MCP", + "theme": "dark", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-lịch-sử-gọi-mcp-dark.png", + "error": "", + "nav": "Lịch sử gọi MCP", + "nav_expected": "Lịch sử gọi MCP" + }, + { + "slug": "monitoring-nhật-ký-hành-động", + "title": "Monitoring ▸ Nhật ký hành động", + "theme": "dark", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-nhật-ký-hành-động-dark.png", + "error": "", + "nav": "Nhật ký hành động", + "nav_expected": "Nhật ký hành động" + }, + { + "slug": "monitoring-trạng-thái-agent", + "title": "Monitoring ▸ Trạng thái Agent", + "theme": "dark", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-trạng-thái-agent-dark.png", + "error": "", + "nav": "Trạng thái Agent", + "nav_expected": "Trạng thái Agent" + }, + { + "slug": "monitoring-agents-admin", + "title": "Monitoring ▸ Agents Admin", + "theme": "dark", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-agents-admin-dark.png", + "error": "", + "nav": "Agents Admin", + "nav_expected": "Agents Admin" + }, + { + "slug": "monitoring-công-cụ", + "title": "Monitoring ▸ Công cụ", + "theme": "dark", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-công-cụ-dark.png", + "error": "", + "nav": "Công cụ", + "nav_expected": "Công cụ" + }, + { + "slug": "monitoring-icon", + "title": "Monitoring ▸ Icon", + "theme": "dark", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-icon-dark.png", + "error": "", + "nav": "Icon", + "nav_expected": "Icon" + }, + { + "slug": "dialog-settings", + "title": "Settings", + "theme": "dark", + "note": "ui/settings_dialog.py:26", + "file": "screens/dialog-settings-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-task-editor", + "title": "Task Editor", + "theme": "dark", + "note": "ui/task_editor_dialog.py:55", + "file": "screens/dialog-task-editor-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-skills", + "title": "Skills manager", + "theme": "dark", + "note": "ui/skills_dialog.py:108", + "file": "screens/dialog-skills-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-skill-edit", + "title": "Skill editor", + "theme": "dark", + "note": "ui/skills_dialog.py:23", + "file": "screens/dialog-skill-edit-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-file-edit", + "title": "File view & AI edit", + "theme": "dark", + "note": "ui/file_edit_dialog.py:50", + "file": "screens/dialog-file-edit-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-co4e-agent", + "title": "Co4E agent editor", + "theme": "dark", + "note": "ui/co4e_agent_dialog.py:23", + "file": "screens/dialog-co4e-agent-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-ext-connector", + "title": "External connector", + "theme": "dark", + "note": "ui/ext_connector_dialog.py:23", + "file": "screens/dialog-ext-connector-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-permission", + "title": "Permission request", + "theme": "dark", + "note": "ui/permission_dialog.py:13", + "file": "screens/dialog-permission-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-agent-edit", + "title": "Admin agent editor", + "theme": "dark", + "note": "ui/agents_admin_tab.py:35", + "file": "screens/dialog-agent-edit-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-login", + "title": "Login (dead screen — not wired)", + "theme": "dark", + "note": "ui/login_dialog.py:57", + "file": "screens/dialog-login-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "overlay-help-panel", + "title": "Help dock — expanded panel", + "theme": "dark", + "note": "ui/help_agent_widget.py:79", + "file": "screens/overlay-help-panel-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dashboard", + "title": "Dashboard", + "theme": "light", + "note": "ui/dashboard_tab.py:35", + "file": "screens/dashboard-light.png", + "error": "", + "nav": "Dashboard", + "nav_expected": "Dashboard" + }, + { + "slug": "schedule-kanban", + "title": "Schedule Task — Kanban", + "theme": "light", + "note": "ui/schedule_task_tab.py:70", + "file": "screens/schedule-kanban-light.png", + "error": "", + "nav": "Schedule Task", + "nav_expected": "Schedule Task" + }, + { + "slug": "schedule-calendar", + "title": "Schedule Task — Calendar", + "theme": "light", + "note": "ui/calendar_view.py:88", + "file": "screens/schedule-calendar-light.png", + "error": "", + "nav": "Schedule Task", + "nav_expected": "Schedule Task" + }, + { + "slug": "workspace-project", + "title": "Workspace ▸ Project", + "theme": "light", + "note": "ui/workspace_tab.py:188", + "file": "screens/workspace-project-light.png", + "error": "", + "nav": "Workspace", + "nav_expected": "Workspace" + }, + { + "slug": "workspace-cowork", + "title": "Workspace ▸ Cowork", + "theme": "light", + "note": "ui/cowork_tab.py:21", + "file": "screens/workspace-cowork-light.png", + "error": "", + "nav": "Cowork", + "nav_expected": "Cowork" + }, + { + "slug": "workspace-co4e", + "title": "Workspace ▸ Co4E", + "theme": "light", + "note": "ui/co4e_tab.py:228", + "file": "screens/workspace-co4e-light.png", + "error": "", + "nav": "Co4E", + "nav_expected": "Co4E" + }, + { + "slug": "workspace-folder", + "title": "Workspace ▸ Folder", + "theme": "light", + "note": "ui/folder_tab.py:238", + "file": "screens/workspace-folder-light.png", + "error": "", + "nav": "Thư mục", + "nav_expected": "Thư mục" + }, + { + "slug": "workspace-graphrag", + "title": "Workspace ▸ GraphRAG", + "theme": "light", + "note": "ui/structure_graph_view.py:188", + "file": "screens/workspace-graphrag-light.png", + "error": "", + "nav": "GraphRAG", + "nav_expected": "GraphRAG" + }, + { + "slug": "monitoring-tổng-quan", + "title": "Monitoring ▸ Tổng quan", + "theme": "light", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-tổng-quan-light.png", + "error": "", + "nav": "Tổng quan", + "nav_expected": "Tổng quan" + }, + { + "slug": "monitoring-sự-kiện-bảo-mật", + "title": "Monitoring ▸ Sự kiện bảo mật", + "theme": "light", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-sự-kiện-bảo-mật-light.png", + "error": "", + "nav": "Sự kiện bảo mật", + "nav_expected": "Sự kiện bảo mật" + }, + { + "slug": "monitoring-lịch-sử-gọi-mcp", + "title": "Monitoring ▸ Lịch sử gọi MCP", + "theme": "light", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-lịch-sử-gọi-mcp-light.png", + "error": "", + "nav": "Lịch sử gọi MCP", + "nav_expected": "Lịch sử gọi MCP" + }, + { + "slug": "monitoring-nhật-ký-hành-động", + "title": "Monitoring ▸ Nhật ký hành động", + "theme": "light", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-nhật-ký-hành-động-light.png", + "error": "", + "nav": "Nhật ký hành động", + "nav_expected": "Nhật ký hành động" + }, + { + "slug": "monitoring-trạng-thái-agent", + "title": "Monitoring ▸ Trạng thái Agent", + "theme": "light", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-trạng-thái-agent-light.png", + "error": "", + "nav": "Trạng thái Agent", + "nav_expected": "Trạng thái Agent" + }, + { + "slug": "monitoring-agents-admin", + "title": "Monitoring ▸ Agents Admin", + "theme": "light", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-agents-admin-light.png", + "error": "", + "nav": "Agents Admin", + "nav_expected": "Agents Admin" + }, + { + "slug": "monitoring-công-cụ", + "title": "Monitoring ▸ Công cụ", + "theme": "light", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-công-cụ-light.png", + "error": "", + "nav": "Công cụ", + "nav_expected": "Công cụ" + }, + { + "slug": "monitoring-icon", + "title": "Monitoring ▸ Icon", + "theme": "light", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-icon-light.png", + "error": "", + "nav": "Icon", + "nav_expected": "Icon" + }, + { + "slug": "dialog-settings", + "title": "Settings", + "theme": "light", + "note": "ui/settings_dialog.py:26", + "file": "screens/dialog-settings-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-task-editor", + "title": "Task Editor", + "theme": "light", + "note": "ui/task_editor_dialog.py:55", + "file": "screens/dialog-task-editor-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-skills", + "title": "Skills manager", + "theme": "light", + "note": "ui/skills_dialog.py:108", + "file": "screens/dialog-skills-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-skill-edit", + "title": "Skill editor", + "theme": "light", + "note": "ui/skills_dialog.py:23", + "file": "screens/dialog-skill-edit-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-file-edit", + "title": "File view & AI edit", + "theme": "light", + "note": "ui/file_edit_dialog.py:50", + "file": "screens/dialog-file-edit-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-co4e-agent", + "title": "Co4E agent editor", + "theme": "light", + "note": "ui/co4e_agent_dialog.py:23", + "file": "screens/dialog-co4e-agent-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-ext-connector", + "title": "External connector", + "theme": "light", + "note": "ui/ext_connector_dialog.py:23", + "file": "screens/dialog-ext-connector-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-permission", + "title": "Permission request", + "theme": "light", + "note": "ui/permission_dialog.py:13", + "file": "screens/dialog-permission-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-agent-edit", + "title": "Admin agent editor", + "theme": "light", + "note": "ui/agents_admin_tab.py:35", + "file": "screens/dialog-agent-edit-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-login", + "title": "Login (dead screen — not wired)", + "theme": "light", + "note": "ui/login_dialog.py:57", + "file": "screens/dialog-login-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "overlay-help-panel", + "title": "Help dock — expanded panel", + "theme": "light", + "note": "ui/help_agent_widget.py:79", + "file": "screens/overlay-help-panel-light.png", + "error": "", + "nav": "", + "nav_expected": "" + } +] \ No newline at end of file diff --git a/docs/screens/monitoring-agents-admin-dark.png b/docs/screens/monitoring-agents-admin-dark.png new file mode 100644 index 0000000..ccddefd Binary files /dev/null and b/docs/screens/monitoring-agents-admin-dark.png differ diff --git a/docs/screens/monitoring-agents-admin-light.png b/docs/screens/monitoring-agents-admin-light.png new file mode 100644 index 0000000..b631d64 Binary files /dev/null and b/docs/screens/monitoring-agents-admin-light.png differ diff --git a/docs/screens/monitoring-công-cụ-dark.png b/docs/screens/monitoring-công-cụ-dark.png new file mode 100644 index 0000000..30d502d Binary files /dev/null and b/docs/screens/monitoring-công-cụ-dark.png differ diff --git a/docs/screens/monitoring-công-cụ-light.png b/docs/screens/monitoring-công-cụ-light.png new file mode 100644 index 0000000..f9dd41a Binary files /dev/null and b/docs/screens/monitoring-công-cụ-light.png differ diff --git a/docs/screens/monitoring-icon-dark.png b/docs/screens/monitoring-icon-dark.png new file mode 100644 index 0000000..6747f32 Binary files /dev/null and b/docs/screens/monitoring-icon-dark.png differ diff --git a/docs/screens/monitoring-icon-light.png b/docs/screens/monitoring-icon-light.png new file mode 100644 index 0000000..f596c1b Binary files /dev/null and b/docs/screens/monitoring-icon-light.png differ diff --git a/docs/screens/monitoring-lịch-sử-gọi-mcp-dark.png b/docs/screens/monitoring-lịch-sử-gọi-mcp-dark.png new file mode 100644 index 0000000..c5f6f94 Binary files /dev/null and b/docs/screens/monitoring-lịch-sử-gọi-mcp-dark.png differ diff --git a/docs/screens/monitoring-lịch-sử-gọi-mcp-light.png b/docs/screens/monitoring-lịch-sử-gọi-mcp-light.png new file mode 100644 index 0000000..218bed4 Binary files /dev/null and b/docs/screens/monitoring-lịch-sử-gọi-mcp-light.png differ diff --git a/docs/screens/monitoring-nhật-ký-hành-động-dark.png b/docs/screens/monitoring-nhật-ký-hành-động-dark.png new file mode 100644 index 0000000..f0b3120 Binary files /dev/null and b/docs/screens/monitoring-nhật-ký-hành-động-dark.png differ diff --git a/docs/screens/monitoring-nhật-ký-hành-động-light.png b/docs/screens/monitoring-nhật-ký-hành-động-light.png new file mode 100644 index 0000000..f028031 Binary files /dev/null and b/docs/screens/monitoring-nhật-ký-hành-động-light.png differ diff --git a/docs/screens/monitoring-sự-kiện-bảo-mật-dark.png b/docs/screens/monitoring-sự-kiện-bảo-mật-dark.png new file mode 100644 index 0000000..728db2a Binary files /dev/null and b/docs/screens/monitoring-sự-kiện-bảo-mật-dark.png differ diff --git a/docs/screens/monitoring-sự-kiện-bảo-mật-light.png b/docs/screens/monitoring-sự-kiện-bảo-mật-light.png new file mode 100644 index 0000000..8316dc0 Binary files /dev/null and b/docs/screens/monitoring-sự-kiện-bảo-mật-light.png differ diff --git a/docs/screens/monitoring-trạng-thái-agent-dark.png b/docs/screens/monitoring-trạng-thái-agent-dark.png new file mode 100644 index 0000000..6bef289 Binary files /dev/null and b/docs/screens/monitoring-trạng-thái-agent-dark.png differ diff --git a/docs/screens/monitoring-trạng-thái-agent-light.png b/docs/screens/monitoring-trạng-thái-agent-light.png new file mode 100644 index 0000000..378ea20 Binary files /dev/null and b/docs/screens/monitoring-trạng-thái-agent-light.png differ diff --git a/docs/screens/monitoring-tổng-quan-dark.png b/docs/screens/monitoring-tổng-quan-dark.png new file mode 100644 index 0000000..1941d25 Binary files /dev/null and b/docs/screens/monitoring-tổng-quan-dark.png differ diff --git a/docs/screens/monitoring-tổng-quan-light.png b/docs/screens/monitoring-tổng-quan-light.png new file mode 100644 index 0000000..aedb87a Binary files /dev/null and b/docs/screens/monitoring-tổng-quan-light.png differ diff --git a/docs/screens/overlay-help-panel-dark.png b/docs/screens/overlay-help-panel-dark.png new file mode 100644 index 0000000..a489b02 Binary files /dev/null and b/docs/screens/overlay-help-panel-dark.png differ diff --git a/docs/screens/overlay-help-panel-light.png b/docs/screens/overlay-help-panel-light.png new file mode 100644 index 0000000..ee7aacd Binary files /dev/null and b/docs/screens/overlay-help-panel-light.png differ diff --git a/docs/screens/schedule-calendar-dark.png b/docs/screens/schedule-calendar-dark.png new file mode 100644 index 0000000..2f28682 Binary files /dev/null and b/docs/screens/schedule-calendar-dark.png differ diff --git a/docs/screens/schedule-calendar-light.png b/docs/screens/schedule-calendar-light.png new file mode 100644 index 0000000..5996950 Binary files /dev/null and b/docs/screens/schedule-calendar-light.png differ diff --git a/docs/screens/schedule-kanban-dark.png b/docs/screens/schedule-kanban-dark.png new file mode 100644 index 0000000..35dc6b0 Binary files /dev/null and b/docs/screens/schedule-kanban-dark.png differ diff --git a/docs/screens/schedule-kanban-light.png b/docs/screens/schedule-kanban-light.png new file mode 100644 index 0000000..07335cc Binary files /dev/null and b/docs/screens/schedule-kanban-light.png differ diff --git a/docs/screens/workspace-co4e-dark.png b/docs/screens/workspace-co4e-dark.png new file mode 100644 index 0000000..7045f1c Binary files /dev/null and b/docs/screens/workspace-co4e-dark.png differ diff --git a/docs/screens/workspace-co4e-light.png b/docs/screens/workspace-co4e-light.png new file mode 100644 index 0000000..0d1126c Binary files /dev/null and b/docs/screens/workspace-co4e-light.png differ diff --git a/docs/screens/workspace-cowork-dark.png b/docs/screens/workspace-cowork-dark.png new file mode 100644 index 0000000..9a19911 Binary files /dev/null and b/docs/screens/workspace-cowork-dark.png differ diff --git a/docs/screens/workspace-cowork-light.png b/docs/screens/workspace-cowork-light.png new file mode 100644 index 0000000..7fd6f6f Binary files /dev/null and b/docs/screens/workspace-cowork-light.png differ diff --git a/docs/screens/workspace-folder-dark.png b/docs/screens/workspace-folder-dark.png new file mode 100644 index 0000000..e96be02 Binary files /dev/null and b/docs/screens/workspace-folder-dark.png differ diff --git a/docs/screens/workspace-folder-light.png b/docs/screens/workspace-folder-light.png new file mode 100644 index 0000000..1f50781 Binary files /dev/null and b/docs/screens/workspace-folder-light.png differ diff --git a/docs/screens/workspace-graphrag-dark.png b/docs/screens/workspace-graphrag-dark.png new file mode 100644 index 0000000..fac834f Binary files /dev/null and b/docs/screens/workspace-graphrag-dark.png differ diff --git a/docs/screens/workspace-graphrag-light.png b/docs/screens/workspace-graphrag-light.png new file mode 100644 index 0000000..6f3d340 Binary files /dev/null and b/docs/screens/workspace-graphrag-light.png differ diff --git a/docs/screens/workspace-project-dark.png b/docs/screens/workspace-project-dark.png new file mode 100644 index 0000000..99262dc Binary files /dev/null and b/docs/screens/workspace-project-dark.png differ diff --git a/docs/screens/workspace-project-light.png b/docs/screens/workspace-project-light.png new file mode 100644 index 0000000..a09e4bb Binary files /dev/null and b/docs/screens/workspace-project-light.png differ diff --git a/docs/ui-audit.html b/docs/ui-audit.html new file mode 100644 index 0000000..e41dcd2 --- /dev/null +++ b/docs/ui-audit.html @@ -0,0 +1,1175 @@ + + +CoworkLocal — Audit UI/UX + +
+
+

CoworkLocal — Audit UI/UX

+

Hiện trạng 27 màn hình · đề xuất sắp xếp lại theo UI/UX kiểu Claude · +bảng màu Visual Studio Code · dựng lúc 09:51 17/08/2026

+
Ràng buộc: chỉ sắp xếp lại, không xoá/thêm chức năng. +Hai chỗ lệch được đánh dấu ở Phần 1.
+
Ảnh chụp: render offscreen trên bản sao dữ liệu +(scheduler tắt, hash dữ liệu thật trước/sau giống hệt). Nạp dữ liệu mẫu: 3 project · +6 chat · 10 task · 3 workflow · 45 ngày token. Ảnh đã chỉnh menu sáng đúng mục — +xem lỗi #9.
+
+ +
Mục lục +

Phần 1 — Điều hướng · Phần 2 — Luồng người dùng · +Phần 3 — 27 màn hình · Phần 4 — Màn chết

+
  1. Dashboard
  2. Schedule Task — Kanban
  3. Schedule Task — Calendar
  4. Workspace ▸ Project
  5. Workspace ▸ Cowork
  6. Workspace ▸ Co4E
  7. Workspace ▸ Folder
  8. Workspace ▸ GraphRAG
  9. Monitoring ▸ Tổng quan
  10. Monitoring ▸ Sự kiện bảo mật
  11. Monitoring ▸ Lịch sử gọi MCP
  12. Monitoring ▸ Nhật ký hành động
  13. Monitoring ▸ Trạng thái Agent
  14. Monitoring ▸ Agents Admin
  15. Monitoring ▸ Công cụ
  16. Monitoring ▸ Icon
  17. Settings
  18. Task Editor
  19. Skills manager
  20. Skill editor
  21. File view & AI edit
  22. Co4E agent editor
  23. External connector
  24. Permission request
  25. Admin agent editor
  26. Login (dead screen — not wired)
  27. Help dock — expanded panel
+ +

Phần 1 — Điều hướng

+
+
Hiện tại
Dashboard
+Schedule Task
+Workspace  ▼          ← nhánh accordion, tab strip bên trong BỊ ẨN
+   Project
+   Cowork             ← TỰ ẨN khi chưa chọn project
+   Co4E
+   Folder
+   GraphRAG           ← TỰ ẨN khi chưa chọn project
+Monitoring ▼          ← nhánh accordion, tab strip BỊ ẨN
+   Tổng quan
+   Sự kiện bảo mật
+   Lịch sử gọi MCP
+   Nhật ký hành động
+   Trạng thái Agent
+   Agents Admin
+   Công cụ
+   Icon
+
Đề xuất
[ + Đoạn chat mới ]     ← hành động chính, trên cùng
+──────────────
+Project                 ← màn đầu, giữ nguyên
+Cowork                  ← luôn hiện (mờ đi nếu chưa chọn project)
+Co4E
+Folder
+GraphRAG                ← luôn hiện (mờ đi nếu chưa chọn project)
+Schedule Task
+──────────────
+RECENTS                 ← History dời từ pane giữa lên đây
+  · thread gần nhất…
+──────────────  (ghim đáy — nhóm phụ trợ)
+Dashboard               ← vẫn 1 cú nhấp như cũ
+Monitoring              ← BỎ ẨN dải tab, đủ 8 mục nằm ngang trong trang:
+   [Tổng quan] [Sự kiện bảo mật] [Lịch sử gọi MCP]
+   [Nhật ký hành động] [Trạng thái Agent]
+   [Agents Admin] [Công cụ] [Icon]
+👤 local · Provider ▾   ← gom Provider/Language/Theme/Settings
+
+

Chín điểm đã xác minh trong code

+
Vấn đềChi tiết
Accordion 2 cấp, không phẳngapp.py:170 ghi là \“Claude-style\” nhưng là QTreeWidget accordion. Claude dùng danh sách phẳng.
Mục tự biến mấtworkspace_tab.py:485 ẩn Cowork và GraphRAG khi chưa chọn project.
Tab strip bị ẩnhide_tab_bar() ẩn dải tab có sẵn → menu là đường duy nhất.
History chỉ có ở tab Coworkworkspace_tab.py:169. Điểm làm tốt phải giữ: lịch sử đã gom theo project — lưu trong <project>/.cowork_history (config.py:590), hiển thị gom nhóm ở sidebar.py:193.
Monitoring gom 5 việc rời rạcChi phí · log bảo mật · quản trị agent · cấu hình tool · thư viện icon.
Dashboard trùng Monitoring ▸ Tổng quanCùng bộ StatCard + BudgetCard.
Top bar giữ thiết lậpProvider/Ngôn ngữ/Giao diện ở topbar, tách khỏi Cài đặt.
Header không đổi theo mànMọi sub-tab đều hiện \“Workspace — Projects\”.
LỖI: bấm mục con Workspace, menu nhảy về mục cha_goto gọi refresh() (app.py:726) → subtabs_changed vô điều kiện (workspace_tab.py:497) → takeChildren() (app.py:691) huỷ mục vừa bấm.
Đo được: 5/5 mục Workspace mất highlight, 0/8 mục Monitoring bị.
+ +

Khung ứng dụng — thanh trên cùng & thanh menu

+

Không thuộc màn nào nên liệt kê riêng.

+
Kiểm kê control — 8 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
—câylambda cur, _prev: self._navigate(cur); self._on_nav_clickapp.py:175giữ nguyên tại chỗ
MENUnútself._toggle_navapp.py:218Giữ — nút MENU gập sidebar (150↔54px)
—droplistself._on_provider_changedapp.py:559→ menu tài khoản ở đáy sidebar
—droplistself._on_language_changedapp.py:568→ menu tài khoản ở đáy sidebar
Cài đặtnútself._open_settingsapp.py:586→ menu tài khoản ở đáy sidebar
self._tray_open_actmenu chuột phải—app.py:329giữ nguyên tại chỗ
self._tray_quit_actmenu chuột phải—app.py:330giữ nguyên tại chỗ
_icon(icon_namemenu chuột phải—app.py:625giữ nguyên tại chỗ
+ +

Các nút thu gọn / mở rộng — giữ nguyên toàn bộ

+

App có 11 chỗ gập được. Thiết kế mới giữ đủ cả 11.

+
+
Menu mở rộng
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
Cài đặt
👤 localVN ▾🌙
Nội dung
+
Menu đã gập (54px, chỉ còn icon)
+
+
▣
▤
◫
⌥
◈
▦
◔
◕
👤
Nội dung
+
+ +
Chỗ gập đượcCách dùngNguồnThiết kế mới
Thanh menu chínhMENU ‹ ở đầu thanh — gập còn dải icon (150px → 54px)app.py:409giữ
Pane Projectchevron ‹ trên đầu danh sách projectworkspace_tab.py:368giữ
Pane Lịch sửchevron trên đầu History, gập thành dải mỏngsidebar.py:167 · workspace_tab.py:247giữ
Pane Tệp trong chatchevron › — gập panel Files bên phải khung chatchat_panel.py:709giữ
Panel Hỏi đáp GraphRAGchevron › — gập panel agent bên phải đồ thịstructure_graph_view.py:615giữ
Panel cấu hình bước Co4Enút gập panel phải của canvasco4e_tab.py:767giữ
Panel Tin nhắn Co4Egập khung log dưới canvas — mặc định đang gậpco4e_tab.py:894giữ
Terminal trong Thư mụcbấm thanh tiêu đề để mở/gập — mặc định đang gậpterminal_panel.py:156giữ
Panel AI sửa tệpnút ✨ bật/tắt panel — mặc định đang ẩnfolder_tab.py:777giữ
Đồ thị ⇄ Tin nhắn (GraphRAG)nút đổi nội dung pane tráistructure_graph_view.py:416giữ — đổi thành cặp tab
Khối kết quả công cụ trong chatbấm tiêu đề để mở/gập output dàichat_view.py:253giữ
+ +

Từng màn hình đi đâu sau khi sửa

+

Không màn nào bị bỏ. Giám sát (Dashboard + 8 màn Monitoring) ở nhóm +phụ trợ đáy sidebar, mở ra thấy đủ 8 tab.

+ +
Màn hìnhHiện tại ở đâuSau khi sửa ở đâuChức năng
Màn hình làm việc
Workspace ▸ ProjectMenu ▸ Workspace (mở nhánh) ▸ ProjectSidebar ▸ Project — lên cấp 1, bớt 1 lần mở nhánhgiữ nguyên
Workspace ▸ CoworkMenu ▸ Workspace ▸ Cowork — biến mất nếu chưa chọn projectSidebar ▸ Cowork — luôn thấy, mờ khi chưa chọngiữ nguyên
Workspace ▸ Co4EMenu ▸ Workspace ▸ Co4ESidebar ▸ Co4Egiữ nguyên
Workspace ▸ Folder (“Thư mục”)Menu ▸ Workspace ▸ Thư mụcSidebar ▸ Foldergiữ nguyên
Workspace ▸ GraphRAGMenu ▸ Workspace ▸ GraphRAG — biến mất nếu chưa chọn projectSidebar ▸ GraphRAG — luôn thấygiữ nguyên
Schedule Task — KanbanMenu ▸ Schedule TaskSidebar ▸ Schedule Task ▸ tab Kanbangiữ nguyên
Schedule Task — LịchMenu ▸ Schedule Task ▸ combo đổi sang “Lịch”Sidebar ▸ Schedule Task ▸ tab Lịch — combo thành tab, dễ thấy hơngiữ nguyên
Giám sát & vận hành — phần bạn hỏi
DashboardMenu ▸ Dashboard (cấp 1)Sidebar ▸ vùng đáy — vẫn 1 cú nhấpgiữ nguyên
Monitoring ▸ Tổng quanMenu ▸ Monitoring (mở nhánh) ▸ Tổng quanSidebar ▸ Monitoring ▸ tab Tổng quangiữ nguyên
Monitoring ▸ Sự kiện bảo mậtMenu ▸ Monitoring ▸ Sự kiện bảo mậtSidebar ▸ Monitoring ▸ tab Sự kiện bảo mậtgiữ nguyên
Monitoring ▸ Lịch sử gọi MCPMenu ▸ Monitoring ▸ Lịch sử gọi MCPSidebar ▸ Monitoring ▸ tab Lịch sử gọi MCPgiữ nguyên
Monitoring ▸ Nhật ký hành độngMenu ▸ Monitoring ▸ Nhật ký hành độngSidebar ▸ Monitoring ▸ tab Nhật ký hành độnggiữ nguyên
Monitoring ▸ Trạng thái AgentMenu ▸ Monitoring ▸ Trạng thái AgentSidebar ▸ Monitoring ▸ tab Trạng thái Agentgiữ nguyên
Monitoring ▸ Agents AdminMenu ▸ Monitoring ▸ Agents AdminSidebar ▸ Monitoring ▸ tab Agents Admingiữ nguyên
Monitoring ▸ Công cụMenu ▸ Monitoring ▸ Công cụ ▸ tab con Tool | ConnectorSidebar ▸ Monitoring ▸ tab Công cụ ▸ Tool | Connectorgiữ nguyên
Monitoring ▸ IconMenu ▸ Monitoring ▸ IconSidebar ▸ Monitoring ▸ tab Icongiữ nguyên
Thành phần bị dời chỗ
History (lịch sử chat)Pane giữa, chỉ ở tab Cowork. Đã gom theo project.Sidebar ▸ RECENTS — vẫn gom theo project + “Tất cả project…”giữ, dễ tới hơn
Bộ chọn projectPane trái, chỉ ở tab ProjectThanh chọn đầu trang, dùng chung mọi màngiữ, dễ tới hơn
Provider · Ngôn ngữ · Giao diệnThanh trên cùng (topbar)Menu tài khoản ở đáy sidebar — gom cùng chỗ với Cài đặtgiữ nguyên
Nút Cài đặtThanh trên cùngMenu tài khoản ở đáy sidebargiữ nguyên
Hộp thoại & lớp phủ
11 hộp thoạiMở từ nút trên các màn tương ứngKhông đổi — vẫn mở từ đúng những nút đógiữ nguyên
Robot trợ giúp · Terminal · ComposerLớp phủ / panel thu gọnKhông đổigiữ nguyên
+
Dashboard/Monitoring xuống đáy nhưng vẫn là mục cấp 1, vẫn +một cú nhấp. Sidebar chia theo tần suất: việc hằng ngày ở trên, quản trị ở dưới.
+ +

Toàn bộ nhóm tab / lane / chế độ trong app

+

Đầy đủ, kể cả nhóm không hiện ra màn hình.

+ +
NhómSLCác mụcNhìn thấy?Nguồn
Thanh menu trái4 mụcDashboard · Schedule Task · Workspace · Monitoringcóapp.py:154
Workspace ▸ mục con5 mụcProject · Cowork · Co4E · Folder · GraphRAGkhông — hide_tab_bar(), và Cowork/GraphRAG còn tự ẩn khi chưa chọn projectworkspace_tab.py:43
Monitoring ▸ mục con8 mụcTổng quan · Sự kiện bảo mật · Lịch sử gọi MCP · Nhật ký hành động · Trạng thái Agent · Agents Admin · Công cụ · Iconkhông — hide_tab_bar()monitoring_tab.py:215
Công cụ ▸ tab con2 tabTool · Connectorcó — màn duy nhất còn hiện dải tabtools_admin_tab.py:91
Schedule ▸ chế độ xem2 chế độKanban · Lịchlà combo, không phải tabschedule_task_tab.py:171
Kanban ▸ lane trạng thái7 lanebacklog · scheduled · running · waiting_input · done · failed · pausedcắt ở mép phải, phải cuộn ngangtasks.py:24
Co4E ▸ sidebar3 tab iconWorkflows · Agents · Skillschỉ có icon, tên nằm trong tooltipco4e_tab.py:426
Co4E ▸ dải tab flow1 + NFlow Status (ghim) + mỗi workflow đang mở một tabcó, kiểu trình duyệtco4e_tab.py:556
Folder ▸ trình xem5 trangtrống · mã nguồn · HTML · tài liệu · ảnh (+ bảng tính)tự đổi theo đuôi tệp, không có tabfolder_tab.py:322
GraphRAG ▸ khung trái2 trangĐồ thị · Tin nhắnlà nút bấm, không phải tabstructure_graph_view.py:217
Dialog Tạo task bằng AI2 tabSinh bằng AI · Nhập từ Excelcóschedule_task_tab.py:530
Dialog Kết nối ngoài2 chế độMCP (stdio) · REST APIlà combo đổi trangext_connector_dialog.py:23
Dialog Đăng nhập (màn chết)3 trangKhởi tạo lần đầu · Đăng nhập · Dự phòng offlinekhông tới đượclogin_dialog.py:74
Dialog Flow Builder (màn chết)3 tabFlow · Agents · Skillskhông tới đượcflow_dialog.py:247
+
14 nhóm tab/lane, chỉ 4 nhóm hiện ra màn hình. +Số còn lại bị ẩn, thành combo, thành nút, hoặc không tới được.
+
Hai chỗ lệch — cần bạn quyết: +(1) Cowork/GraphRAG: biến mất → hiện nhưng mờ. +(2) Bỏ ẩn tab strip Monitoring (khôi phục thứ đã có).
+ +

Phần 2 — Luồng người dùng

+
LuồngHiện tạiSau khi sửa
Khởi động → màn đầupython -m cowork_local → MainWindow → Workspace ▸ Project
Không có bước đăng nhập (LoginDialog bị bỏ qua)
Giữ nguyên đích đến. Sidebar phẳng nên Project là mục đầu, không còn nằm dưới nhánh Workspace.
Tạo project → chatNav ▸ Workspace (mở nhánh) → Project → “+” → điền form → Lưu → chọn project → nav ▸ Cowork (mục vừa mới xuất hiện) → gõSidebar ▸ Project → “+” → Lưu → sidebar ▸ Cowork (luôn nhìn thấy) → gõ.
Bớt 1 bước mở nhánh, và menu không đổi hình giữa chừng.
Co4E: tạo → chạy → xem runNav ▸ Workspace ▸ Co4E → “+” trên dải tab → kéo agent từ sidebar → chọn node → sửa ở panel phải → Lưu → Chạy → bấm tab Flow Status để xemSidebar ▸ Co4E → “+ Workflow” trong danh sách trái → kéo → sửa phải → Lưu → Chạy.
Trạng thái run là một mục trong danh sách trái, không phải tab riêng.
Tạo & chạy scheduled taskNav ▸ Schedule → “+ Task” → form 5 group → Lưu → kéo thẻ vào lane Running (chạy ngay, không hỏi)Sidebar ▸ Schedule Task → “+ Task” → form 3 tab → Lưu → kéo vào Running (lane có viền cảnh báo).
Duyệt tệp → AI sửaNav ▸ Workspace ▸ Folder → chọn tệp → bấm ✨ mở panel AI → gõ lệnh → xem plan → xem diff → ApplySidebar ▸ Folder → chọn tệp → ✨ mở lớp phủ AI → gõ → plan → diff → Apply.
Luồng giữ nguyên; panel không còn chiếm chỗ cố định.
+ +

Truy vết chi tiết: “Đoạn chat mới”

+ +
Khía cạnhGiao diện cũGiao diện mớiCó đồng bộ không
Số lối vào1 — nút “Cuộc trò chuyện mới” trên toolbar Cowork (cowork_tab.py:34)2 — nút sidebar + nút toolbar cũ giữ nguyênTôi vẽ thêm nút sidebar mà chưa nói gì về nút cũ. Giữ cả hai (bỏ nút cũ là xoá chức năng), cùng gọi một hàm.
Thấy được khi nàoChỉ khi đang ở tab Cowork — mà tab này tự ẩn khi chưa chọn projectLuôn thấy trên sidebarMới dễ tới hơn. Chưa chọn project thì nút mờ đi.
Chat mới thuộc project nàoProject đang mở, ngầm định — không hiển thị ở đâuBộ chọn project ngay trên nút, trong sidebarCùng hành vi — vẫn là project đang mở (ctx.active_project_id), nhưng nay nhìn thấy và đổi được tại chỗ.
Đổi project trước khi tạoPhải rời Cowork → về tab Project → chọn dòng trong danh sách → quay lại Cowork → bấm nút. 4 bước.Bấm droplist ngay trên nút → chọn → bấm nút. 2 bước, không rời màn.Ít bước hơn, không thêm chức năng — vẫn là chọn project rồi tạo chat.
Bấm từ màn khácKhông xảy ra được — nút chỉ có trên CoworkChuyển sang Cowork rồi tạo chat mớiHành vi mới, cần thiết vì nút giờ ở mọi màn.
Việc thực sự làmnew_session() (chat_panel.py:1653): xoá messages · sinh session_id mới · dọn view, composer, plan, tệp vào/ra · turn đang chạy vẫn chạy nềnGiữ y nguyênKhông đổi.
Lưu chat cũTự lưu; History refresh qua history_changedGiữ y nguyên — RECENTS refreshKhông đổi.
+
+
Có project
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
Cài đặt
👤 localVN ▾🌙
Cowork
+
Chưa có project nào
Chưa có project▾
+ Đoạn chat mới
Tạo project trước
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
trống
Dashboard
Monitoring
Cài đặt
👤 localVN ▾🌙
Quản lý project
Chưa có project — bấm “+ Project mới”
+
+
Khi chưa có project (đã chạy thử app với 0 project): +hiện nay Cowork và GraphRAG biến mất khỏi menu nên không chat được, mà không nói vì sao. +Thiết kế mới giữ nguyên cổng chặn đó — vẫn không tạo chat được — nhưng hai mục vẫn nằm +đúng chỗ, chỉ mờ đi; droplist ghi “Chưa có project”; nút chat mới bị khoá kèm lý do +“Tạo project trước”.
+Ghi nhận thêm: lúc đó ctx.active_project_id vẫn giữ +'default' — trỏ vào một project không tồn tại. Và +projects.ensure_starter_project() (“đảm bảo luôn có ít nhất một project”) +không nơi nào gọi.
+ +
Phát hiện: sidebar.py:68 khai báo tín hiệu +new_chat và workspace_tab.py:241 đã nối nó vào +_on_sidebar_new — nhưng không nơi nào phát tín hiệu này +(grep new_chat.emit → rỗng). Tức pane Lịch sử vốn được thiết kế để có nút +“chat mới” nhưng nút đó chưa bao giờ được thêm. Đề xuất đưa nút lên sidebar chính là +hoàn thiện ý định sẵn có trong code, không phải thêm mới.
+ +

Phần 3 — Từng màn hình

+
+
1. Dashboardui/dashboard_tab.py:35
+
+

Token đã tiêu và chi phí quy ra tiền, theo kỳ.

+
Hiện tại
Dashboard
+

Header: kỳ · granularity · metric · tiền tệ · 6 thẻ: Total · In · Out · Cache · Cost · Ngân sách · Biểu đồ: spline + đường so sánh kỳ trước · Thói quen: top task tốn token

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
Cài đặt
👤 localVN ▾🌙
Dashboard
◀
08/03 – 08/09
▶
Theo tuần ▾
Chi phí ▾
USD ▾
⟳
$0.31Tổng chi phí · 57 lượt
395.4KTổng token
292.8KInput
102.7KOutput
108.9KCache
Tốn nhiều nhất: Dựng slide trình bày — 105.4K (26%)
✨
+
Kiểm kê control — 10 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Kỳ trướcnútself._chart_prevui\dashboard_tab.py:59giữ nguyên tại chỗ
Kỳ saunútself._chart_nextui\dashboard_tab.py:67giữ nguyên tại chỗ
—droplistself._on_gran_changedui\dashboard_tab.py:71giữ nguyên tại chỗ
—droplistself._refresh_chartui\dashboard_tab.py:75giữ nguyên tại chỗ
Tiền tệ hiển thị (tỉ giá USD→VND/JPY cố định, sửa được trongdroplistself._on_currency_changedui\dashboard_tab.py:84giữ nguyên tại chỗ
nútself.refreshui\dashboard_tab.py:91→ lên sidebar cùng RECENTS
AI phân tíchnútself._ai_analyzeui\dashboard_tab.py:145giữ nguyên tại chỗ
Áp dụng chiến lược tiết kiệmnútself._apply_saving_strategyui\dashboard_tab.py:150giữ nguyên tại chỗ
f'{arrow} {self._title} ({self._count}nútself._toggle; self._toggleui\widgets.py:254giữ nguyên tại chỗ
—danh sáchself._emitui\widgets.py:261giữ nguyên tại chỗ
+
+
Vấn đề
  • Tám control trên một hàng header.
  • Trùng Monitoring ▸ Tổng quan: cùng StatCard + BudgetCard.
  • Sáu thẻ số bằng nhau — không thấy đâu là chỉ số chính.
+
Thay đổi
  • Header tách 2 hàng: thời gian / bộ lọc.
  • Nâng Chi phí làm thẻ chính, 4 thẻ còn lại phụ.
+
+
2. Schedule Task — Kanbanui/schedule_task_tab.py:70
+
+

Kanban các tác vụ hẹn giờ. Bộ lập lịch chạy nền dù màn này đóng.

+
Hiện tại
Schedule Task — Kanban
+

Lane: một cột mỗi trạng thái · Thẻ: kéo đổi lane · double-click sửa · chuột phải: Chạy ngay / Lịch sử

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
Cài đặt
👤 localVN ▾🌙
Kanban
Lịch
+ Task
✨ AI tạo
BACKLOG (2)
[AI] Xuất DS khách hàng B2BChưa đặt lịch
Rà soát bảo mật trước releasehigh
ĐÃ LÊN LỊCH (2)
Quét lại chỉ mục ISO08-11 14:32
Báo cáo doanh thu 08:0008-09 14:32
ĐANG CHẠY (1) ⚠
Đồng bộ heartbeat trạm sạc08-08 · high
CHỜ DUYỆT (1)
Chờ kế toán duyệt số liệu T7Chưa đặt lịch
XONG (2)
Sao lưu CSDL hằng đêm08-07 · critical
[AI] Slide tổng kết Q3Thành công
LỖI (1)
Kiểm tra chứng chỉ TLScritical · Lỗi
TẠM DỪNG (1)
Dọn log cũ hơn 90 ngàylow
Đủ 7 lane theo core.tasks.STATUSES — không gộp, không giấu lane nào. Lane hẹp lại để vừa một màn, hết cuộn ngang.
✨
+
Kiểm kê control — 23 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Thêm Tasknútself._add_taskui\schedule_task_tab.py:88giữ nguyên tại chỗ
AI tạo Tasknútself._ai_createui\schedule_task_tab.py:92giữ nguyên tại chỗ
—droplistself._on_view_changedui\schedule_task_tab.py:95→ đổi thành cặp tab Kanban | Lịch
len(runsbảngself._open_artifactui\schedule_task_tab.py:436giữ nguyên tại chỗ
QDialogButtonBox.Closenút hộp thoại—ui\schedule_task_tab.py:461giữ nguyên tại chỗ
Project/workspace mà agent của task này sẽ chạy trong đó — ádroplist—ui\schedule_task_tab.py:523giữ nguyên tại chỗ
vd: Mỗi thứ 2 lúc 9h, dùng Code đọc dữ liệu CAE mới tạo báo ô nhập nhiều dòng—ui\schedule_task_tab.py:537giữ nguyên tại chỗ
Đường dẫn tệp local, cách nhau bằng ;ô nhập—ui\schedule_task_tab.py:544giữ nguyên tại chỗ
Chọn tệp…nútself._ai_pick_filesui\schedule_task_tab.py:546giữ nguyên tại chỗ
https://… các link, cách nhau bằng ;ô nhập—ui\schedule_task_tab.py:553giữ nguyên tại chỗ
Tạo kế hoạchnútself._generateui\schedule_task_tab.py:557giữ nguyên tại chỗ
Tạo template Excel…nútself._export_templateui\schedule_task_tab.py:571giữ nguyên tại chỗ
Chọn file…nútself._pick_import_fileui\schedule_task_tab.py:576giữ nguyên tại chỗ
QDialogButtonBox.Ok | QDialogButtonBox.Cancelnút hộp thoại—ui\schedule_task_tab.py:592giữ nguyên tại chỗ
Chạy ngaymenu chuột phải—ui\schedule_task_tab.py:309giữ nguyên tại chỗ
Sửa taskmenu chuột phải—ui\schedule_task_tab.py:310giữ nguyên tại chỗ
Nhân bản taskmenu chuột phải—ui\schedule_task_tab.py:311giữ nguyên tại chỗ
schedtask.menu_resume' if paused else 'schedtask.menu_pausemenu chuột phải—ui\schedule_task_tab.py:313giữ nguyên tại chỗ
Xem logmenu chuột phải—ui\schedule_task_tab.py:314giữ nguyên tại chỗ
Lịch sử chạy…menu chuột phải—ui\schedule_task_tab.py:315giữ nguyên tại chỗ
Tạo task tiếp theo từ outputmenu chuột phải—ui\schedule_task_tab.py:316giữ nguyên tại chỗ
Xóa taskmenu chuột phải—ui\schedule_task_tab.py:318giữ nguyên tại chỗ
schedtask.menu_delete_selected', n=len(selectedmenu chuột phải—ui\schedule_task_tab.py:348giữ nguyên tại chỗ
+
+
Vấn đề
  • 7 lane bị cắt ở mép phải — lane Paused mất một nửa.
  • Kéo-thả có tác dụng thật: thả vào Running là chạy task ngay (schedule_task_tab.py:265), không cảnh báo.
  • Kanban/Calendar là combo, không phải tab.
+
Thay đổi
  • Giữ đủ 7 lane, thu hẹp cho vừa một màn. Không gộp lane nào.
  • Combo → cặp tab Kanban | Lịch.
  • Lane Running có viền cảnh báo.
+
+
3. Schedule Task — Calendarui/calendar_view.py:88
+
+

Cùng dữ liệu Kanban, xếp theo ngày.

+
Hiện tại
Schedule Task — Calendar
+

Ô ngày: nút + tạo task lúc 09:00 ngày đó

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 7 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
+nútlambda: self.add_requested.emit(self._date_str)ui\calendar_view.py:43giữ nguyên tại chỗ
—danh sáchself._on_item_clickedui\calendar_view.py:49giữ nguyên tại chỗ
Trướcnútlambda: self._shift(-1)ui\calendar_view.py:100giữ nguyên tại chỗ
Hôm naynútself._go_todayui\calendar_view.py:103giữ nguyên tại chỗ
Saunútlambda: self._shift(1)ui\calendar_view.py:105giữ nguyên tại chỗ
—droplistself._on_granularity_changedui\calendar_view.py:110giữ nguyên tại chỗ
—danh sáchlambda item: self.show_month(self.anchor.year, item.data(Qt.UserRole))ui\calendar_view.py:213giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
4. Workspace ▸ Projectui/workspace_tab.py:188
+
+

Khai báo project — đơn vị gom nhóm của app. Mỗi project có sandbox riêng và Instructions chèn vào mọi chat. Đây là bộ chọn project duy nhất.

+
Hiện tại
Workspace ▸ Project
+

Trái: danh sách project — chỉ hiện ở tab này · Phải: Tên · Mô tả · Instructions · thư mục

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
Cài đặt
👤 localVN ▾🌙
Quản lý project
+ Project mới
PROJECT‹
Trạm sạc EV — Cổng vận hành6 đoạn chat · 4 task
Báo cáo tài chính Q32 đoạn chat · 3 task
Cổng tra cứu tài liệu ISO1 đoạn chat · 1 task
Tên
Báo cáo tài chính Q3
Mô tả
Tự động gom số liệu từ Excel phòng ban, dựng bảng tổng hợp và slide.
Instructions
Đơn vị tiền tệ mặc định VND. Làm tròn tới nghìn đồng.
Mọi con số phải truy được về file nguồn.
Thư mục làm việc
…\workspaces\bao-cao-tai-chinh-q3
Đổi
Mở
Lưu project
✨
+
Kiểm kê control — 11 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Thu gọn danh sách projectnútlambda: self._set_projects_collapsed(True)ui\workspace_tab.py:90giữ nguyên tại chỗ
—danh sáchself._on_selectui\workspace_tab.py:97→ giữ ở màn Quản lý project + thêm thanh chọn đầu trang
Project mớinútself._createui\workspace_tab.py:101giữ nguyên tại chỗ
Xóanútself._deleteui\workspace_tab.py:105giữ nguyên tại chỗ
—dải tabself._on_tab_changedui\workspace_tab.py:129giữ nguyên tại chỗ
project.nameô nhập—ui\workspace_tab.py:193giữ nguyên tại chỗ
project.descriptionô nhập—ui\workspace_tab.py:194giữ nguyên tại chỗ
vd: "Trả lời bằng tiếng Việt. Team đang xây tool báo cáo X; ô nhập nhiều dòng—ui\workspace_tab.py:203giữ nguyên tại chỗ
Đổi thư mục…nútself._pick_folderui\workspace_tab.py:211giữ nguyên tại chỗ
Mở thư mụcnútself._open_workspaceui\workspace_tab.py:214giữ nguyên tại chỗ
Lưu projectnútself._saveui\workspace_tab.py:223giữ nguyên tại chỗ
+
+
Vấn đề
  • Pane trái đổi danh tính theo tab (workspace_tab.py:310-338): Project → danh sách project, Cowork → History, còn lại → trống.
  • History chỉ tới được từ tab Cowork.
  • Bộ chọn project chỉ có ở tab Project.
  • 2/3 chiều cao dưới là khoảng trống chết.
  • Header “Workspace — Projects” hiện ở mọi sub-tab.
+
Thay đổi
  • History lên sidebar thành RECENTS, luôn thấy.
  • Thanh chọn project ở đầu trang, dùng chung mọi màn.
  • Pane trái cố định, không đổi danh tính.
  • Header đổi theo màn.
+
+
5. Workspace ▸ Coworkui/cowork_tab.py:21
+
+

Chat với agent. Agent đọc/ghi tệp trong sandbox, chạy lệnh, gọi MCP.

+
Hiện tại
Workspace ▸ Cowork
+

Lịch sử: chat gom theo project; nhãn đậm = tên project, chỉ là nhãn · Hội thoại: bong bóng theo trục thời gian · Files: tệp đầu ra, gập được · Composer: Enter gửi · /skill · /agent · Hàng dưới: thư mục sandbox (chỗ thứ 2 lộ project) · model · Định tuyến · Tự chạy

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
Cài đặt
👤 localVN ▾🌙
Gom số liệu doanh thu
qwen2.5-coder
Skills
Cuộc trò chuyện mới
Có 6 file Excel trong thư mục input, gom lại thành 1 bảng tổng hợp giúp mình.
Đã đọc cả 6 file. Lưu ý: PB_Marketing.xlsx để cột “Doanh thu” ở cột F thay vì D và có 3 dòng trống ở cuối.

Mình đã chuẩn hoá và xuất tonghop_q3.xlsx — 1.284 dòng, tổng 42.7 tỷ VND.
TỆP ĐẦU RA (3)›
tonghop_q3.xlsx
BaoCao_Q3.pptx
README.md
Nhập yêu cầu… (Enter để gửi)
📎
Gửi
Agent: qwen2.5-coder · Định tuyến: Tắt  ·  ↓292.8K ↑102.7K · $0.31  ·  📁 bao-cao-tai-chinh-q3
✨
+
Kiểm kê control — 26 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Skillsnútself._open_skills_managerui\cowork_tab.py:30giữ nguyên tại chỗ
Cuộc trò chuyện mớinútself.new_sessionui\cowork_tab.py:34giữ nguyên tại chỗ
Thư mục Local…nútself._pick_output_folderui\cowork_tab.py:48giữ nguyên tại chỗ
Model/agent riêng cho tab này — độc lập với tab kiadroplistself._on_agent_changedui\chat_panel.py:165giữ nguyên tại chỗ
Nénnútself._compress_messagesui\chat_panel.py:177giữ nguyên tại chỗ
Thu gọn bảng Filesnútlambda: self._set_io_collapsed(True)ui\chat_panel.py:239giữ nguyên tại chỗ
app_icon('linkmenu chuột phải—ui\chat_panel.py:439giữ nguyên tại chỗ
app_icon('editmenu chuột phải—ui\chat_panel.py:440giữ nguyên tại chỗ
Nhấp đúp để xoá một tin nhắn khỏi hàng đợidanh sáchself._remove_queue_itemui\composer.py:406giữ nguyên tại chỗ
Bấm trên thẻ để gỡ tệp đính kèm nhầmdanh sáchself._remove_attachmentui\composer.py:420giữ nguyên tại chỗ
nútself._pick_attachmentsui\composer.py:443giữ nguyên tại chỗ
composer.queue_btn') if self._busy else 'composer.sendnútself._on_submitui\composer.py:446giữ nguyên tại chỗ
Dừngnútself.stop_requested.emitui\composer.py:450giữ nguyên tại chỗ
Gỡ tệp này (đính kèm nhầmnútlambda _=False, path=p: self._remove_attachment_path(path)ui\composer.py:599giữ nguyên tại chỗ
Thu gọn bảng Lịch sửnútself.collapse_requested.emitui\sidebar.py:104giữ nguyên tại chỗ
Tìm theo tiêu đề hoặc nội dung…ô nhậpself.refresh; self.refreshui\sidebar.py:119giữ nguyên tại chỗ
nútself.refreshui\sidebar.py:123→ lên sidebar cùng RECENTS
—câyself._on_item; self._context_menuui\sidebar.py:132giữ nguyên tại chỗ
Làm mớinútself.refresh_requested.emitui\sidebar.py:147giữ nguyên tại chỗ
sidebar.menu.unpin') if pinned else 'sidebar.menu.pinmenu chuột phải—ui\sidebar.py:305giữ nguyên tại chỗ
Đổi tên…menu chuột phải—ui\sidebar.py:306giữ nguyên tại chỗ
Xóamenu chuột phải—ui\sidebar.py:307giữ nguyên tại chỗ
sidebar.menu.delete_selected', n=len(selectedmenu chuột phải—ui\sidebar.py:332giữ nguyên tại chỗ
titlenútself._toggle_bodyui\chat_view.py:228giữ nguyên tại chỗ
Tự động định tuyến model cho khung chat này. +Tắt: luôn dùng droplistself._on_changedui\routing_toggle.py:66giữ nguyên tại chỗ
Tự chạyô tickself._on_toggledui\routing_toggle.py:133giữ nguyên tại chỗ
+
+
Vấn đề
  • Hàng dưới composer nhồi 5 control + usage/cost + nút thư mục (chat_panel.py:142-181).
  • Không có lối tắt “chat mới” — phải chọn project → mở nav → Cowork.
  • Màn duy nhất thấy được History.
  • Ba pane bóp vùng đọc hội thoại còn chưa tới 60% bề ngang.
  • Biết project nào, nhưng không đổi được. Project hiện ở 2 chỗ (nhãn nhóm Lịch sử, nhãn thư mục đáy) — cả hai chỉ là nhãn. Đổi phải quay về tab Project (workspace_tab.py:323).
+
Thay đổi
  • Bộ chọn project lên sidebar — nó là trạng thái toàn cục (ctx.active_project_id), không phải của riêng màn nào.
  • Bộ chọn project + nút “+ Đoạn chat mới” đặt cạnh nhau ở đầu sidebar: chọn project rồi bấm, không rời màn. Nút cũ trên toolbar giữ nguyên.
  • History lên sidebar, vẫn gom theo project + mục “Tất cả project…”.
  • Usage/cost xuống thanh trạng thái; vùng gõ chỉ còn nhập · đính kèm · gửi.
+
+
6. Workspace ▸ Co4Eui/co4e_tab.py:228
+
+

Xưởng dựng workflow node-graph. Lưu toàn cục, không theo project.

+
Hiện tại
Workspace ▸ Co4E
+

Sidebar: 3 tab icon: Workflows · Agents · Skills — kéo thả được · Dải tab: Flow Status ghim + mỗi workflow một tab · Canvas: node và cạnh · Phải: cấu hình bước đang chọn

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
Cài đặt
👤 localVN ▾🌙
Quy trình phát triển tính năng
+ Bước
Auto ▾
▷ Chạy
WORKFLOWS+ Mới‹
Quy trình phát triển tính năng5 bước · đã lưu
Rà soát bảo mật định kỳ2 bước · đã lưu
Dựng báo cáo từ Excel3 bước · đã lưu
AGENTS (5)+ Mới
Phân tích yêu cầuANALYST
Thiết kế giải phápARCHITECT
Lập trình viênCODER
Kiểm thửTESTER
Soạn tài liệuWRITER
SKILLS (5)Quản lý…
Rà soát bảo mật · Viết test trước · Chuẩn hoá Excel · …
LẦN CHẠY (6)
✓ Quy trình phát triển5/5 · 08-08 15:32
✕ Rà soát bảo mật3/5 · 08-06 16:32
■ Dựng báo cáo từ Excel1/5 · 08-04 18:32
✎
⧉
🗑
▷ Chạy nền
Phân tích yêu cầu
→
Thiết kế
→
Lập trình viên
→
Kiểm thử
CẤU HÌNH BƯỚC›
▾ Cơ bảnLập trình viên · CODER
Hiện thực theo thiết kế. Viết test trước khi sửa logic nghiệp vụ.
▸ Model & quyềnqwen2.5-coder · full
▸ Skills & tệpViết test trước
▸ Agent song songchưa có
✨
+
Kiểm kê control — 52 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
—danh sáchlambda _i: self._accept()ui\co4e_tab.py:144giữ nguyên tại chỗ
×nútlambda: self._close_flow_tab_button(btn)ui\co4e_tab.py:341giữ nguyên tại chỗ
Chạynútself._run_selected_in_backgroundui\co4e_tab.py:459giữ nguyên tại chỗ
Mớinútself._new_agentui\co4e_tab.py:476→ nút “+ Mới” cạnh tiêu đề AGENTS
Quản lý skill…nútself._manage_skillsui\co4e_tab.py:493→ nút “Quản lý…” cạnh tiêu đề SKILLS
tip_keynútslotui\co4e_tab.py:502giữ nguyên tại chỗ
—dải tabself._on_flow_tab_changed; self._close_flow_tabui\co4e_tab.py:556→ bỏ; chọn workflow từ danh sách trái
+nútself._new_workflowui\co4e_tab.py:582→ nút “+ Mới” cạnh tiêu đề WORKFLOWS (chỗ cũ là dải tab, đã bỏ nên phải có chỗ mới)
self._wf.nameô nhậpself._on_name_changedui\co4e_tab.py:631giữ nguyên tại chỗ
Thêmnútself._add_blank_stepui\co4e_tab.py:636giữ nguyên tại chỗ
Lưunútlambda: self._save(as_template=False)ui\co4e_tab.py:639giữ nguyên tại chỗ
Auto = mỗi bước tự lập kế hoạch rồi chạy · Plan = chỉ lập kếdroplistself._on_mode_changedui\co4e_tab.py:645giữ nguyên tại chỗ
Chạynútself._on_run_clickedui\co4e_tab.py:650giữ nguyên tại chỗ
shortnútself._open_workspace_folderui\co4e_tab.py:693giữ nguyên tại chỗ
Dừngnútself._stop_selected_runui\co4e_tab.py:701giữ nguyên tại chỗ
Đổi tênnútself._rename_selected_runui\co4e_tab.py:706giữ nguyên tại chỗ
Xóanútself._delete_selected_runui\co4e_tab.py:710giữ nguyên tại chỗ
Xóa đã xongnútlambda: self.manager.clear_finished()ui\co4e_tab.py:714giữ nguyên tại chỗ
0bảngself._open_run_from_table; self._runs_context_menuui\co4e_tab.py:722giữ nguyên tại chỗ
Thu gọn bảng cấu hìnhnútself._toggle_configui\co4e_tab.py:747giữ nguyên tại chỗ
Mở rộng khung tin nhắnnútself._toggle_messagesui\co4e_tab.py:841giữ nguyên tại chỗ
Gửinútself._chat_sendui\co4e_tab.py:873giữ nguyên tại chỗ
icon('editmenu chuột phải—ui\co4e_tab.py:1014giữ nguyên tại chỗ
icon('editmenu chuột phải—ui\co4e_tab.py:1015giữ nguyên tại chỗ
icon('branchmenu chuột phải—ui\co4e_tab.py:1016giữ nguyên tại chỗ
icon('playmenu chuột phải—ui\co4e_tab.py:1017giữ nguyên tại chỗ
icon('trashmenu chuột phải—ui\co4e_tab.py:1018giữ nguyên tại chỗ
Mở flowmenu chuột phải—ui\co4e_tab.py:1400giữ nguyên tại chỗ
Mở thư mục outputmenu chuột phải—ui\co4e_tab.py:1404giữ nguyên tại chỗ
Đổi tênmenu chuột phải—ui\co4e_tab.py:1405giữ nguyên tại chỗ
Xóamenu chuột phải—ui\co4e_tab.py:1406giữ nguyên tại chỗ
step.labelô nhậpself._on_editui\co4e_config_panel.py:44giữ nguyên tại chỗ
step.roleô nhậpself._on_editui\co4e_config_panel.py:48giữ nguyên tại chỗ
—ô nhập nhiều dòngself._on_editui\co4e_config_panel.py:60giữ nguyên tại chỗ
Soạn bằng AInútself._ai_draftui\co4e_config_panel.py:63giữ nguyên tại chỗ
Thông tin/ngữ cảnh bổ sung cho agent hoặc step này (thêm vàoô nhập nhiều dòngself._on_editui\co4e_config_panel.py:77giữ nguyên tại chỗ
Tải danh sách modelnútself._load_modelsui\co4e_config_panel.py:87giữ nguyên tại chỗ
—droplistself._on_editui\co4e_config_panel.py:97giữ nguyên tại chỗ
Tự kiểm traô tickself._on_editui\co4e_config_panel.py:104giữ nguyên tại chỗ
—ô sốself._on_editui\co4e_config_panel.py:106giữ nguyên tại chỗ
Đính kèm tệpnútself._add_attachmentui\co4e_config_panel.py:125giữ nguyên tại chỗ
Bỏnútself._del_attachmentui\co4e_config_panel.py:128giữ nguyên tại chỗ
—danh sáchself._edit_subagentui\co4e_config_panel.py:141giữ nguyên tại chỗ
Thêmnútself._add_subagentui\co4e_config_panel.py:144giữ nguyên tại chỗ
Bỏnútself._del_subagentui\co4e_config_panel.py:147giữ nguyên tại chỗ
Chạynútlambda: self.run_node.emit(self._node_id)ui\co4e_config_panel.py:159giữ nguyên tại chỗ
Chạy từ đâynútlambda: self.run_from.emit(self._node_id)ui\co4e_config_panel.py:163giữ nguyên tại chỗ
Xóa bướcnútlambda: self.delete_node.emit(self._node_id)ui\co4e_config_panel.py:166giữ nguyên tại chỗ
+ Add next stepmenu chuột phải—ui\co4e_canvas.py:188giữ nguyên tại chỗ
→ Connect from heremenu chuột phải—ui\co4e_canvas.py:189giữ nguyên tại chỗ
🗑 Delete stepmenu chuột phải—ui\co4e_canvas.py:190giữ nguyên tại chỗ
🗑 Delete connectionmenu chuột phải—ui\co4e_canvas.py:368giữ nguyên tại chỗ
+
+
Vấn đề
  • Bốn lớp điều hướng chồng nhau: nav → tab icon sidebar → dải tab flow → panel phải.
  • Dải tab flow lặp lại danh sách Workflows ngay bên trái.
  • Panel cấu hình 11 trường dọc, phải cuộn.
+
Thay đổi
  • Bỏ dải tab flow; chọn workflow từ danh sách trái.
  • 3 tab icon → 3 mục có nhãn cùng danh sách.
  • Còn 2 lớp: chọn trái → sửa phải.
+
+
7. Workspace ▸ Folderui/folder_tab.py:238
+
+

Duyệt tệp + nhờ AI sửa. AI không ghi đè — đề xuất diff, bấm Apply mới ghi.

+
Hiện tại
Workspace ▸ Folder
+

Cây trái: hệ thống tệp thật · Viewer: code / HTML / PDF / ảnh / bảng tính · Panel AI: yêu cầu → plan → diff → Apply · Terminal: shell thật, không qua sandbox

+
Đề xuất — bố cục mới
📁 Trạm sạc EV — Cổng vận hành▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Trạm sạc EV — Cổng vận hành
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
Cài đặt
👤 localVN ▾🌙
Thư mục
…\workspaces\tram-sac-ev
Sửa
✨ AI
Lưu
📁 src
 📄 main.py
 📁 billing
  📄 session.py
📁 tests
 📄 test_stations.py
📄 README.md
# billing/session.py
def close_session(sid):
  s = repo.get(sid)
  s.ended_at = None # ← lỗi tính tiền
  return bill(s)
✨ AI SỬA TỆP›
Sửa lỗi tính dư tiền khi phiên bị ngắt đột ngột.
Lấy mốc heartbeat cuối làm ended_at.
- s.ended_at = None
+ s.ended_at = last_heartbeat(sid)
Bỏ
Áp dụng
▸ Terminal — bật khi cần
✨
+
Kiểm kê control — 15 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
self._rootô nhập—ui\folder_tab.py:265giữ nguyên tại chỗ
Mở thư mụcnútself._pick_rootui\folder_tab.py:267giữ nguyên tại chỗ
folder.edit') if not self.mode_btn.isChecked() else 'folder.nútself._toggle_edit_modeui\folder_tab.py:299giữ nguyên tại chỗ
AI Editnútself._toggle_ai_panelui\folder_tab.py:304giữ nguyên tại chỗ
Lưunútself._saveui\folder_tab.py:309giữ nguyên tại chỗ
Mở bằng app ngoàinútself._open_externalui\folder_tab.py:315giữ nguyên tại chỗ
len(rowsbảng—ui\folder_tab.py:534giữ nguyên tại chỗ
Mô tả chỉnh sửa… (vd: thêm xử lý lỗiô nhậpself._ai_sendui\folder_tab.py:736giữ nguyên tại chỗ
Gửinútself._ai_sendui\folder_tab.py:740giữ nguyên tại chỗ
Hủynútself._ai_discardui\folder_tab.py:752giữ nguyên tại chỗ
Áp dụngnútself._ai_applyui\folder_tab.py:755giữ nguyên tại chỗ
terminal.expand_tooltip') if self._collapsed else 'terminal.nútself.toggleui\terminal_panel.py:85giữ nguyên tại chỗ
—ô nhập nhiều dòng—ui\terminal_panel.py:105giữ nguyên tại chỗ
Chạynútself._run_currentui\terminal_panel.py:130giữ nguyên tại chỗ
Mở bằng LibreOfficenútself._open_externalui\libreoffice_view.py:97giữ nguyên tại chỗ
+
+
Vấn đề
  • Năm vùng cùng lúc: path · cây · viewer · panel AI · terminal.
  • Hàng nút viewer trộn 5 chức năng khác loại.
+
Thay đổi
  • Path bar gộp vào tiêu đề.
  • Panel AI thành lớp phủ phải; terminal xuống đáy dạng thanh mỏng.
+
+
8. Workspace ▸ GraphRAGui/structure_graph_view.py:188
+
+

Đồ thị tri thức về cấu trúc mã/tài liệu + agent hỏi đáp trên đó.

+
Hiện tại
Workspace ▸ GraphRAG
+

Đồ thị: node theo loại, cạnh có nhãn quan hệ · Phải: hỏi đáp dựa trên đồ thị

+
Đề xuất — bố cục mới
📁 Cổng tra cứu tài liệu ISO▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Cổng tra cứu tài liệu ISO
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
Cài đặt
👤 localVN ▾🌙
GraphRAG
…\workspaces\cong-tra-cuu-iso
Quét
Xuất PNG
Đồ thị
Tin nhắn
1.902 node · 3.418 cạnh
ISO 9001
→
Điều 7.5
→
Hồ sơ
HỎI ĐÁP TRÊN ĐỒ THỊ›
Điều khoản nào nói về kiểm soát hồ sơ?
Điều 7.5.3 — Kiểm soát thông tin dạng văn bản. Có 12 tài liệu trùng số hiệu, xem trung_lap.md.
Đặt câu hỏi…
Hỏi
✨
+
Kiểm kê control — 10 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
sctx.config.cowork_output_dir(ô nhập—ui\structure_graph_view.py:220giữ nguyên tại chỗ
Browse…nútself._pickui\structure_graph_view.py:222giữ nguyên tại chỗ
Khóa phạm vi quét vào đúng thư mục sandbox của 1 project — pdroplistself._on_project_changedui\structure_graph_view.py:226giữ nguyên tại chỗ
Scannútself._scanui\structure_graph_view.py:228giữ nguyên tại chỗ
Xem mọi message hội thoại nhóm theo ngày (dạng JSON).nútself._toggle_messagesui\structure_graph_view.py:242giữ nguyên tại chỗ
Xuất PNGnútself._exportui\structure_graph_view.py:248giữ nguyên tại chỗ
—câyself._show_msg_jsonui\structure_graph_view.py:268giữ nguyên tại chỗ
Thu gọn bảng Agentnútlambda: self._set_agent_collapsed(True)ui\structure_graph_view.py:288giữ nguyên tại chỗ
vd. cái gì gọi hàm main? file nào định nghĩa class?ô nhậpself._askui\structure_graph_view.py:299giữ nguyên tại chỗ
Hỏinútself._askui\structure_graph_view.py:301giữ nguyên tại chỗ
+
+
Vấn đề
  • Hai hàng toolbar riêng biệt — nên là một.
  • Nút Messages/Graph đổi hẳn nội dung pane nhưng trông như nút thường.
+
Thay đổi
  • Gộp hai hàng toolbar thành một.
  • Messages/Graph thành cặp tab rõ ràng phía trên pane trái.
+
+
9. Monitoring ▸ Tổng quanui/monitoring_tab.py:132
+
+

Chi phí, tài nguyên máy, sandbox, nhật ký gần đây.

+
Hiện tại
Monitoring ▸ Tổng quan
+

Trái: Token & chi phí · Hoạt động · Tài nguyên · Bảng giá model · Phải: Sandbox · Quyền · Audit log

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
Cài đặt
👤 localVN ▾🌙
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
TOKEN & CHI PHÍ
395.4KTổng token
$0.31Chi phí
57Lượt gọi
—Ngân sách
TÀI NGUYÊN
CPU 34% · RAM 2.1/8 GB · Đĩa 41 GB trống
SANDBOX & QUYỀN
Tệp: chỉ trong workspace · Mạng: chặn · Tiến trình: giới hạn 4
BẢNG GIÁ MODELNhập · Xuất · Thêm · Tự dòUSD ▾
ModelVàoRaCacheĐơn vị
qwen2.5-coder:7b0.000.000.00/Mtok
gpt-4o-mini0.150.600.08/Mtok
NHẬT KÝ GẦN ĐÂYXem tất cả
✕ Chặn đọc personal.xlsx (ngoài sandbox)
✓ pytest tests/test_stations.py → 4 passed
✕ jira.create_issue — 401 token hết hạn
✨
+
Kiểm kê control — 14 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Làm mớinútself.refreshui\monitoring_tab.py:149→ lên sidebar cùng RECENTS
0bảng—ui\monitoring_tab.py:175giữ nguyên tại chỗ
Lọc dòng (hoặc gõ câu hỏi rồi bấm )…ô nhậptable.apply_filterui\monitoring_tab.py:347giữ nguyên tại chỗ
AInútlambda: self._ai_filter(search, ai_btn)ui\monitoring_tab.py:350giữ nguyên tại chỗ
—droplistself._reload_pricing_tableui\monitoring_tab.py:486giữ nguyên tại chỗ
Nhậpnútself._import_pricingui\monitoring_tab.py:496giữ nguyên tại chỗ
Mẫunútself._export_pricingui\monitoring_tab.py:498giữ nguyên tại chỗ
Thêmnútself._add_pricing_rowui\monitoring_tab.py:500giữ nguyên tại chỗ
Tự lấynútself._autolink_pricingui\monitoring_tab.py:502giữ nguyên tại chỗ
Xóanútself._delete_pricing_rowui\monitoring_tab.py:504giữ nguyên tại chỗ
0bảng—ui\monitoring_tab.py:510giữ nguyên tại chỗ
Sửanútself._open_settings_and_refreshui\monitoring_tab.py:547giữ nguyên tại chỗ
Sửanútself._open_settings_and_refreshui\monitoring_tab.py:573giữ nguyên tại chỗ
Xem tất cảnútlambda: self.tabs.setCurrentIndex(self.tabs.indexOf(self.action_page))ui\monitoring_tab.py:586giữ nguyên tại chỗ
+
+
Vấn đề
  • Sáu group box, hai cột — màn dày đặc nhất app.
  • Trộn 3 mối quan tâm: chi phí · tài nguyên · bảo mật.
  • Bảng giá model nhét chung hàng với thanh CPU/RAM.
+
Thay đổi
  • Tách thành các mục có tiêu đề, cuộn dọc một cột chính.
  • Bảng giá model tách ra thành mục riêng.
+
+
10. Monitoring ▸ Sự kiện bảo mậtui/monitoring_tab.py:132
+
+

Nhật ký lần agent chạm thứ nhạy cảm: lệnh bị chặn, truy cập ngoài sandbox.

+
Hiện tại
Monitoring ▸ Sự kiện bảo mật
+

Trên: 4 thẻ KPI · Giữa: ô tìm + nút ✨AI, lọc Agent/Hành động/Thời gian · Dưới: bảng cột Hành động dạng badge màu (thay cho ✕/✓ luôn giống nhau) + phân trang, nhấp dòng mở panel chi tiết bên phải.

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Sự kiện bảo mật
⭳ Xuất CSV
⟳ Làm mới
▦
1.247Tổng sự kiện · +12.3%
◔
84Hôm nay · -8.1%
⛔
23Chặn lệnh · +3 mới
🛡
156Chặn prompt · 12.5%
✨ AI
✕ Xoá lọc
☐Thời gianAgentTài khoảnMáyHành độngChi tiết chặn
Hiển thị 24 / 24 sự kiện
‹
1
›
✨
+ + +
+
Vấn đề
  • Dải tab Monitoring bị ẩn sau accordion — không thấy 7 màn Giám sát còn lại từ đây.
  • Cột "Kết quả" (✓/✕) trong bảng thật luôn hiện ✕ vì audit_log.record("security_block",…) luôn truyền ok=False — dư thừa, không phân biệt được gì.
  • KPI · lọc Agent/Hành động/Thời gian · phân trang · panel chi tiết đều là tính năng mới: bản thật hiện chỉ có 1 ô tìm + nút ✨AI + bảng cuộn (xem ui/monitoring_tab.py:_wrap_with_filter).
+
Thay đổi
  • Bỏ ẩn dải tab Monitoring (8 mục ngang hàng) — không đổi accordion nav rail.
  • Bỏ cột Kết quả luôn-giống-nhau; thay bằng cột Hành động dạng badge màu theo name thật (prompt/run_command/install_package).
  • Vượt phạm vi "chỉ sắp xếp lại": 4 thẻ KPI, 3 dropdown lọc, phân trang và panel chi tiết trượt — cần bổ sung logic đếm/lọc/phân trang ở ui/monitoring_tab.py trước khi hiện thực, và 2/4 loại "Hành động" (path ngoài sandbox, mạng chặn) chưa có nguồn ghi log thật.
+
+
11. Monitoring ▸ Lịch sử gọi MCPui/monitoring_tab.py:132
+
+

Mọi lần agent gọi MCP server ngoài.

+
Hiện tại
Monitoring ▸ Lịch sử gọi MCP
+

Trên: 4 thẻ KPI · Giữa: ô tìm + nút ✨AI, lọc Server/Kết quả/Thời gian · Dưới: bảng có badge Kết quả (✓/✕ phân biệt thật theo ok) + phân trang, nhấp dòng mở panel chi tiết bên phải. Bảng hiện tại (ảnh trên) không có ô lọc/tìm kiếm nào — khác biệt không chủ đích so với 2 màn log kia.

+
Đề xuất — bố cục mới
+
+
+ +
📁 Báo cáo tài chính Q3▾
+
+ Đoạn chat mới
+
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
+
+
RECENTS
+
📁 Báo cáo tài chính Q3
+
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
+
+
+
Dashboard
Monitoring
+
👤 local · Ollama ▾
+
+
+
Monitoring
+
+
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
+
+
+
+
Lịch sử gọi MCP
+
+
⭳ Xuất CSV
⟳ Làm mới
+
+
+
▦
892Tổng cuộc gọi · +8.4%
+
✓
823Thành công · 92.3%
+
✕
69Thất bại · 7.7%
+
◔
7Hôm nay · cuộc gọi
+
+
+
✨ AI
+ + + +
✕ Xoá lọc
+
+
Thời gianAgentTài khoảnMáyToolKết quảChi tiết
+
Hiển thị 25 / 25 cuộc gọi
‹
1
›
+
+
+
+
✨
+ + +
+
Vấn đề
  • Dải tab Monitoring bị ẩn sau accordion — không thấy 7 màn Giám sát còn lại từ đây.
  • Bảng Lịch sử gọi MCP thật hiện không có ô tìm/lọc nào — còn thiếu hơn cả Sự kiện bảo mật: ui/monitoring_tab.py:166-168 tạo thẳng self.mcp_table = _EventTable() rồi thêm vào tab, không bọc qua _wrap_with_filter như Sự kiện bảo mật/Nhật ký hành động — đúng như đã ghi ở ảnh Hiện tại.
  • KPI · lọc Server/Kết quả/Thời gian · phân trang · panel chi tiết đều là tính năng mới: bản thật chỉ có bảng 7 cột (Thời gian/Vai trò/Tài khoản/Máy/Tên/Kết quả/Chi tiết), không tìm không lọc không phân trang (xem ui/monitoring_tab.py:_EventTable).
  • 5 server trong bảng mẫu (M365/GitHub/Filesystem/Brave Search/PostgreSQL) chỉ là ví dụ minh hoạ lấy từ docstring core/mcp_client.py — server thật phụ thuộc Admin đã cấu hình connector nào ở Settings, không có sẵn mặc định.
+
Thay đổi
  • Bỏ ẩn dải tab Monitoring (8 mục ngang hàng) — không đổi accordion nav rail.
  • Thêm ô tìm kiếm + nút ✨AI cho bảng này để đồng bộ với 2 bảng log kia — hiện là bảng log duy nhất trong 3 bảng không có tìm kiếm.
  • Vượt phạm vi "chỉ sắp xếp lại": 4 thẻ KPI, 3 dropdown lọc (Server/Kết quả/Thời gian), phân trang và panel chi tiết trượt — cần bổ sung logic đếm/lọc/phân trang ở ui/monitoring_tab.py trước khi hiện thực; danh sách server chỉ minh hoạ, không phải cấu hình mặc định.
+
+
12. Monitoring ▸ Nhật ký hành độngui/monitoring_tab.py:132
+
+

Nhật ký cấp ứng dụng: ai đổi cấu hình, ai chạy task.

+
Hiện tại
Monitoring ▸ Nhật ký hành động
+ +
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Nhật ký hành động
⭳ Xuất CSV
⟳ Làm mới
▤
25Tổng sự kiện · +6.2%
✓
19Thành công · 76%
✕
6Thất bại · 24%
🛡
4Chặn bảo mật
✨ AI
✕ Xóa lọc
Thời gianAgentLoạiMáyHành độngKết quảChi tiết
15/08 · 09:42CACowork AgentGọi MCPDESKTOP-DEMOms365__send_mail✓ Thành côngĐã gửi email đến nam.pdt@company.com — chủ đề "Báo cáo tuần Q3".
15/08 · 09:38CACowork AgentCông cụDESKTOP-DEMOread_file✓ Thành côngĐã đọc ./docs/installation.md (172 dòng).
15/08 · 09:31SASecurity AgentChặn bảo mậtDESKTOP-DEMOdangerous_command✕ Thất bạiChặn lệnh: rm -rf / --no-preserve-root
15/08 · 09:20CACowork AgentQuyềnDESKTOP-DEMOpath_outside_sandbox✕ Thất bạiChặn đọc C:\Users\NamPDT\Documents\personal.xlsx — ngoài sandbox.
15/08 · 08:55SASecurity AgentGọi MCPDESKTOP-DEMObrave__web_search✓ Thành côngTrả về 8 kết quả cho "CVE-2026-1234".
14/08 · 17:45SCscheduleCông cụDESKTOP-DEMOrun_command✓ Thành côngpython scripts/report.py — thoát mã 0.
Hiển thị 1-25 của 25 sự kiện
‹
1
›
✨
+ +
+
Vấn đề
  • Bảng thật (ui/monitoring_tab.py:_EventTable) gộp cả 4 loại sự kiện (tool_call/mcp_call/security_block/permission) vào tab này nhưng không có cột phân loại — phải tự suy ra loại hành động từ cột "Tên".
  • Bộ lọc thật (_wrap_with_filter) chỉ có 1 ô tìm + nút ✨AI; 3 dropdown Loại/Trạng thái/Thời gian, 4 thẻ KPI, phân trang và nút Xuất CSV trong 12.ui-redesign-action-logs.html đều là tính năng mới, chưa có trong code.
  • Bảng thật giới hạn ngầm _MAX_ROWS dòng mới nhất và không phân trang — không biết còn bao nhiêu sự kiện bị ẩn.
+
Thay đổi
  • Thêm cột Loại dạng pill màu (Công cụ/Gọi MCP/Chặn bảo mật/Quyền) trước cột Hành động, tái dùng đúng bảng màu .pill đã áp cho Sự kiện bảo mật (mục 10).
  • Vượt phạm vi "chỉ sắp xếp lại": 4 thẻ KPI, 3 dropdown lọc, phân trang và nút Xuất CSV cần bổ sung logic đếm/lọc/phân trang/export ở ui/monitoring_tab.py trước khi hiện thực — hiện bảng thật chỉ lọc bằng 1 ô tìm text.
+
+
13. Monitoring ▸ Trạng thái Agentui/monitoring_tab.py:132
+
+

Agent nào đang bật và nguồn định nghĩa.

+
Hiện tại
Monitoring ▸ Trạng thái Agent
+ +
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Trạng thái Agent
⟳ Làm mới
AgentTrạng tháiNguồn
👤Cowork Agent● RảnhLượt đang chạy của tab Cowork
⏱Task Agent● RảnhTask đang chạy trong Schedule Task
◈Knowledge Agent● RảnhÔ hỏi của GraphRAG
▦Planner Agent—Một giai đoạn trong lượt Cowork/Task đang chạy (update_plan) — không theo dõi riêng
⬡Reasoning Agent—Luồng suy luận (reasoning) của model trong lượt đang chạy — không theo dõi riêng
🛡Security Agent● BậtKiểm duyệt prompt/đính kèm/lệnh của Agent Security — chạy inline trong lượt đang chạy
✨
+
+
Vấn đề
  • Bảng thật 3 cột (Agent · Đang chạy · Nguồn, ui/monitoring_tab.py:_refresh_agent_status) chỉ dùng chữ/icon đơn cho trạng thái — khó quét nhanh dòng nào đang chạy khi bảng dài.
+
Thay đổi
  • Giữ nguyên 3 cột Agent · Trạng thái · Nguồn (không chuyển sang lưới thẻ, không thêm cột mới) — chỉ thêm icon màu theo loại agent trước tên và badge màu cho Trạng thái (xanh khi đang chạy/bật, xám khi "—") thay cho chữ thuần.
+
+
14. Monitoring ▸ Agents Adminui/monitoring_tab.py:132
+
+

Quản trị agent hệ thống. Cũng là nơi chọn model cho robot trợ giúp.

+
Hiện tại
Monitoring ▸ Agents Admin
+

Nút Kiểm tra: probe provider thật

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Agents Admin
⟳ Làm mới
+ Thêm
Agent quản lý hệ thống, dùng chung mọi máy (lưu trong thư mục dùng chung): agent trợ giúp và agent chạy Schedule Task. Đây KHÔNG phải agent để chọn trong Cowork hay Co4E.
TênVai tròModelKích hoạtTrạng tháiCập nhật
SASecurity AgentBảo mậtOllama — qwen2.5:7bOK11/08 09:30✎ 🗑
GAGraphRAG AgentTri thứcOllama — qwen2.5:14bOK10/08 14:22✎ 🗑
MAMonitor AgentGiám sátMặc định — qwen2.5:7bKiểm tra…09/08 11:05✎ 🗑
HAHelp AssistantHỗ trợMặc định — qwen2.5:7bChưa kiểm tra08/08 16:40✎ 🗑
✨
+
Kiểm kê control — 11 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
agent.name if agent else ô nhập—ui\agents_admin_tab.py:55giữ nguyên tại chỗ
agent.prompt if agent else ô nhập nhiều dòng—ui\agents_admin_tab.py:65giữ nguyên tại chỗ
—droplistself._refresh_model_comboui\agents_admin_tab.py:70→ menu tài khoản ở đáy sidebar
Lấy danh sách model thực tế của provider này để chọn từ dropnútself._load_live_modelsui\agents_admin_tab.py:89giữ nguyên tại chỗ
Kích hoạtô tick—ui\agents_admin_tab.py:98giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\agents_admin_tab.py:101giữ nguyên tại chỗ
0bảng—ui\agents_admin_tab.py:169giữ nguyên tại chỗ
Thêmnútself._addui\agents_admin_tab.py:178giữ nguyên tại chỗ
Sửanútself._editui\agents_admin_tab.py:182giữ nguyên tại chỗ
Xóanútself._deleteui\agents_admin_tab.py:185giữ nguyên tại chỗ
Kiểm tranútself._check_allui\agents_admin_tab.py:188giữ nguyên tại chỗ
+
+
Vấn đề
  • Cột Vai trò/Kích hoạt/Trạng thái chỉ là chữ hoặc icon đơn — khó phân biệt nhanh giữa các dòng khi bảng dài.
  • Bỏ [Kiểm tra tất cả] khỏi thanh công cụ chính làm mất lối vào hành động thật (_check_all trong ui/agents_admin_tab.py) — cần chỗ khác để gọi (icon riêng theo dòng, hoặc menu ⋯).
+
Thay đổi
  • Vai trò/Trạng thái chuyển sang badge màu + avatar viết tắt tên trước Tên agent; nút Sửa/Xoá dời vào từng dòng thay vì thanh công cụ rời bên dưới.
  • Thanh công cụ chính chỉ còn 2 nút: Làm mới (giống nút monitoring.refresh toàn màn hình) và + Thêm (giống nút agents_admin.add_btn thật) — Sửa/Xoá đã dời vào từng dòng nên không cần ở đây nữa.
+
+
15. Monitoring ▸ Công cụui/monitoring_tab.py:132
+
+

Bật/tắt công cụ dựng sẵn và khai báo kết nối ngoài. Màn duy nhất còn hiện dải tab bên trong.

+
Hiện tại
Monitoring ▸ Công cụ
+

Tool: công cụ dựng sẵn + tự kiểm tra Internet · Connector: MCP · REST · MS365 · Jira

+
Đề xuất — bố cục mới
Tab Tool
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Tool
Connector
📄
read_file
Đọc nội dung file text trong thư mục làm việc
📁
list_dir
Liệt kê file/thư mục con tại một đường dẫn
✎
write_file
Tạo file mới hoặc ghi đè toàn bộ file
✏
edit_file
Sửa một đoạn chính xác trong file có sẵn
▤
run_command
Chạy lệnh shell trong thư mục làm việc
⭳
install_package
Cài package Python (pip) vào môi trường app
⤓
fetch_url
Lấy nội dung trang web/tài liệu theo URL
🌐 Kiểm tra Internet
✓ Kết nối OK · 42ms
🔍
jira_search
Tìm issue Jira bằng JQL, trả về danh sách tóm tắt
🔗
jira_get_issue
Đọc chi tiết 1 issue Jira theo mã (vd ABX-123)
+ +
Tab Connector
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Tool
Connector
Kết nối tới connector bên ngoài
Một nơi duy nhất cho mọi nguồn tool ngoài — nhóm theo CAD (NX/CATIA/SolidWorks/AutoCAD), CAE (ANSA/ABAQUS/HyperWorks/ANSYS), MS365 (Microsoft 365/OneDrive/SharePoint) và Other (MCP server bất kỳ). MS365 tự kết nối qua server tích hợp sau khi đăng nhập; còn lại bạn trỏ mỗi connector tới MCP server bạn đã có hoặc REST API nó cung cấp (không kèm SDK hãng nào).
🔧CAD(NX / CATIA / SolidWorks / AutoCAD)
AutoCAD
MCP server (stdio)✎ Sửa  ·  🗑 Xóa
📐CAE(ANSA / ABAQUS / HyperWorks / ANSYS)
ANSA
MCP server (stdio)✎ Sửa  ·  🗑 Xóa
☁MS365(Microsoft 365 / OneDrive / SharePoint)
OneDrive
Tích hợp, tự kết nối
SharePoint
Tích hợp, tự kết nối
🔌Other(any generic MCP server)
Jira
Tích hợp, tự kết nối · chưa cấu hình✎ Sửa
+ Thêm connector…
✨
+
Kiểm kê control — 15 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
—ô tickon_toggleui\tools_admin_tab.py:32giữ nguyên tại chỗ
0bảng—ui\tools_admin_tab.py:57giữ nguyên tại chỗ
Kiểm tra Internetnútself._test_internetui\tools_admin_tab.py:72giữ nguyên tại chỗ
Làm mớinútself.refreshui\tools_admin_tab.py:80→ lên sidebar cùng RECENTS
Dán bất kỳ link Jira nào — tự điền Base URLô nhậpself._on_pasteui\connectors_panel.py:42giữ nguyên tại chỗ
jira.get('base_url', ô nhập—ui\connectors_panel.py:46giữ nguyên tại chỗ
jira.get('email', ô nhập—ui\connectors_panel.py:48giữ nguyên tại chỗ
jira.get('api_token', ô nhập—ui\connectors_panel.py:49giữ nguyên tại chỗ
Kiểm tra kết nốinútself._testui\connectors_panel.py:58giữ nguyên tại chỗ
Lưunútself._save_closeui\connectors_panel.py:60giữ nguyên tại chỗ
Kết nối tới connector bên ngoàiô tickself._on_connect_external_toggledui\connectors_panel.py:133giữ nguyên tại chỗ
—câylambda *_: self._ext_edit()ui\connectors_panel.py:144giữ nguyên tại chỗ
Thêm connector…nútself._ext_addui\connectors_panel.py:155giữ nguyên tại chỗ
Sửanútself._ext_editui\connectors_panel.py:159giữ nguyên tại chỗ
Xóanútself._ext_deleteui\connectors_panel.py:162giữ nguyên tại chỗ
+
+
Vấn đề
  • Màn duy nhất còn hiện dải tab — 7 màn Monitoring kia bị ẩn. Không nhất quán.
  • Tab Tool/Connector lọt thỏm trong một mục nav.
  • Cấu hình kết nối tách khỏi Settings → thiết lập ở 2 nơi.
  • Tab Tool liệt kê 9 tool thật (core/tools.py:TOOL_SPECS) dạng chữ phẳng (☑/☐) — không thấy ngay tool nào đang bật khi lướt nhanh.
  • Tab Connector là cây phân cấp 4 nhóm (CAD/CAE/MS365/Other, ui/connectors_panel.py) — phải bung từng nhóm mới thấy có gì bên trong.
+
Thay đổi
  • Bỏ ẩn dải tab cho toàn bộ Monitoring → 8 tab một hàng; Tool/Connector ngang hàng.
  • Tab Tool: 9 tool thành lưới thẻ căn trái (không kéo giãn lấp đầy hàng) — icon màu theo loại + công tắc gạt bật/tắt, thay ô tick trong list phẳng.
  • Tab Connector: 4 nhóm thật thành khối nhóm theo catalog (không gộp vào tab Tool) — mỗi catalog là 1 tiêu đề (icon + tên + loại máy/phần mềm), bên dưới là hàng thẻ nhỏ căn trái, mỗi thẻ 1 option thật kèm công tắc gạt bật/tắt (không phải badge tĩnh) — đúng bản chất mỗi option đều chuyển được trạng thái, nhất quán với công tắc ở tab Tool; giữ đúng cấu trúc phân cấp 2 tầng của cây thật thay vì gộp phẳng vào 1 thẻ/catalog. Công tắc "Kết nối ngoài" tổng vẫn ở trên cùng. Thẻ của connector do người dùng tự thêm (AutoCAD, ANSA) có thêm ✎ Sửa/🗑 Xóa đúng như _ext_edit/_ext_delete thật; Jira (built-in) chỉ có ✎ Sửa (mở dialog cấu hình riêng, không xóa được); OneDrive/SharePoint (built-in MS365) không có nút nào — thật cũng chỉ bật/tắt, không sửa/xóa.
  • Nút Kiểm tra Internet dời từ thanh trên (tách khỏi tool nào) vào đúng bên trong thẻ fetch_url, khớp tools_admin_tab.py:_fetch_url_desc_cell — nút này gắn với khả năng fetch_url, không phải một hành động chung của cả tab.
+
+
16. Monitoring ▸ Iconui/monitoring_tab.py:132
+
+

Thư viện icon, dùng lại khi đặt icon cho agent Co4E.

+
Hiện tại
Monitoring ▸ Icon
+ +
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Icon
+ Thêm icon
Dán SVG
Xoá
🔍 Tìm icon theo tên…
ICON TÍCH HỢP
✛
plus
✓
check
✕
close
✎
edit
🗑
trash
⟳
refresh
🔍
search
⚙
settings
🤖
robot
🛡
shield
🔧
wrench
★
star
ICON TÙY CHỈNH
🧠
brain
⌥
code
☁
cloud
✨
+
Kiểm kê control — 4 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Tìm icon có sẵn…ô nhậpself._reload_builtinui\icons_admin_tab.py:43giữ nguyên tại chỗ
Thêm tệp SVGnútself._add_iconui\icons_admin_tab.py:58giữ nguyên tại chỗ
Dán SVGnútself._add_from_svg_textui\icons_admin_tab.py:60giữ nguyên tại chỗ
Xóa tùy chỉnhnútself._delete_iconui\icons_admin_tab.py:62giữ nguyên tại chỗ
+
+
Vấn đề
  • Ô icon trong lưới hiện tại không có viền/hover rõ khi rê chuột hay khi đang chọn.
+
Thay đổi
  • Ô tìm kiếm thêm icon kính lúp bên trái; mỗi ô icon có viền accent khi hover/chọn — cùng ngôn ngữ thẻ với Agent/Công cụ.
+
+
17. Settingsui/settings_dialog.py:26
+
+

Thiết lập toàn app. Cuộn dọc, không mục lục.

+
Hiện tại
Settings
+

5 nhóm: Ngôn ngữ · Provider · Bảo mật (khoá mật khẩu) · Tham số · Routing

+
Đề xuất — bố cục mới
Cài đặt
Chungngôn ngữ · giao diện · khay
AI ProviderOllama · qwen2.5-coder
Bảo mật sandbox🔒 cần mở khoá
Tham sốđính kèm · GraphRAG · giới hạn
Auto Model Routingđang Tắt
Ngôn ngữ hiển thị
EnglishTiếng Việt日本語
Giao diện
SángHệ thốngTối
Giữ chạy nền trong khay hệ thống khi đóng
Hiện thông báo khay khi tác vụ xong hoặc lỗi
Nhà cung cấp AI
Ollama (local models)
Base URL
http://localhost:11434
API Key
••••••••
Model
qwen2.5-coder:7b ⭳ Tải · ⚗ Test kết nối
Nhập mật khẩu…
Unlock
🔒 Locked
Xác nhận trước khi Cowork chạy lệnh
Chặn mạng cho lệnh do agent chạy
Enable Agent Security (kiểm tra lệnh)
AI check commands
Đính kèm
Số tệp tối đa
−20+
tệp
Token tối đa mỗi tệp
−500+
nghìn
Cấu trúc & GraphRAG
Số node tối đa
−500+
node
Số cạnh tối đa
−500+
cạnh
Giới hạn sandbox
CPU
Không giới hạn
Bộ nhớ
2.048 MB
Disk I/O
2.048 MB
Chế độ
TắtAutoManual
Chính sách
Chất lượngChi phíĐộ trễCân bằng
Ngưỡng tăng điểm tối thiểu
−5+
%
Timeout xác nhận
−60+
giây
Chu kỳ đánh giá lại
−24+
giờ
Đồng thời mỗi provider
−2+
Judge model
(để trống · dùng model đang chọn)
⟳ Đánh giá lại ngay
Huỷ
Lưu
+ +
Kiểm kê control — 23 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Giữ chạy nền trong khay hệ thống khi đóng cửa sổô tick—ui\settings_dialog.py:56giữ nguyên tại chỗ
Hiện thông báo khay hệ thống khi tác vụ xong hoặc lỗiô tick—ui\settings_dialog.py:59giữ nguyên tại chỗ
—droplistself._on_provider_edit_changedui\settings_dialog.py:70→ menu tài khoản ở đáy sidebar
conf.get('base_url', ô nhập—ui\settings_dialog.py:77giữ nguyên tại chỗ
ô nhập—ui\settings_dialog.py:103giữ nguyên tại chỗ
Unlocknútself._sandbox_unlockui\settings_dialog.py:107giữ nguyên tại chỗ
Xác nhận trước khi Cowork chạy lệnhô tick—ui\settings_dialog.py:121giữ nguyên tại chỗ
Chặn mạng cho lệnh do agent chạyô tick—ui\settings_dialog.py:126giữ nguyên tại chỗ
Enable Agent Security (command validationô tick—ui\settings_dialog.py:136giữ nguyên tại chỗ
AI check commandsô tick—ui\settings_dialog.py:142giữ nguyên tại chỗ
Số tệp tối đa đính kèm vào một tin nhắn.ô số—ui\settings_dialog.py:178giữ nguyên tại chỗ
Giới hạn nội dung mỗi tệp đính kèm đưa vào prompt; phần vượtô số—ui\settings_dialog.py:183giữ nguyên tại chỗ
Giới hạn số node trong đồ thị Cấu trúc (0 = không giới hạn).ô số—ui\settings_dialog.py:194giữ nguyên tại chỗ
Giới hạn số cạnh trong đồ thị Cấu trúc (0 = không giới hạn).ô số—ui\settings_dialog.py:200giữ nguyên tại chỗ
routing.get('judge_model', ô nhập—ui\settings_dialog.py:279giữ nguyên tại chỗ
Đánh giá lại ngaynútself._routing_reassess_nowui\settings_dialog.py:282giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\settings_dialog.py:299giữ nguyên tại chỗ
valueô nhập—ui\settings_dialog.py:316giữ nguyên tại chỗ
Tảinútlambda: self._load_models(self.provider_combo.currentData(), combo, stui\settings_dialog.py:368giữ nguyên tại chỗ
Test kết nốinútlambda: self._test_connection(self.provider_combo.currentData(), statuui\settings_dialog.py:374giữ nguyên tại chỗ
codeô nhập—ui\settings_dialog.py:496giữ nguyên tại chỗ
Copy mãnútlambda: QGuiApplication.clipboard().setText(code)ui\settings_dialog.py:503giữ nguyên tại chỗ
Mở linknútlambda: webbrowser.open(flow.get('verification_uri_complete') or url)ui\settings_dialog.py:506giữ nguyên tại chỗ
+
+
Vấn đề
  • Năm group cuộn dọc, không mục lục.
  • Sandbox khoá bằng mật khẩu hard-code (settings_dialog.py:115).
  • Provider/Ngôn ngữ/Giao diện ở top bar, tách khỏi Settings.
+
Thay đổi
  • Thêm cột mục lục bên trái; gom Ngôn ngữ/Giao diện vào nhóm Chung (AI Provider vẫn là nhóm riêng như hiện tại, không gộp).
  • Đổi cách hiển thị field theo đúng loại dữ liệu: checkbox ☑/☐ → toggle switch, dropdown ít lựa chọn (2–4 giá trị) → segmented control, số nhập tay → stepper, 3 nhóm con trong Tham số → gom thành card — vẫn đúng field/giá trị mặc định thật từ ui/settings_dialog.py, chỉ đổi cách trình bày chứ không đổi dữ liệu.
+
+
18. Task Editorui/task_editor_dialog.py:55
+
+

Khai báo tác vụ hẹn giờ: nội dung, lịch lặp, phụ thuộc, thông báo.

+
Hiện tại
Task Editor
+

5 nhóm: Cơ bản · Lịch · Đầu vào · Phụ thuộc · Thực thi

+
Đề xuất — bố cục mới
+

Cùng 1 dialog TaskEditorDialog cho cả Thêm task và Sửa task — chỉ đổi tiêu đề (Thêm/Sửa), bố cục dưới đây dùng chung cho cả hai. Layout theo đúng kiểu màn Cài đặt: danh sách 5 nhóm bên trái thay cho 3 tab wizard, đúng 5 QGroupBox thật (không phải 3 bước tự đặt ra).

+
Sửa task
Cơ bảntiêu đề · loại chạy · model
Lịchchạy 1 lần · lặp lại
Đầu vàoprompt · tệp · liên kết
Phụ thuộctask kế tiếp · chờ hoàn tất
Thực thithử lại · timeout · phê duyệt
Chế độ
Bình thườngTự động
Tiêu đề
Gửi báo cáo doanh thu hằng ngày 08:00
Mô tả
Gom số liệu ngày hôm trước, dựng bảng và gửi email cho nhóm kế toán. ✨ Sinh prompt từ mô tả
Project
Báo cáo tài chính Q3
Loại chạy
AI AgentCo4E Flow
Nhà cung cấp
Ollama (local models)
Model
qwen2.5-coder:7b ⭳ Tải model
Skill
(Không dùng)
Ưu tiên
medium
Trạng thái
backlog
Bật lịch chạy
Thời điểm chạy
2026-08-17 08:00 AM
Lặp lại
Hằng ngày
Cron (khi lặp = tuỳ chỉnh)
0 8 * * * — chọn mẫu có sẵn
Chỉ ngày làm việc (bỏ T7/CN)
Bỏ qua ngày nghỉ lễ
Mã quốc gia lễ
VN
Kênh thông báo
KhôngTeamsOutlook
Email nhận thông báo
ketoan@company.com
Prompt thủ công
Tổng hợp số liệu doanh thu ngày hôm trước từ file đính kèm, dựng bảng tóm tắt.
Tệp đính kèm
📄 sales_template.xlsx
📄 Q3_report_outline.docx
+ Thêm tệp
🗑 Xoá
Liên kết
🔗 https://intranet.company.com/sales-dashboard
+ Thêm liên kết
🗑 Xoá
Task kế tiếp
(Không có)
Chế độ chạy tiếp
Không tự động chạy tiếp
Dùng output làm input task sau
Chờ các task này xong (fan-in)
☑ Gom số liệu doanh thu
☐ Dựng slide trình bày Q3
Số lần thử lại
0 lần
Timeout
600 giây
Cần phê duyệt (chờ bấm Chạy ngay)
Huỷ
Lưu
+ +
Kiểm kê control — 26 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
self.task.get('title', ô nhập—ui\task_editor_dialog.py:96giữ nguyên tại chỗ
self.task.get('description', ô nhập nhiều dòng—ui\task_editor_dialog.py:100giữ nguyên tại chỗ
nútself._gen_prompt_from_descriptionui\task_editor_dialog.py:102giữ nguyên tại chỗ
—droplistself._refresh_model_comboui\task_editor_dialog.py:135→ menu tài khoản ở đáy sidebar
Tải danh sách model của provider nàynútself._load_live_modelsui\task_editor_dialog.py:147giữ nguyên tại chỗ
AI agent = chạy một agent Cowork với model đã chọn. Co4E flodroplistself._on_run_kind_changedui\task_editor_dialog.py:170giữ nguyên tại chỗ
Flow Co4E đã lưu mà task này sẽ chạy (có sẵn hoặc của bạn).droplist—ui\task_editor_dialog.py:176giữ nguyên tại chỗ
Thông thường = chạy một lần (hoặc thủ công). Tự động = cronjdroplistself._on_task_mode_changedui\task_editor_dialog.py:188giữ nguyên tại chỗ
Bật lịch chạyô tick—ui\task_editor_dialog.py:217giữ nguyên tại chỗ
—droplistself._on_repeat_changedui\task_editor_dialog.py:232giữ nguyên tại chỗ
sched.get('cron_expression') or ô nhập—ui\task_editor_dialog.py:238giữ nguyên tại chỗ
Chọn một lịch mẫu — sẽ điền đúng cú pháp vào ô cron.droplistself._on_cron_sampleui\task_editor_dialog.py:242giữ nguyên tại chỗ
Chỉ ngày làm việc (bỏ T7/CNô tick—ui\task_editor_dialog.py:258giữ nguyên tại chỗ
Bỏ qua ngày nghỉ lễô tick—ui\task_editor_dialog.py:260giữ nguyên tại chỗ
sched.get('holiday_country', 'VN') or 'VNô nhập—ui\task_editor_dialog.py:262giữ nguyên tại chỗ
—droplistself._on_notify_changedui\task_editor_dialog.py:274giữ nguyên tại chỗ
ex_sched.get('notify_email', '') or ô nhập—ui\task_editor_dialog.py:280giữ nguyên tại chỗ
inp.get('manual_text') or ô nhập nhiều dòng—ui\task_editor_dialog.py:313giữ nguyên tại chỗ
—nútself._add_filesui\task_editor_dialog.py:324giữ nguyên tại chỗ
—nútlambda: self._remove_selected(self.files_list)ui\task_editor_dialog.py:328giữ nguyên tại chỗ
—nútself._add_linkui\task_editor_dialog.py:349giữ nguyên tại chỗ
—nútlambda: self._remove_selected(self.links_list)ui\task_editor_dialog.py:353giữ nguyên tại chỗ
—droplistself._check_chainui\task_editor_dialog.py:376giữ nguyên tại chỗ
Dùng output task này làm input task sauô tick—ui\task_editor_dialog.py:385giữ nguyên tại chỗ
Cần phê duyệt (không tự chạy theo lịch; chờ bấm Chạy ngayô tick—ui\task_editor_dialog.py:421giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\task_editor_dialog.py:430giữ nguyên tại chỗ
+
+
Vấn đề
  • Năm group dọc — form dài nhất app, không thấy đang ở bước nào.
+
Thay đổi
  • Đổi sang layout danh sách bên trái + panel bên phải giống màn Cài đặt (thay vì chia tab) — 5 mục danh sách khớp đúng 5 QGroupBox thật (Cơ bản · Lịch · Đầu vào · Phụ thuộc · Thực thi, xem ui/task_editor_dialog.py), luôn thấy đang ở nhóm nào và còn bao nhiêu nhóm nữa — nhất quán với cách điều hướng ở Cài đặt thay vì mỗi màn một kiểu.
+
+
19. Skills managerui/skills_dialog.py:108
+
+

Quản lý skill — khối hướng dẫn tái dùng, gõ /skill để chèn.

+
Hiện tại
Skills manager
+

Ô tick: chính là bật/tắt skill

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 13 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
skill.name if skill else ô nhập—ui\skills_dialog.py:34giữ nguyên tại chỗ
skill.description if skill else ô nhập—ui\skills_dialog.py:39giữ nguyên tại chỗ
Tạo từ mô tảnútself._gen_instructionsui\skills_dialog.py:44giữ nguyên tại chỗ
skill.instructions if skill else ô nhập nhiều dòng—ui\skills_dialog.py:50giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\skills_dialog.py:55giữ nguyên tại chỗ
Tự động tạonútself._auto_generateui\skills_dialog.py:127giữ nguyên tại chỗ
Từ file template…nútself._from_templateui\skills_dialog.py:132giữ nguyên tại chỗ
Nhập…nútself._importui\skills_dialog.py:136giữ nguyên tại chỗ
Xuất .mdnútself._export_mdui\skills_dialog.py:140giữ nguyên tại chỗ
Nhân bảnnútself._duplicateui\skills_dialog.py:144giữ nguyên tại chỗ
Sửanútself._editui\skills_dialog.py:148giữ nguyên tại chỗ
Xóanútself._deleteui\skills_dialog.py:151giữ nguyên tại chỗ
Đóngnútself.acceptui\skills_dialog.py:154giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
20. Skill editorui/skills_dialog.py:23
+
+

Soạn skill: tên, mô tả, hướng dẫn.

+
Hiện tại
Skill editor
+

✨: sinh hướng dẫn từ mô tả ngắn

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+ +
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
21. File view & AI editui/file_edit_dialog.py:50
+
+

Xem và nhờ AI sửa tệp, mở từ panel Files trong chat.

+
Hiện tại
File view & AI edit
+

Tệp nhị phân: trích văn bản, chỉ đọc · Lưu: tạo .bak trước khi ghi

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 8 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
spô nhập—ui\file_edit_dialog.py:66giữ nguyên tại chỗ
Mở file khác…nútself._browseui\file_edit_dialog.py:68giữ nguyên tại chỗ
Tải lại từ đĩanútself._reloadui\file_edit_dialog.py:73giữ nguyên tại chỗ
Mở một file để xem hoặc chỉnh sửa.ô nhập nhiều dòng—ui\file_edit_dialog.py:84giữ nguyên tại chỗ
Nói cho AI cách sửa file này (vd: 'sửa lỗi chính tả', 'dịch ô nhậpself._ai_editui\file_edit_dialog.py:94giữ nguyên tại chỗ
Sửa bằng AInútself._ai_editui\file_edit_dialog.py:97giữ nguyên tại chỗ
Lưunútself._saveui\file_edit_dialog.py:106giữ nguyên tại chỗ
Đóngnútself.rejectui\file_edit_dialog.py:110giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
22. Co4E agent editorui/co4e_agent_dialog.py:23
+
+

Định nghĩa agent Co4E: tính cách, quyền, model, skill.

+
Hiện tại
Co4E agent editor
+ +
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 9 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
agent.nameô nhập—ui\co4e_agent_dialog.py:32giữ nguyên tại chỗ
agent.role or 'AGENTô nhập—ui\co4e_agent_dialog.py:34giữ nguyên tại chỗ
agent.instructionsô nhập nhiều dòng—ui\co4e_agent_dialog.py:42giữ nguyên tại chỗ
Soạn bằng AInútself._ai_draftui\co4e_agent_dialog.py:46giữ nguyên tại chỗ
getatagent, 'context', ô nhập nhiều dòng—ui\co4e_agent_dialog.py:59giữ nguyên tại chỗ
Tải danh sách modelnútself._load_modelsui\co4e_agent_dialog.py:68giữ nguyên tại chỗ
Đính kèm tệpnútself._add_attachmentui\co4e_agent_dialog.py:99giữ nguyên tại chỗ
Bỏnútself._del_attachmentui\co4e_agent_dialog.py:102giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\co4e_agent_dialog.py:113giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
23. External connectorui/ext_connector_dialog.py:23
+
+

Khai báo kết nối ngoài, 2 chế độ.

+
Hiện tại
External connector
+

MCP (stdio): lệnh + tham số · REST: URL · key · header · Test: thử kết nối thật

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 11 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
—droplistself._apply_presetui\ext_connector_dialog.py:35giữ nguyên tại chỗ
connector.get('name', ô nhập—ui\ext_connector_dialog.py:43giữ nguyên tại chỗ
—droplistlambda i: self.stack.setCurrentIndex(self.mode_combo.currentData() != ui\ext_connector_dialog.py:47giữ nguyên tại chỗ
connector.get('command', ô nhập—ui\ext_connector_dialog.py:59giữ nguyên tại chỗ
'.join(connector.get('args', []) or []ô nhập—ui\ext_connector_dialog.py:62giữ nguyên tại chỗ
connector.get('base_url', ô nhập—ui\ext_connector_dialog.py:69giữ nguyên tại chỗ
connector.get('api_key', ô nhập—ui\ext_connector_dialog.py:72giữ nguyên tại chỗ
connector.get('auth_header', 'Authorizationô nhập—ui\ext_connector_dialog.py:75giữ nguyên tại chỗ
connector.get('auth_scheme', 'Bearerô nhập—ui\ext_connector_dialog.py:77giữ nguyên tại chỗ
Kiểm tra kết nốinútself._test_connectionui\ext_connector_dialog.py:90giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\ext_connector_dialog.py:103giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
24. Permission requestui/permission_dialog.py:13
+
+

Chốt chặn cuối trước khi agent làm việc có hậu quả. Bật Tự chạy thì bỏ qua bước này.

+
Hiện tại
Permission request
+

Xem trước: lệnh sắp chạy hoặc diff sắp ghi

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+ +
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
25. Admin agent editorui/agents_admin_tab.py:35
+
+

Soạn agent hệ thống: gắn vào chức năng nào, provider/model gì.

+
Hiện tại
Admin agent editor
+ +
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+ +
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
26. Login (dead screen — not wired)ui/login_dialog.py:57
+
+

Màn đăng nhập — đã dựng xong nhưng không nơi nào gọi. App khởi động thẳng với user \“local\”, quyền admin.

+
Hiện tại
Login (dead screen — not wired)
+

3 trang: Khởi tạo · Đăng nhập · Offline

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 9 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Thoátnútself.rejectui\login_dialog.py:87giữ nguyên tại chỗ
self.ctx.config.shared_dirô nhập—ui\login_dialog.py:121giữ nguyên tại chỗ
Chọn…nútself._bs_browseui\login_dialog.py:122giữ nguyên tại chỗ
Tạo tài khoản Adminnútself._bs_create_adminui\login_dialog.py:137giữ nguyên tại chỗ
cached_codeô nhập—ui\login_dialog.py:186giữ nguyên tại chỗ
self.ctx.config.auth.get('last_department', ô nhập—ui\login_dialog.py:199giữ nguyên tại chỗ
Đăng nhậpnútlambda: self._do_login(shared_dir)ui\login_dialog.py:209giữ nguyên tại chỗ
login.offline_btn', role=rolenútlambda: self._finish_login(Account(username=username, role=role, code=ui\login_dialog.py:256giữ nguyên tại chỗ
Thử lạinútself._retryui\login_dialog.py:263giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
27. Help dock — expanded panelui/help_agent_widget.py:79
+
+

Trợ lý dùng app, nổi ở góc phải và có mặt trên mọi màn. Cố tình không có công cụ — chỉ hỏi đáp cách dùng.

+
Hiện tại
Help dock — expanded panel
+

3 trạng thái: tab mép phải → huy hiệu → panel 340×460, luôn ghim góc dưới phải · 3 nút: › ẩn vào cạnh phải · — thu nhỏ về huy hiệu · tab mép để hiện lại · Model: chọn ở Monitoring ▸ Agents Admin, agent chức năng “help”

+
Đề xuất — bố cục mới
Trợ lý — thu gọn còn một chấm
Bình thường
cũ 84×64
✨
26×26 · không chữ, không chevron · −88% diện tích
Rê chuột / focus
✨AI Assistant
tên chỉ hiện lúc cần
Mở — “Ẩn” nằm trong menu ⋯
✨AI Assistant
— ⋯
Thu nhỏ về chấm
Ẩn trợ lý vào cạnh phải
Đổi model…
Xin chào Nam, mình giúp gì khi bạn dùng app?
Hỏi về cách dùng app…
Gửi
Đã ẩn
‹
tab mép 28px
+
Kiểm kê control — 5 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
selfnútself._show_launcherui\help_agent_widget.py:169Giữ — tab mép mở lại trợ lý, nới 16px → 28px
selfnútself._hide_to_edgeui\help_agent_widget.py:178→ mục “Ẩn trợ lý vào cạnh phải” trong menu ⋯ ở header panel
headernútself._collapseui\help_agent_widget.py:214giữ nguyên tại chỗ
rowô nhậpself._sendui\help_agent_widget.py:236giữ nguyên tại chỗ
rownútself._sendui\help_agent_widget.py:241giữ nguyên tại chỗ
+
+
Vấn đề
  • Hai vùng bấm cho một tính năng. Huy hiệu mở, chevron ẩn — nằm sát nhau, dễ bấm nhầm.
  • Vùng bấm quá nhỏ. Chevron rộng 18px, tab mép 16px (help_agent_widget.py:36-38) — dưới ngưỡng ~24px để bấm thoải mái, nhất là trên màn cảm ứng.
  • Ba trạng thái, thừa một. “Nép mép” và “huy hiệu” đều nghĩa là đang đóng; người dùng phải học hai kiểu đóng và hai đường quay lại.
  • Chiếm 84×64px vĩnh viễn ngay góc dưới phải (huy hiệu 64 + khe 2 + chevron 18 — help_agent_widget.py:34-37) — ở màn Cowork nó nằm đè lên vùng nút Gửi, dù cả ngày chỉ mở vài lần.
  • Huy hiệu dùng chính icon app (help_agent_widget.py:49-53, dự phòng là glyph robot) nên nhìn không khác gì icon cửa sổ; nhãn “Trợ lý App” / “App Assistant” / “アプリアシスタント” nói chỗ dùng chứ không nói nó là gì.
+
Thay đổi
  • Một chấm 26px, không chữ. Bỏ luôn chevron rời — chỗ chiếm giảm từ 84×64 xuống 26×26 (−88% diện tích). Vẫn là một vùng bấm, 26px ≥ ngưỡng bấm thoải mái.
  • Tên: “AI Assistant” — giữ nguyên ở cả 3 ngôn ngữ, sửa đúng một khoá help_agent.title (i18n.py:470) thay cho “App Assistant / Trợ lý App / アプリアシスタント”. Tên dài không còn là vấn đề vì nó không nằm trên màn lúc bình thường.
  • Chữ chỉ hiện khi rê chuột / focus bàn phím — chấm nở thành pill “✨ AI Assistant”. Lúc bình thường màn hình không có chữ nào thừa.
  • “Ẩn trợ lý” dời vào menu ⋯ trong header panel, cạnh “Thu nhỏ”. Không mất chức năng — chỉ chuyển tới lúc người dùng đang tương tác.
  • Thường ngày chỉ còn 2 trạng thái: đóng ↔ mở. Ẩn hẳn thành lựa chọn hiếm.
  • Tab mép nới từ 16px → 28px cho bấm được.
  • Ở màn có ô nhập dưới đáy (Cowork), chấm nâng lên trên hàng nhập, không đè nút Gửi.
+
+ +

Phần 4 — Phát triển lần sau

+

Những việc audit này phát hiện nhưng cố ý không làm, vì đều thêm +hoặc đổi chức năng — ngoài phạm vi “chỉ sắp xếp lại”.

+ +
ViệcChi tiếtLoại
Xuất log cho Nhật ký gần đâyHiện chỉ có “Xem tất cả” nhảy sang Action Logs (monitoring_tab.py:586). Xuất ra tệp là chức năng mới.chức năng mới
Bỏ auto-refresh, thay bằng nút bấmMonitoring làm mới mỗi 3 giây (monitoring_tab.py:43), Schedule 10 giây (schedule_task_tab.py:150), Dashboard 30 giây (dashboard_tab.py:175). Đề xuất: chỉ làm mới khi vào màn + một nút thủ công.đổi hành vi
Nút tạo skill mớiCả SkillsDialog lẫn SkillManagerTab đều không có. Chỉ tạo được qua AI / template / nhập / nhân bản. SkillEditDialog đã làm được việc này, chỉ thiếu lối vào.chức năng mới
Nút “chat mới” trong pane Lịch sửsidebar.py:68 khai báo tín hiệu new_chat, workspace_tab.py:241 đã nối — nhưng không nơi nào phát.hoàn thiện thứ đã dựng
Gọi ensure_starter_project()Hàm có docstring “đảm bảo luôn có ít nhất một project” nhưng không ai gọi, trong khi refresh() lại ghi “no auto-seed”. Hai chỗ mâu thuẫn.đổi hành vi
Mật khẩu Sandbox hard-codesettings_dialog.py:115 để mật khẩu mở khoá ngay trong mã nguồn.bảo mật
Hai lớp trùng tên CustomAgentcore/custom_agents.py:23 và core/co4e.py:117 — khác trường, khác thư mục lưu.dọn mã
Sáu màn không có đường vàoAccountsTab · LoginDialog · FlowBuilderDialog · AgentManagerTab · SkillManagerTab · McpServerEditDialog — tổng 64 control.quyết định giữ hay gỡ
+ +

Phần 5 — Màn chết (chỉ ghi nhận)

+

Sáu màn có trong code nhưng không tới được — tổng 64 control +(accounts 18 · flow_dialog 28 · agent_manager 7 · skill_manager 7 · mcp_servers 4). +Không nằm trong kiểm kê phía trên vì không có đường nào tới; chỉ ghi nhận, không đề xuất gỡ.

+
Thành phầnVị tríTình trạng
AccountsTabui/accounts_tab.py:153Quản lý tài khoản/nhóm đầy đủ, nhưng không được gắn vào MonitoringTab.
LoginDialogui/login_dialog.py:57Màn đăng nhập hoàn chỉnh; app.py:860 bỏ qua, hard-code user “local”.
FlowBuilderDialogui/flow_dialog.py:34Bị Co4E thay thế; không nơi nào gọi.
AgentManagerTabui/agent_manager_tab.py:28Chỉ dùng bởi FlowBuilderDialog → cũng không tới được.
SkillManagerTabui/skill_manager_tab.pyChỉ dùng bởi FlowBuilderDialog → cũng không tới được.
McpServerEditDialogui/mcp_servers_dialog.py:15Bị ExtConnectorEditDialog thay thế.
+
Ngoài phạm vi: settings_dialog.py:115 hard-code mật khẩu +Sandbox; hai lớp cùng tên CustomAgent +(custom_agents.py:23 · co4e.py:117).
+
+ + \ No newline at end of file diff --git a/docs/ui-audit_v2.html b/docs/ui-audit_v2.html new file mode 100644 index 0000000..393d721 --- /dev/null +++ b/docs/ui-audit_v2.html @@ -0,0 +1,1087 @@ + + +CoworkLocal — Audit UI/UX + +
+
+

CoworkLocal — Audit UI/UX

+

Hiện trạng 27 màn hình · đề xuất sắp xếp lại theo UI/UX kiểu Claude · +bảng màu Visual Studio Code · dựng lúc 21:26 15/08/2026

+
Ràng buộc: chỉ sắp xếp lại, không xoá/thêm chức năng. +Hai chỗ lệch được đánh dấu ở Phần 1.
+
Ảnh chụp: render offscreen trên bản sao dữ liệu +(scheduler tắt, hash dữ liệu thật trước/sau giống hệt). Nạp dữ liệu mẫu: 3 project · +6 chat · 10 task · 3 workflow · 45 ngày token. Ảnh đã chỉnh menu sáng đúng mục — +xem lỗi #9.
+
+ +
Mục lục +

Phần 1 — Điều hướng · Phần 2 — Luồng người dùng · +Phần 3 — 27 màn hình · Phần 4 — Màn chết

+
  1. Dashboard
  2. Schedule Task — Kanban
  3. Schedule Task — Calendar
  4. Workspace ▸ Project
  5. Workspace ▸ Cowork
  6. Workspace ▸ Co4E
  7. Workspace ▸ Folder
  8. Workspace ▸ GraphRAG
  9. Monitoring ▸ Tổng quan
  10. Monitoring ▸ Sự kiện bảo mật
  11. Monitoring ▸ Lịch sử gọi MCP
  12. Monitoring ▸ Nhật ký hành động
  13. Monitoring ▸ Trạng thái Agent
  14. Monitoring ▸ Agents Admin
  15. Monitoring ▸ Công cụ
  16. Monitoring ▸ Icon
  17. Settings
  18. Task Editor
  19. Skills manager
  20. Skill editor
  21. File view & AI edit
  22. Co4E agent editor
  23. External connector
  24. Permission request
  25. Admin agent editor
  26. Login (dead screen — not wired)
  27. Help dock — expanded panel
+ +

Phần 1 — Điều hướng

+
+
Hiện tại
Dashboard
+Schedule Task
+Workspace  ▼          ← nhánh accordion, tab strip bên trong BỊ ẨN
+   Project
+   Cowork             ← TỰ ẨN khi chưa chọn project
+   Co4E
+   Folder
+   GraphRAG           ← TỰ ẨN khi chưa chọn project
+Monitoring ▼          ← nhánh accordion, tab strip BỊ ẨN
+   Tổng quan
+   Sự kiện bảo mật
+   Lịch sử gọi MCP
+   Nhật ký hành động
+   Trạng thái Agent
+   Agents Admin
+   Công cụ
+   Icon
+
Đề xuất
[ + Đoạn chat mới ]     ← hành động chính, trên cùng
+──────────────
+Project                 ← màn đầu, giữ nguyên
+Cowork                  ← luôn hiện (mờ đi nếu chưa chọn project)
+Co4E
+Folder
+GraphRAG                ← luôn hiện (mờ đi nếu chưa chọn project)
+Schedule Task
+──────────────
+RECENTS                 ← History dời từ pane giữa lên đây
+  · thread gần nhất…
+──────────────  (ghim đáy — nhóm phụ trợ)
+Dashboard               ← vẫn 1 cú nhấp như cũ
+Monitoring              ← BỎ ẨN dải tab, đủ 8 mục nằm ngang trong trang:
+   [Tổng quan] [Sự kiện bảo mật] [Lịch sử gọi MCP]
+   [Nhật ký hành động] [Trạng thái Agent]
+   [Agents Admin] [Công cụ] [Icon]
+👤 local · Provider ▾   ← gom Provider/Language/Theme/Settings
+
+

Chín điểm đã xác minh trong code

+
Vấn đềChi tiết
Accordion 2 cấp, không phẳngapp.py:170 ghi là \“Claude-style\” nhưng là QTreeWidget accordion. Claude dùng danh sách phẳng.
Mục tự biến mấtworkspace_tab.py:485 ẩn Cowork và GraphRAG khi chưa chọn project.
Tab strip bị ẩnhide_tab_bar() ẩn dải tab có sẵn → menu là đường duy nhất.
History chỉ có ở tab Coworkworkspace_tab.py:169. Điểm làm tốt phải giữ: lịch sử đã gom theo project — lưu trong <project>/.cowork_history (config.py:590), hiển thị gom nhóm ở sidebar.py:193.
Monitoring gom 5 việc rời rạcChi phí · log bảo mật · quản trị agent · cấu hình tool · thư viện icon.
Dashboard trùng Monitoring ▸ Tổng quanCùng bộ StatCard + BudgetCard.
Top bar giữ thiết lậpProvider/Ngôn ngữ/Giao diện ở topbar, tách khỏi Cài đặt.
Header không đổi theo mànMọi sub-tab đều hiện \“Workspace — Projects\”.
LỖI: bấm mục con Workspace, menu nhảy về mục cha_goto gọi refresh() (app.py:726) → subtabs_changed vô điều kiện (workspace_tab.py:497) → takeChildren() (app.py:691) huỷ mục vừa bấm.
Đo được: 5/5 mục Workspace mất highlight, 0/8 mục Monitoring bị.
+ +

Khung ứng dụng — thanh trên cùng & thanh menu

+

Không thuộc màn nào nên liệt kê riêng.

+
Kiểm kê control — 8 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
—câylambda cur, _prev: self._navigate(cur); self._on_nav_clickapp.py:175giữ nguyên tại chỗ
MENUnútself._toggle_navapp.py:218Giữ — nút MENU gập sidebar (150↔54px)
—droplistself._on_provider_changedapp.py:559→ menu tài khoản ở đáy sidebar
—droplistself._on_language_changedapp.py:568→ menu tài khoản ở đáy sidebar
Cài đặtnútself._open_settingsapp.py:586→ menu tài khoản ở đáy sidebar
self._tray_open_actmenu chuột phải—app.py:329giữ nguyên tại chỗ
self._tray_quit_actmenu chuột phải—app.py:330giữ nguyên tại chỗ
_icon(icon_namemenu chuột phải—app.py:625giữ nguyên tại chỗ
+ +

Các nút thu gọn / mở rộng — giữ nguyên toàn bộ

+

App có 11 chỗ gập được. Thiết kế mới giữ đủ cả 11.

+
+
Menu mở rộng
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Nội dung
+
Menu đã gập (54px, chỉ còn icon)
+
+
▣
▤
◫
⌥
◈
▦
◔
◕
👤
Nội dung
+
+ +
Chỗ gập đượcCách dùngNguồnThiết kế mới
Thanh menu chínhMENU ‹ ở đầu thanh — gập còn dải icon (150px → 54px)app.py:409giữ
Pane Projectchevron ‹ trên đầu danh sách projectworkspace_tab.py:368giữ
Pane Lịch sửchevron trên đầu History, gập thành dải mỏngsidebar.py:167 · workspace_tab.py:247giữ
Pane Tệp trong chatchevron › — gập panel Files bên phải khung chatchat_panel.py:709giữ
Panel Hỏi đáp GraphRAGchevron › — gập panel agent bên phải đồ thịstructure_graph_view.py:615giữ
Panel cấu hình bước Co4Enút gập panel phải của canvasco4e_tab.py:767giữ
Panel Tin nhắn Co4Egập khung log dưới canvas — mặc định đang gậpco4e_tab.py:894giữ
Terminal trong Thư mụcbấm thanh tiêu đề để mở/gập — mặc định đang gậpterminal_panel.py:156giữ
Panel AI sửa tệpnút ✨ bật/tắt panel — mặc định đang ẩnfolder_tab.py:777giữ
Đồ thị ⇄ Tin nhắn (GraphRAG)nút đổi nội dung pane tráistructure_graph_view.py:416giữ — đổi thành cặp tab
Khối kết quả công cụ trong chatbấm tiêu đề để mở/gập output dàichat_view.py:253giữ
+ +

Từng màn hình đi đâu sau khi sửa

+

Không màn nào bị bỏ. Giám sát (Dashboard + 8 màn Monitoring) ở nhóm +phụ trợ đáy sidebar, mở ra thấy đủ 8 tab.

+ +
Màn hìnhHiện tại ở đâuSau khi sửa ở đâuChức năng
Màn hình làm việc
Workspace ▸ ProjectMenu ▸ Workspace (mở nhánh) ▸ ProjectSidebar ▸ Project — lên cấp 1, bớt 1 lần mở nhánhgiữ nguyên
Workspace ▸ CoworkMenu ▸ Workspace ▸ Cowork — biến mất nếu chưa chọn projectSidebar ▸ Cowork — luôn thấy, mờ khi chưa chọngiữ nguyên
Workspace ▸ Co4EMenu ▸ Workspace ▸ Co4ESidebar ▸ Co4Egiữ nguyên
Workspace ▸ Folder (“Thư mục”)Menu ▸ Workspace ▸ Thư mụcSidebar ▸ Foldergiữ nguyên
Workspace ▸ GraphRAGMenu ▸ Workspace ▸ GraphRAG — biến mất nếu chưa chọn projectSidebar ▸ GraphRAG — luôn thấygiữ nguyên
Schedule Task — KanbanMenu ▸ Schedule TaskSidebar ▸ Schedule Task ▸ tab Kanbangiữ nguyên
Schedule Task — LịchMenu ▸ Schedule Task ▸ combo đổi sang “Lịch”Sidebar ▸ Schedule Task ▸ tab Lịch — combo thành tab, dễ thấy hơngiữ nguyên
Giám sát & vận hành — phần bạn hỏi
DashboardMenu ▸ Dashboard (cấp 1)Sidebar ▸ vùng đáy — vẫn 1 cú nhấpgiữ nguyên
Monitoring ▸ Tổng quanMenu ▸ Monitoring (mở nhánh) ▸ Tổng quanSidebar ▸ Monitoring ▸ tab Tổng quangiữ nguyên
Monitoring ▸ Sự kiện bảo mậtMenu ▸ Monitoring ▸ Sự kiện bảo mậtSidebar ▸ Monitoring ▸ tab Sự kiện bảo mậtgiữ nguyên
Monitoring ▸ Lịch sử gọi MCPMenu ▸ Monitoring ▸ Lịch sử gọi MCPSidebar ▸ Monitoring ▸ tab Lịch sử gọi MCPgiữ nguyên
Monitoring ▸ Nhật ký hành độngMenu ▸ Monitoring ▸ Nhật ký hành độngSidebar ▸ Monitoring ▸ tab Nhật ký hành độnggiữ nguyên
Monitoring ▸ Trạng thái AgentMenu ▸ Monitoring ▸ Trạng thái AgentSidebar ▸ Monitoring ▸ tab Trạng thái Agentgiữ nguyên
Monitoring ▸ Agents AdminMenu ▸ Monitoring ▸ Agents AdminSidebar ▸ Monitoring ▸ tab Agents Admingiữ nguyên
Monitoring ▸ Công cụMenu ▸ Monitoring ▸ Công cụ ▸ tab con Tool | ConnectorSidebar ▸ Monitoring ▸ tab Công cụ ▸ Tool | Connectorgiữ nguyên
Monitoring ▸ IconMenu ▸ Monitoring ▸ IconSidebar ▸ Monitoring ▸ tab Icongiữ nguyên
Thành phần bị dời chỗ
History (lịch sử chat)Pane giữa, chỉ ở tab Cowork. Đã gom theo project.Sidebar ▸ RECENTS — vẫn gom theo project + “Tất cả project…”giữ, dễ tới hơn
Bộ chọn projectPane trái, chỉ ở tab ProjectThanh chọn đầu trang, dùng chung mọi màngiữ, dễ tới hơn
Provider · Ngôn ngữ · Giao diệnThanh trên cùng (topbar)Menu tài khoản ở đáy sidebar — gom cùng chỗ với Cài đặtgiữ nguyên
Nút Cài đặtThanh trên cùngMenu tài khoản ở đáy sidebargiữ nguyên
Hộp thoại & lớp phủ
11 hộp thoạiMở từ nút trên các màn tương ứngKhông đổi — vẫn mở từ đúng những nút đógiữ nguyên
Robot trợ giúp · Terminal · ComposerLớp phủ / panel thu gọnKhông đổigiữ nguyên
+
Dashboard/Monitoring xuống đáy nhưng vẫn là mục cấp 1, vẫn +một cú nhấp. Sidebar chia theo tần suất: việc hằng ngày ở trên, quản trị ở dưới.
+ +

Toàn bộ nhóm tab / lane / chế độ trong app

+

Đầy đủ, kể cả nhóm không hiện ra màn hình.

+ +
NhómSLCác mụcNhìn thấy?Nguồn
Thanh menu trái4 mụcDashboard · Schedule Task · Workspace · Monitoringcóapp.py:154
Workspace ▸ mục con5 mụcProject · Cowork · Co4E · Folder · GraphRAGkhông — hide_tab_bar(), và Cowork/GraphRAG còn tự ẩn khi chưa chọn projectworkspace_tab.py:43
Monitoring ▸ mục con8 mụcTổng quan · Sự kiện bảo mật · Lịch sử gọi MCP · Nhật ký hành động · Trạng thái Agent · Agents Admin · Công cụ · Iconkhông — hide_tab_bar()monitoring_tab.py:215
Công cụ ▸ tab con2 tabTool · Connectorcó — màn duy nhất còn hiện dải tabtools_admin_tab.py:91
Schedule ▸ chế độ xem2 chế độKanban · Lịchlà combo, không phải tabschedule_task_tab.py:171
Kanban ▸ lane trạng thái7 lanebacklog · scheduled · running · waiting_input · done · failed · pausedcắt ở mép phải, phải cuộn ngangtasks.py:24
Co4E ▸ sidebar3 tab iconWorkflows · Agents · Skillschỉ có icon, tên nằm trong tooltipco4e_tab.py:426
Co4E ▸ dải tab flow1 + NFlow Status (ghim) + mỗi workflow đang mở một tabcó, kiểu trình duyệtco4e_tab.py:556
Folder ▸ trình xem5 trangtrống · mã nguồn · HTML · tài liệu · ảnh (+ bảng tính)tự đổi theo đuôi tệp, không có tabfolder_tab.py:322
GraphRAG ▸ khung trái2 trangĐồ thị · Tin nhắnlà nút bấm, không phải tabstructure_graph_view.py:217
Dialog Tạo task bằng AI2 tabSinh bằng AI · Nhập từ Excelcóschedule_task_tab.py:530
Dialog Kết nối ngoài2 chế độMCP (stdio) · REST APIlà combo đổi trangext_connector_dialog.py:23
Dialog Đăng nhập (màn chết)3 trangKhởi tạo lần đầu · Đăng nhập · Dự phòng offlinekhông tới đượclogin_dialog.py:74
Dialog Flow Builder (màn chết)3 tabFlow · Agents · Skillskhông tới đượcflow_dialog.py:247
+
14 nhóm tab/lane, chỉ 4 nhóm hiện ra màn hình. +Số còn lại bị ẩn, thành combo, thành nút, hoặc không tới được.
+
Hai chỗ lệch — cần bạn quyết: +(1) Cowork/GraphRAG: biến mất → hiện nhưng mờ. +(2) Bỏ ẩn tab strip Monitoring (khôi phục thứ đã có).
+ +

Phần 2 — Luồng người dùng

+
LuồngHiện tạiSau khi sửa
Khởi động → màn đầupython -m cowork_local → MainWindow → Workspace ▸ Project
Không có bước đăng nhập (LoginDialog bị bỏ qua)
Giữ nguyên đích đến. Sidebar phẳng nên Project là mục đầu, không còn nằm dưới nhánh Workspace.
Tạo project → chatNav ▸ Workspace (mở nhánh) → Project → “+” → điền form → Lưu → chọn project → nav ▸ Cowork (mục vừa mới xuất hiện) → gõSidebar ▸ Project → “+” → Lưu → sidebar ▸ Cowork (luôn nhìn thấy) → gõ.
Bớt 1 bước mở nhánh, và menu không đổi hình giữa chừng.
Co4E: tạo → chạy → xem runNav ▸ Workspace ▸ Co4E → “+” trên dải tab → kéo agent từ sidebar → chọn node → sửa ở panel phải → Lưu → Chạy → bấm tab Flow Status để xemSidebar ▸ Co4E → “+ Workflow” trong danh sách trái → kéo → sửa phải → Lưu → Chạy.
Trạng thái run là một mục trong danh sách trái, không phải tab riêng.
Tạo & chạy scheduled taskNav ▸ Schedule → “+ Task” → form 5 group → Lưu → kéo thẻ vào lane Running (chạy ngay, không hỏi)Sidebar ▸ Schedule Task → “+ Task” → form 3 tab → Lưu → kéo vào Running (lane có viền cảnh báo).
Duyệt tệp → AI sửaNav ▸ Workspace ▸ Folder → chọn tệp → bấm ✨ mở panel AI → gõ lệnh → xem plan → xem diff → ApplySidebar ▸ Folder → chọn tệp → ✨ mở lớp phủ AI → gõ → plan → diff → Apply.
Luồng giữ nguyên; panel không còn chiếm chỗ cố định.
+ +

Truy vết chi tiết: “Đoạn chat mới”

+ +
Khía cạnhGiao diện cũGiao diện mớiCó đồng bộ không
Số lối vào1 — nút “Cuộc trò chuyện mới” trên toolbar Cowork (cowork_tab.py:34)2 — nút sidebar + nút toolbar cũ giữ nguyênTôi vẽ thêm nút sidebar mà chưa nói gì về nút cũ. Giữ cả hai (bỏ nút cũ là xoá chức năng), cùng gọi một hàm.
Thấy được khi nàoChỉ khi đang ở tab Cowork — mà tab này tự ẩn khi chưa chọn projectLuôn thấy trên sidebarMới dễ tới hơn. Chưa chọn project thì nút mờ đi.
Chat mới thuộc project nàoProject đang mở, ngầm định — không hiển thị ở đâuBộ chọn project ngay trên nút, trong sidebarCùng hành vi — vẫn là project đang mở (ctx.active_project_id), nhưng nay nhìn thấy và đổi được tại chỗ.
Đổi project trước khi tạoPhải rời Cowork → về tab Project → chọn dòng trong danh sách → quay lại Cowork → bấm nút. 4 bước.Bấm droplist ngay trên nút → chọn → bấm nút. 2 bước, không rời màn.Ít bước hơn, không thêm chức năng — vẫn là chọn project rồi tạo chat.
Bấm từ màn khácKhông xảy ra được — nút chỉ có trên CoworkChuyển sang Cowork rồi tạo chat mớiHành vi mới, cần thiết vì nút giờ ở mọi màn.
Việc thực sự làmnew_session() (chat_panel.py:1653): xoá messages · sinh session_id mới · dọn view, composer, plan, tệp vào/ra · turn đang chạy vẫn chạy nềnGiữ y nguyênKhông đổi.
Lưu chat cũTự lưu; History refresh qua history_changedGiữ y nguyên — RECENTS refreshKhông đổi.
+
Phát hiện: sidebar.py:68 khai báo tín hiệu +new_chat và workspace_tab.py:241 đã nối nó vào +_on_sidebar_new — nhưng không nơi nào phát tín hiệu này +(grep new_chat.emit → rỗng). Tức pane Lịch sử vốn được thiết kế để có nút +“chat mới” nhưng nút đó chưa bao giờ được thêm. Đề xuất đưa nút lên sidebar chính là +hoàn thiện ý định sẵn có trong code, không phải thêm mới.
+ +

Phần 3 — Từng màn hình

+
+
1. Dashboardui/dashboard_tab.py:35
+
+

Token đã tiêu và chi phí quy ra tiền, theo kỳ.

+
Hiện tại
Dashboard
+

Header: kỳ · granularity · metric · tiền tệ · 6 thẻ: Total · In · Out · Cache · Cost · Ngân sách · Biểu đồ: spline + đường so sánh kỳ trước · Thói quen: top task tốn token

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Dashboard
◀
08/03 – 08/09
▶
Theo tuần ▾
Chi phí ▾
USD ▾
⟳
$0.31Tổng chi phí · 57 lượt
395.4KTổng token
292.8KInput
102.7KOutput
108.9KCache
Tốn nhiều nhất: Dựng slide trình bày — 105.4K (26%)
+
Kiểm kê control — 10 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Kỳ trướcnútself._chart_prevui\dashboard_tab.py:59giữ nguyên tại chỗ
Kỳ saunútself._chart_nextui\dashboard_tab.py:67giữ nguyên tại chỗ
—droplistself._on_gran_changedui\dashboard_tab.py:71giữ nguyên tại chỗ
—droplistself._refresh_chartui\dashboard_tab.py:75giữ nguyên tại chỗ
Tiền tệ hiển thị (tỉ giá USD→VND/JPY cố định, sửa được trongdroplistself._on_currency_changedui\dashboard_tab.py:84giữ nguyên tại chỗ
nútself.refreshui\dashboard_tab.py:91→ lên sidebar cùng RECENTS
AI phân tíchnútself._ai_analyzeui\dashboard_tab.py:145giữ nguyên tại chỗ
Áp dụng chiến lược tiết kiệmnútself._apply_saving_strategyui\dashboard_tab.py:150giữ nguyên tại chỗ
f'{arrow} {self._title} ({self._count}nútself._toggle; self._toggleui\widgets.py:254giữ nguyên tại chỗ
—danh sáchself._emitui\widgets.py:261giữ nguyên tại chỗ
+
+
Vấn đề
  • Tám control trên một hàng header.
  • Trùng Monitoring ▸ Tổng quan: cùng StatCard + BudgetCard.
  • Sáu thẻ số bằng nhau — không thấy đâu là chỉ số chính.
+
Thay đổi
  • Header tách 2 hàng: thời gian / bộ lọc.
  • Nâng Chi phí làm thẻ chính, 4 thẻ còn lại phụ.
+
+
2. Schedule Task — Kanbanui/schedule_task_tab.py:70
+
+

Kanban các tác vụ hẹn giờ. Bộ lập lịch chạy nền dù màn này đóng.

+
Hiện tại
Schedule Task — Kanban
+

Lane: một cột mỗi trạng thái · Thẻ: kéo đổi lane · double-click sửa · chuột phải: Chạy ngay / Lịch sử

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Kanban
Lịch
+ Task
✨ AI tạo
BACKLOG (2)
[AI] Xuất DS khách hàng B2BChưa đặt lịch
Rà soát bảo mật trước releasehigh
ĐÃ LÊN LỊCH (2)
Quét lại chỉ mục ISO08-11 14:32
Báo cáo doanh thu 08:0008-09 14:32
ĐANG CHẠY (1) ⚠
Đồng bộ heartbeat trạm sạc08-08 · high
CHỜ DUYỆT (1)
Chờ kế toán duyệt số liệu T7Chưa đặt lịch
XONG (2)
Sao lưu CSDL hằng đêm08-07 · critical
[AI] Slide tổng kết Q3Thành công
LỖI (1)
Kiểm tra chứng chỉ TLScritical · Lỗi
TẠM DỪNG (1)
Dọn log cũ hơn 90 ngàylow
Đủ 7 lane theo core.tasks.STATUSES — không gộp, không giấu lane nào. Lane hẹp lại để vừa một màn, hết cuộn ngang.
+
Kiểm kê control — 23 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Thêm Tasknútself._add_taskui\schedule_task_tab.py:88giữ nguyên tại chỗ
AI tạo Tasknútself._ai_createui\schedule_task_tab.py:92giữ nguyên tại chỗ
—droplistself._on_view_changedui\schedule_task_tab.py:95→ đổi thành cặp tab Kanban | Lịch
len(runsbảngself._open_artifactui\schedule_task_tab.py:436giữ nguyên tại chỗ
QDialogButtonBox.Closenút hộp thoại—ui\schedule_task_tab.py:461giữ nguyên tại chỗ
Project/workspace mà agent của task này sẽ chạy trong đó — ádroplist—ui\schedule_task_tab.py:523giữ nguyên tại chỗ
vd: Mỗi thứ 2 lúc 9h, dùng Code đọc dữ liệu CAE mới tạo báo ô nhập nhiều dòng—ui\schedule_task_tab.py:537giữ nguyên tại chỗ
Đường dẫn tệp local, cách nhau bằng ;ô nhập—ui\schedule_task_tab.py:544giữ nguyên tại chỗ
Chọn tệp…nútself._ai_pick_filesui\schedule_task_tab.py:546giữ nguyên tại chỗ
https://… các link, cách nhau bằng ;ô nhập—ui\schedule_task_tab.py:553giữ nguyên tại chỗ
Tạo kế hoạchnútself._generateui\schedule_task_tab.py:557giữ nguyên tại chỗ
Tạo template Excel…nútself._export_templateui\schedule_task_tab.py:571giữ nguyên tại chỗ
Chọn file…nútself._pick_import_fileui\schedule_task_tab.py:576giữ nguyên tại chỗ
QDialogButtonBox.Ok | QDialogButtonBox.Cancelnút hộp thoại—ui\schedule_task_tab.py:592giữ nguyên tại chỗ
Chạy ngaymenu chuột phải—ui\schedule_task_tab.py:309giữ nguyên tại chỗ
Sửa taskmenu chuột phải—ui\schedule_task_tab.py:310giữ nguyên tại chỗ
Nhân bản taskmenu chuột phải—ui\schedule_task_tab.py:311giữ nguyên tại chỗ
schedtask.menu_resume' if paused else 'schedtask.menu_pausemenu chuột phải—ui\schedule_task_tab.py:313giữ nguyên tại chỗ
Xem logmenu chuột phải—ui\schedule_task_tab.py:314giữ nguyên tại chỗ
Lịch sử chạy…menu chuột phải—ui\schedule_task_tab.py:315giữ nguyên tại chỗ
Tạo task tiếp theo từ outputmenu chuột phải—ui\schedule_task_tab.py:316giữ nguyên tại chỗ
Xóa taskmenu chuột phải—ui\schedule_task_tab.py:318giữ nguyên tại chỗ
schedtask.menu_delete_selected', n=len(selectedmenu chuột phải—ui\schedule_task_tab.py:348giữ nguyên tại chỗ
+
+
Vấn đề
  • 7 lane bị cắt ở mép phải — lane Paused mất một nửa.
  • Kéo-thả có tác dụng thật: thả vào Running là chạy task ngay (schedule_task_tab.py:265), không cảnh báo.
  • Kanban/Calendar là combo, không phải tab.
+
Thay đổi
  • Giữ đủ 7 lane, thu hẹp cho vừa một màn. Không gộp lane nào.
  • Combo → cặp tab Kanban | Lịch.
  • Lane Running có viền cảnh báo.
+
+
3. Schedule Task — Calendarui/calendar_view.py:88
+
+

Cùng dữ liệu Kanban, xếp theo ngày.

+
Hiện tại
Schedule Task — Calendar
+

Ô ngày: nút + tạo task lúc 09:00 ngày đó

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 7 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
+nútlambda: self.add_requested.emit(self._date_str)ui\calendar_view.py:43giữ nguyên tại chỗ
—danh sáchself._on_item_clickedui\calendar_view.py:49giữ nguyên tại chỗ
Trướcnútlambda: self._shift(-1)ui\calendar_view.py:100giữ nguyên tại chỗ
Hôm naynútself._go_todayui\calendar_view.py:103giữ nguyên tại chỗ
Saunútlambda: self._shift(1)ui\calendar_view.py:105giữ nguyên tại chỗ
—droplistself._on_granularity_changedui\calendar_view.py:110giữ nguyên tại chỗ
—danh sáchlambda item: self.show_month(self.anchor.year, item.data(Qt.UserRole))ui\calendar_view.py:213giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
4. Workspace ▸ Projectui/workspace_tab.py:188
+
+

Khai báo project — đơn vị gom nhóm của app. Mỗi project có sandbox riêng và Instructions chèn vào mọi chat. Đây là bộ chọn project duy nhất.

+
Hiện tại
Workspace ▸ Project
+

Trái: danh sách project — chỉ hiện ở tab này · Phải: Tên · Mô tả · Instructions · thư mục

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Quản lý project
+ Project mới
PROJECT‹
Trạm sạc EV — Cổng vận hành6 đoạn chat · 4 task
Báo cáo tài chính Q32 đoạn chat · 3 task
Cổng tra cứu tài liệu ISO1 đoạn chat · 1 task
Tên
Báo cáo tài chính Q3
Mô tả
Tự động gom số liệu từ Excel phòng ban, dựng bảng tổng hợp và slide.
Instructions
Đơn vị tiền tệ mặc định VND. Làm tròn tới nghìn đồng.
Mọi con số phải truy được về file nguồn.
Thư mục làm việc
…\workspaces\bao-cao-tai-chinh-q3
Đổi
Mở
Lưu project
+
Kiểm kê control — 11 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Thu gọn danh sách projectnútlambda: self._set_projects_collapsed(True)ui\workspace_tab.py:90giữ nguyên tại chỗ
—danh sáchself._on_selectui\workspace_tab.py:97→ giữ ở màn Quản lý project + thêm thanh chọn đầu trang
Project mớinútself._createui\workspace_tab.py:101giữ nguyên tại chỗ
Xóanútself._deleteui\workspace_tab.py:105giữ nguyên tại chỗ
—dải tabself._on_tab_changedui\workspace_tab.py:129giữ nguyên tại chỗ
project.nameô nhập—ui\workspace_tab.py:193giữ nguyên tại chỗ
project.descriptionô nhập—ui\workspace_tab.py:194giữ nguyên tại chỗ
vd: "Trả lời bằng tiếng Việt. Team đang xây tool báo cáo X; ô nhập nhiều dòng—ui\workspace_tab.py:203giữ nguyên tại chỗ
Đổi thư mục…nútself._pick_folderui\workspace_tab.py:211giữ nguyên tại chỗ
Mở thư mụcnútself._open_workspaceui\workspace_tab.py:214giữ nguyên tại chỗ
Lưu projectnútself._saveui\workspace_tab.py:223giữ nguyên tại chỗ
+
+
Vấn đề
  • Pane trái đổi danh tính theo tab (workspace_tab.py:310-338): Project → danh sách project, Cowork → History, còn lại → trống.
  • History chỉ tới được từ tab Cowork.
  • Bộ chọn project chỉ có ở tab Project.
  • 2/3 chiều cao dưới là khoảng trống chết.
  • Header “Workspace — Projects” hiện ở mọi sub-tab.
+
Thay đổi
  • History lên sidebar thành RECENTS, luôn thấy.
  • Thanh chọn project ở đầu trang, dùng chung mọi màn.
  • Pane trái cố định, không đổi danh tính.
  • Header đổi theo màn.
+
+
5. Workspace ▸ Coworkui/cowork_tab.py:21
+
+

Chat với agent. Agent đọc/ghi tệp trong sandbox, chạy lệnh, gọi MCP.

+
Hiện tại
Workspace ▸ Cowork
+

Lịch sử: chat gom theo project; nhãn đậm = tên project, chỉ là nhãn · Hội thoại: bong bóng theo trục thời gian · Files: tệp đầu ra, gập được · Composer: Enter gửi · /skill · /agent · Hàng dưới: thư mục sandbox (chỗ thứ 2 lộ project) · model · Định tuyến · Tự chạy

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Gom số liệu doanh thu
qwen2.5-coder
Skills
Cuộc trò chuyện mới
Có 6 file Excel trong thư mục input, gom lại thành 1 bảng tổng hợp giúp mình.
Đã đọc cả 6 file. Lưu ý: PB_Marketing.xlsx để cột “Doanh thu” ở cột F thay vì D và có 3 dòng trống ở cuối.

Mình đã chuẩn hoá và xuất tonghop_q3.xlsx — 1.284 dòng, tổng 42.7 tỷ VND.
TỆP ĐẦU RA (3)›
tonghop_q3.xlsx
BaoCao_Q3.pptx
README.md
Nhập yêu cầu… (Enter để gửi)
📎
Gửi
Agent: qwen2.5-coder · Định tuyến: Tắt  ·  ↓292.8K ↑102.7K · $0.31  ·  📁 bao-cao-tai-chinh-q3
+
Kiểm kê control — 26 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Skillsnútself._open_skills_managerui\cowork_tab.py:30giữ nguyên tại chỗ
Cuộc trò chuyện mớinútself.new_sessionui\cowork_tab.py:34giữ nguyên tại chỗ
Thư mục Local…nútself._pick_output_folderui\cowork_tab.py:48giữ nguyên tại chỗ
Model/agent riêng cho tab này — độc lập với tab kiadroplistself._on_agent_changedui\chat_panel.py:165giữ nguyên tại chỗ
Nénnútself._compress_messagesui\chat_panel.py:177giữ nguyên tại chỗ
Thu gọn bảng Filesnútlambda: self._set_io_collapsed(True)ui\chat_panel.py:239giữ nguyên tại chỗ
app_icon('linkmenu chuột phải—ui\chat_panel.py:439giữ nguyên tại chỗ
app_icon('editmenu chuột phải—ui\chat_panel.py:440giữ nguyên tại chỗ
Nhấp đúp để xoá một tin nhắn khỏi hàng đợidanh sáchself._remove_queue_itemui\composer.py:406giữ nguyên tại chỗ
Bấm trên thẻ để gỡ tệp đính kèm nhầmdanh sáchself._remove_attachmentui\composer.py:420giữ nguyên tại chỗ
nútself._pick_attachmentsui\composer.py:443giữ nguyên tại chỗ
composer.queue_btn') if self._busy else 'composer.sendnútself._on_submitui\composer.py:446giữ nguyên tại chỗ
Dừngnútself.stop_requested.emitui\composer.py:450giữ nguyên tại chỗ
Gỡ tệp này (đính kèm nhầmnútlambda _=False, path=p: self._remove_attachment_path(path)ui\composer.py:599giữ nguyên tại chỗ
Thu gọn bảng Lịch sửnútself.collapse_requested.emitui\sidebar.py:104giữ nguyên tại chỗ
Tìm theo tiêu đề hoặc nội dung…ô nhậpself.refresh; self.refreshui\sidebar.py:119giữ nguyên tại chỗ
nútself.refreshui\sidebar.py:123→ lên sidebar cùng RECENTS
—câyself._on_item; self._context_menuui\sidebar.py:132giữ nguyên tại chỗ
Làm mớinútself.refresh_requested.emitui\sidebar.py:147giữ nguyên tại chỗ
sidebar.menu.unpin') if pinned else 'sidebar.menu.pinmenu chuột phải—ui\sidebar.py:305giữ nguyên tại chỗ
Đổi tên…menu chuột phải—ui\sidebar.py:306giữ nguyên tại chỗ
Xóamenu chuột phải—ui\sidebar.py:307giữ nguyên tại chỗ
sidebar.menu.delete_selected', n=len(selectedmenu chuột phải—ui\sidebar.py:332giữ nguyên tại chỗ
titlenútself._toggle_bodyui\chat_view.py:228giữ nguyên tại chỗ
Tự động định tuyến model cho khung chat này. +Tắt: luôn dùng droplistself._on_changedui\routing_toggle.py:66giữ nguyên tại chỗ
Tự chạyô tickself._on_toggledui\routing_toggle.py:133giữ nguyên tại chỗ
+
+
Vấn đề
  • Hàng dưới composer nhồi 5 control + usage/cost + nút thư mục (chat_panel.py:142-181).
  • Không có lối tắt “chat mới” — phải chọn project → mở nav → Cowork.
  • Màn duy nhất thấy được History.
  • Ba pane bóp vùng đọc hội thoại còn chưa tới 60% bề ngang.
  • Biết project nào, nhưng không đổi được. Project hiện ở 2 chỗ (nhãn nhóm Lịch sử, nhãn thư mục đáy) — cả hai chỉ là nhãn. Đổi phải quay về tab Project (workspace_tab.py:323).
+
Thay đổi
  • Bộ chọn project lên sidebar — nó là trạng thái toàn cục (ctx.active_project_id), không phải của riêng màn nào.
  • Bộ chọn project + nút “+ Đoạn chat mới” đặt cạnh nhau ở đầu sidebar: chọn project rồi bấm, không rời màn. Nút cũ trên toolbar giữ nguyên.
  • History lên sidebar, vẫn gom theo project + mục “Tất cả project…”.
  • Usage/cost xuống thanh trạng thái; vùng gõ chỉ còn nhập · đính kèm · gửi.
+
+
6. Workspace ▸ Co4Eui/co4e_tab.py:228
+
+

Xưởng dựng workflow node-graph. Lưu toàn cục, không theo project.

+
Hiện tại
Workspace ▸ Co4E
+

Sidebar: 3 tab icon: Workflows · Agents · Skills — kéo thả được · Dải tab: Flow Status ghim + mỗi workflow một tab · Canvas: node và cạnh · Phải: cấu hình bước đang chọn

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Quy trình phát triển tính năng
+ Bước
Auto ▾
▷ Chạy
WORKFLOWS‹
Quy trình phát triển tính năng5 bước · đã lưu
Rà soát bảo mật định kỳ2 bước · đã lưu
Dựng báo cáo từ Excel3 bước · đã lưu
AGENTS (5)
Phân tích yêu cầuANALYST
Thiết kế giải phápARCHITECT
Lập trình viênCODER
Kiểm thửTESTER
Soạn tài liệuWRITER
SKILLS (5)
Rà soát bảo mật · Viết test trước · Chuẩn hoá Excel · …
LẦN CHẠY (6)
✓ Quy trình phát triển5/5 · 08-08 15:32
✕ Rà soát bảo mật3/5 · 08-06 16:32
■ Dựng báo cáo từ Excel1/5 · 08-04 18:32
Phân tích yêu cầu
→
Thiết kế
→
Lập trình viên
→
Kiểm thử
CẤU HÌNH BƯỚC›
▾ Cơ bảnLập trình viên · CODER
Hiện thực theo thiết kế. Viết test trước khi sửa logic nghiệp vụ.
▸ Model & quyềnqwen2.5-coder · full
▸ Skills & tệpViết test trước
▸ Agent song songchưa có
+
Kiểm kê control — 52 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
—danh sáchlambda _i: self._accept()ui\co4e_tab.py:144giữ nguyên tại chỗ
×nútlambda: self._close_flow_tab_button(btn)ui\co4e_tab.py:341giữ nguyên tại chỗ
Chạynútself._run_selected_in_backgroundui\co4e_tab.py:459giữ nguyên tại chỗ
Mớinútself._new_agentui\co4e_tab.py:476giữ nguyên tại chỗ
Quản lý skill…nútself._manage_skillsui\co4e_tab.py:493giữ nguyên tại chỗ
tip_keynútslotui\co4e_tab.py:502giữ nguyên tại chỗ
—dải tabself._on_flow_tab_changed; self._close_flow_tabui\co4e_tab.py:556→ bỏ; chọn workflow từ danh sách trái
+nútself._new_workflowui\co4e_tab.py:582giữ nguyên tại chỗ
self._wf.nameô nhậpself._on_name_changedui\co4e_tab.py:631giữ nguyên tại chỗ
Thêmnútself._add_blank_stepui\co4e_tab.py:636giữ nguyên tại chỗ
Lưunútlambda: self._save(as_template=False)ui\co4e_tab.py:639giữ nguyên tại chỗ
Auto = mỗi bước tự lập kế hoạch rồi chạy · Plan = chỉ lập kếdroplistself._on_mode_changedui\co4e_tab.py:645giữ nguyên tại chỗ
Chạynútself._on_run_clickedui\co4e_tab.py:650giữ nguyên tại chỗ
shortnútself._open_workspace_folderui\co4e_tab.py:693giữ nguyên tại chỗ
Dừngnútself._stop_selected_runui\co4e_tab.py:701giữ nguyên tại chỗ
Đổi tênnútself._rename_selected_runui\co4e_tab.py:706giữ nguyên tại chỗ
Xóanútself._delete_selected_runui\co4e_tab.py:710giữ nguyên tại chỗ
Xóa đã xongnútlambda: self.manager.clear_finished()ui\co4e_tab.py:714giữ nguyên tại chỗ
0bảngself._open_run_from_table; self._runs_context_menuui\co4e_tab.py:722giữ nguyên tại chỗ
Thu gọn bảng cấu hìnhnútself._toggle_configui\co4e_tab.py:747giữ nguyên tại chỗ
Mở rộng khung tin nhắnnútself._toggle_messagesui\co4e_tab.py:841giữ nguyên tại chỗ
Gửinútself._chat_sendui\co4e_tab.py:873giữ nguyên tại chỗ
icon('editmenu chuột phải—ui\co4e_tab.py:1014giữ nguyên tại chỗ
icon('editmenu chuột phải—ui\co4e_tab.py:1015giữ nguyên tại chỗ
icon('branchmenu chuột phải—ui\co4e_tab.py:1016giữ nguyên tại chỗ
icon('playmenu chuột phải—ui\co4e_tab.py:1017giữ nguyên tại chỗ
icon('trashmenu chuột phải—ui\co4e_tab.py:1018giữ nguyên tại chỗ
Mở flowmenu chuột phải—ui\co4e_tab.py:1400giữ nguyên tại chỗ
Mở thư mục outputmenu chuột phải—ui\co4e_tab.py:1404giữ nguyên tại chỗ
Đổi tênmenu chuột phải—ui\co4e_tab.py:1405giữ nguyên tại chỗ
Xóamenu chuột phải—ui\co4e_tab.py:1406giữ nguyên tại chỗ
step.labelô nhậpself._on_editui\co4e_config_panel.py:44giữ nguyên tại chỗ
step.roleô nhậpself._on_editui\co4e_config_panel.py:48giữ nguyên tại chỗ
—ô nhập nhiều dòngself._on_editui\co4e_config_panel.py:60giữ nguyên tại chỗ
Soạn bằng AInútself._ai_draftui\co4e_config_panel.py:63giữ nguyên tại chỗ
Thông tin/ngữ cảnh bổ sung cho agent hoặc step này (thêm vàoô nhập nhiều dòngself._on_editui\co4e_config_panel.py:77giữ nguyên tại chỗ
Tải danh sách modelnútself._load_modelsui\co4e_config_panel.py:87giữ nguyên tại chỗ
—droplistself._on_editui\co4e_config_panel.py:97giữ nguyên tại chỗ
Tự kiểm traô tickself._on_editui\co4e_config_panel.py:104giữ nguyên tại chỗ
—ô sốself._on_editui\co4e_config_panel.py:106giữ nguyên tại chỗ
Đính kèm tệpnútself._add_attachmentui\co4e_config_panel.py:125giữ nguyên tại chỗ
Bỏnútself._del_attachmentui\co4e_config_panel.py:128giữ nguyên tại chỗ
—danh sáchself._edit_subagentui\co4e_config_panel.py:141giữ nguyên tại chỗ
Thêmnútself._add_subagentui\co4e_config_panel.py:144giữ nguyên tại chỗ
Bỏnútself._del_subagentui\co4e_config_panel.py:147giữ nguyên tại chỗ
Chạynútlambda: self.run_node.emit(self._node_id)ui\co4e_config_panel.py:159giữ nguyên tại chỗ
Chạy từ đâynútlambda: self.run_from.emit(self._node_id)ui\co4e_config_panel.py:163giữ nguyên tại chỗ
Xóa bướcnútlambda: self.delete_node.emit(self._node_id)ui\co4e_config_panel.py:166giữ nguyên tại chỗ
+ Add next stepmenu chuột phải—ui\co4e_canvas.py:188giữ nguyên tại chỗ
→ Connect from heremenu chuột phải—ui\co4e_canvas.py:189giữ nguyên tại chỗ
🗑 Delete stepmenu chuột phải—ui\co4e_canvas.py:190giữ nguyên tại chỗ
🗑 Delete connectionmenu chuột phải—ui\co4e_canvas.py:368giữ nguyên tại chỗ
+
+
Vấn đề
  • Bốn lớp điều hướng chồng nhau: nav → tab icon sidebar → dải tab flow → panel phải.
  • Dải tab flow lặp lại danh sách Workflows ngay bên trái.
  • Panel cấu hình 11 trường dọc, phải cuộn.
+
Thay đổi
  • Bỏ dải tab flow; chọn workflow từ danh sách trái.
  • 3 tab icon → 3 mục có nhãn cùng danh sách.
  • Còn 2 lớp: chọn trái → sửa phải.
+
+
7. Workspace ▸ Folderui/folder_tab.py:238
+
+

Duyệt tệp + nhờ AI sửa. AI không ghi đè — đề xuất diff, bấm Apply mới ghi.

+
Hiện tại
Workspace ▸ Folder
+

Cây trái: hệ thống tệp thật · Viewer: code / HTML / PDF / ảnh / bảng tính · Panel AI: yêu cầu → plan → diff → Apply · Terminal: shell thật, không qua sandbox

+
Đề xuất — bố cục mới
📁 Trạm sạc EV — Cổng vận hành▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Trạm sạc EV — Cổng vận hành
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Thư mục
…\workspaces\tram-sac-ev
Sửa
✨ AI
Lưu
📁 src
 📄 main.py
 📁 billing
  📄 session.py
📁 tests
 📄 test_stations.py
📄 README.md
# billing/session.py
def close_session(sid):
  s = repo.get(sid)
  s.ended_at = None # ← lỗi tính tiền
  return bill(s)
✨ AI SỬA TỆP›
Sửa lỗi tính dư tiền khi phiên bị ngắt đột ngột.
Lấy mốc heartbeat cuối làm ended_at.
- s.ended_at = None
+ s.ended_at = last_heartbeat(sid)
Bỏ
Áp dụng
▸ Terminal — bật khi cần
+
Kiểm kê control — 15 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
self._rootô nhập—ui\folder_tab.py:265giữ nguyên tại chỗ
Mở thư mụcnútself._pick_rootui\folder_tab.py:267giữ nguyên tại chỗ
folder.edit') if not self.mode_btn.isChecked() else 'folder.nútself._toggle_edit_modeui\folder_tab.py:299giữ nguyên tại chỗ
AI Editnútself._toggle_ai_panelui\folder_tab.py:304giữ nguyên tại chỗ
Lưunútself._saveui\folder_tab.py:309giữ nguyên tại chỗ
Mở bằng app ngoàinútself._open_externalui\folder_tab.py:315giữ nguyên tại chỗ
len(rowsbảng—ui\folder_tab.py:534giữ nguyên tại chỗ
Mô tả chỉnh sửa… (vd: thêm xử lý lỗiô nhậpself._ai_sendui\folder_tab.py:736giữ nguyên tại chỗ
Gửinútself._ai_sendui\folder_tab.py:740giữ nguyên tại chỗ
Hủynútself._ai_discardui\folder_tab.py:752giữ nguyên tại chỗ
Áp dụngnútself._ai_applyui\folder_tab.py:755giữ nguyên tại chỗ
terminal.expand_tooltip') if self._collapsed else 'terminal.nútself.toggleui\terminal_panel.py:85giữ nguyên tại chỗ
—ô nhập nhiều dòng—ui\terminal_panel.py:105giữ nguyên tại chỗ
Chạynútself._run_currentui\terminal_panel.py:130giữ nguyên tại chỗ
Mở bằng LibreOfficenútself._open_externalui\libreoffice_view.py:97giữ nguyên tại chỗ
+
+
Vấn đề
  • Năm vùng cùng lúc: path · cây · viewer · panel AI · terminal.
  • Hàng nút viewer trộn 5 chức năng khác loại.
+
Thay đổi
  • Path bar gộp vào tiêu đề.
  • Panel AI thành lớp phủ phải; terminal xuống đáy dạng thanh mỏng.
+
+
8. Workspace ▸ GraphRAGui/structure_graph_view.py:188
+
+

Đồ thị tri thức về cấu trúc mã/tài liệu + agent hỏi đáp trên đó.

+
Hiện tại
Workspace ▸ GraphRAG
+

Đồ thị: node theo loại, cạnh có nhãn quan hệ · Phải: hỏi đáp dựa trên đồ thị

+
Đề xuất — bố cục mới
📁 Cổng tra cứu tài liệu ISO▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Cổng tra cứu tài liệu ISO
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
GraphRAG
…\workspaces\cong-tra-cuu-iso
Quét
Xuất PNG
Đồ thị
Tin nhắn
1.902 node · 3.418 cạnh
ISO 9001
→
Điều 7.5
→
Hồ sơ
HỎI ĐÁP TRÊN ĐỒ THỊ›
Điều khoản nào nói về kiểm soát hồ sơ?
Điều 7.5.3 — Kiểm soát thông tin dạng văn bản. Có 12 tài liệu trùng số hiệu, xem trung_lap.md.
Đặt câu hỏi…
Hỏi
+
Kiểm kê control — 10 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
sctx.config.cowork_output_dir(ô nhập—ui\structure_graph_view.py:220giữ nguyên tại chỗ
Browse…nútself._pickui\structure_graph_view.py:222giữ nguyên tại chỗ
Khóa phạm vi quét vào đúng thư mục sandbox của 1 project — pdroplistself._on_project_changedui\structure_graph_view.py:226giữ nguyên tại chỗ
Scannútself._scanui\structure_graph_view.py:228giữ nguyên tại chỗ
Xem mọi message hội thoại nhóm theo ngày (dạng JSON).nútself._toggle_messagesui\structure_graph_view.py:242giữ nguyên tại chỗ
Xuất PNGnútself._exportui\structure_graph_view.py:248giữ nguyên tại chỗ
—câyself._show_msg_jsonui\structure_graph_view.py:268giữ nguyên tại chỗ
Thu gọn bảng Agentnútlambda: self._set_agent_collapsed(True)ui\structure_graph_view.py:288giữ nguyên tại chỗ
vd. cái gì gọi hàm main? file nào định nghĩa class?ô nhậpself._askui\structure_graph_view.py:299giữ nguyên tại chỗ
Hỏinútself._askui\structure_graph_view.py:301giữ nguyên tại chỗ
+
+
Vấn đề
  • Hai hàng toolbar riêng biệt — nên là một.
  • Nút Messages/Graph đổi hẳn nội dung pane nhưng trông như nút thường.
+
Thay đổi
  • Gộp hai hàng toolbar thành một.
  • Messages/Graph thành cặp tab rõ ràng phía trên pane trái.
+
+
9. Monitoring ▸ Tổng quanui/monitoring_tab.py:132
+
+

Chi phí, tài nguyên máy, sandbox, nhật ký gần đây.

+
Hiện tại
Monitoring ▸ Tổng quan
+

Trái: Token & chi phí · Hoạt động · Tài nguyên · Bảng giá model · Phải: Sandbox · Quyền · Audit log

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
TOKEN & CHI PHÍ
395.4KTổng token
$0.31Chi phí
57Lượt gọi
—Ngân sách
TÀI NGUYÊN
CPU 34% · RAM 2.1/8 GB · Đĩa 41 GB trống
SANDBOX & QUYỀN
Tệp: chỉ trong workspace · Mạng: chặn · Tiến trình: giới hạn 4
NHẬT KÝ GẦN ĐÂY
✕ Chặn đọc personal.xlsx (ngoài sandbox)
✓ pytest tests/test_stations.py → 4 passed
✕ jira.create_issue — 401 token hết hạn
+
Kiểm kê control — 14 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Làm mớinútself.refreshui\monitoring_tab.py:149→ lên sidebar cùng RECENTS
0bảng—ui\monitoring_tab.py:175giữ nguyên tại chỗ
Lọc dòng (hoặc gõ câu hỏi rồi bấm )…ô nhậptable.apply_filterui\monitoring_tab.py:347giữ nguyên tại chỗ
AInútlambda: self._ai_filter(search, ai_btn)ui\monitoring_tab.py:350giữ nguyên tại chỗ
—droplistself._reload_pricing_tableui\monitoring_tab.py:486giữ nguyên tại chỗ
Nhậpnútself._import_pricingui\monitoring_tab.py:496giữ nguyên tại chỗ
Mẫunútself._export_pricingui\monitoring_tab.py:498giữ nguyên tại chỗ
Thêmnútself._add_pricing_rowui\monitoring_tab.py:500giữ nguyên tại chỗ
Tự lấynútself._autolink_pricingui\monitoring_tab.py:502giữ nguyên tại chỗ
Xóanútself._delete_pricing_rowui\monitoring_tab.py:504giữ nguyên tại chỗ
0bảng—ui\monitoring_tab.py:510giữ nguyên tại chỗ
Sửanútself._open_settings_and_refreshui\monitoring_tab.py:547giữ nguyên tại chỗ
Sửanútself._open_settings_and_refreshui\monitoring_tab.py:573giữ nguyên tại chỗ
Xem tất cảnútlambda: self.tabs.setCurrentIndex(self.tabs.indexOf(self.action_page))ui\monitoring_tab.py:586giữ nguyên tại chỗ
+
+
Vấn đề
  • Sáu group box, hai cột — màn dày đặc nhất app.
  • Trộn 3 mối quan tâm: chi phí · tài nguyên · bảo mật.
  • Bảng giá model nhét chung hàng với thanh CPU/RAM.
+
Thay đổi
  • Tách thành các mục có tiêu đề, cuộn dọc một cột chính.
  • Bảng giá model tách ra thành mục riêng.
+
+
10. Monitoring ▸ Sự kiện bảo mậtui/monitoring_tab.py:132
+
+

Nhật ký lần agent chạm thứ nhạy cảm: lệnh bị chặn, truy cập ngoài sandbox.

+
Hiện tại
Monitoring ▸ Sự kiện bảo mật
+

Trên: 4 thẻ KPI · Giữa: ô tìm + nút ✨AI, lọc Agent/Hành động/Thời gian · Dưới: bảng cột Hành động dạng badge màu (thay cho ✕/✓ luôn giống nhau) + phân trang, nhấp dòng mở panel chi tiết bên phải.

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Sự kiện bảo mật
⭳ Xuất CSV
⟳ Làm mới
▦
1.247Tổng sự kiện · +12.3%
◔
84Hôm nay · -8.1%
⛔
23Chặn lệnh · +3 mới
🛡
156Chặn prompt · 12.5%
✨ AI
✕ Xoá lọc
☐Thời gianAgentTài khoảnMáyHành độngChi tiết chặn
Hiển thị 24 / 24 sự kiện
‹
1
›
+ + +
+
Vấn đề
  • Dải tab Monitoring bị ẩn sau accordion — không thấy 7 màn Giám sát còn lại từ đây.
  • Cột "Kết quả" (✓/✕) trong bảng thật luôn hiện ✕ vì audit_log.record("security_block",…) luôn truyền ok=False — dư thừa, không phân biệt được gì.
  • KPI · lọc Agent/Hành động/Thời gian · phân trang · panel chi tiết đều là tính năng mới: bản thật hiện chỉ có 1 ô tìm + nút ✨AI + bảng cuộn (xem ui/monitoring_tab.py:_wrap_with_filter).
+
Thay đổi
  • Bỏ ẩn dải tab Monitoring (8 mục ngang hàng) — không đổi accordion nav rail.
  • Bỏ cột Kết quả luôn-giống-nhau; thay bằng cột Hành động dạng badge màu theo name thật (prompt/run_command/install_package).
  • Vượt phạm vi "chỉ sắp xếp lại": 4 thẻ KPI, 3 dropdown lọc, phân trang và panel chi tiết trượt — cần bổ sung logic đếm/lọc/phân trang ở ui/monitoring_tab.py trước khi hiện thực, và 2/4 loại "Hành động" (path ngoài sandbox, mạng chặn) chưa có nguồn ghi log thật.
+
+
11. Monitoring ▸ Lịch sử gọi MCPui/monitoring_tab.py:132
+
+

Mọi lần agent gọi MCP server ngoài.

+
Hiện tại
Monitoring ▸ Lịch sử gọi MCP
+

Trên: 4 thẻ KPI · Giữa: ô tìm + nút ✨AI, lọc Server/Kết quả/Thời gian · Dưới: bảng có badge Kết quả (✓/✕ phân biệt thật theo ok) + phân trang, nhấp dòng mở panel chi tiết bên phải. Bảng hiện tại (ảnh trên) không có ô lọc/tìm kiếm nào — khác biệt không chủ đích so với 2 màn log kia.

+
Đề xuất — bố cục mới
+
+
+ +
📁 Báo cáo tài chính Q3▾
+
+ Đoạn chat mới
+
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
+
+
RECENTS
+
📁 Báo cáo tài chính Q3
+
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
+
+
+
Dashboard
Monitoring
+
👤 local · Ollama ▾
+
+
+
Monitoring
+
+
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
+
+
+
+
Lịch sử gọi MCP
+
+
⭳ Xuất CSV
⟳ Làm mới
+
+
+
▦
892Tổng cuộc gọi · +8.4%
+
✓
823Thành công · 92.3%
+
✕
69Thất bại · 7.7%
+
◔
7Hôm nay · cuộc gọi
+
+
+
✨ AI
+ + + +
✕ Xoá lọc
+
+
Thời gianAgentTài khoảnMáyToolKết quảChi tiết
+
Hiển thị 25 / 25 cuộc gọi
‹
1
›
+
+
+
+
+ + +
+
Vấn đề
  • Dải tab Monitoring bị ẩn sau accordion — không thấy 7 màn Giám sát còn lại từ đây.
  • Bảng Lịch sử gọi MCP thật hiện không có ô tìm/lọc nào — còn thiếu hơn cả Sự kiện bảo mật: ui/monitoring_tab.py:166-168 tạo thẳng self.mcp_table = _EventTable() rồi thêm vào tab, không bọc qua _wrap_with_filter như Sự kiện bảo mật/Nhật ký hành động — đúng như đã ghi ở ảnh Hiện tại.
  • KPI · lọc Server/Kết quả/Thời gian · phân trang · panel chi tiết đều là tính năng mới: bản thật chỉ có bảng 7 cột (Thời gian/Vai trò/Tài khoản/Máy/Tên/Kết quả/Chi tiết), không tìm không lọc không phân trang (xem ui/monitoring_tab.py:_EventTable).
  • 5 server trong bảng mẫu (M365/GitHub/Filesystem/Brave Search/PostgreSQL) chỉ là ví dụ minh hoạ lấy từ docstring core/mcp_client.py — server thật phụ thuộc Admin đã cấu hình connector nào ở Settings, không có sẵn mặc định.
+
Thay đổi
  • Bỏ ẩn dải tab Monitoring (8 mục ngang hàng) — không đổi accordion nav rail.
  • Thêm ô tìm kiếm + nút ✨AI cho bảng này để đồng bộ với 2 bảng log kia — hiện là bảng log duy nhất trong 3 bảng không có tìm kiếm.
  • Vượt phạm vi "chỉ sắp xếp lại": 4 thẻ KPI, 3 dropdown lọc (Server/Kết quả/Thời gian), phân trang và panel chi tiết trượt — cần bổ sung logic đếm/lọc/phân trang ở ui/monitoring_tab.py trước khi hiện thực; danh sách server chỉ minh hoạ, không phải cấu hình mặc định.
+
+
12. Monitoring ▸ Nhật ký hành độngui/monitoring_tab.py:132
+
+

Nhật ký cấp ứng dụng: ai đổi cấu hình, ai chạy task.

+
Hiện tại
Monitoring ▸ Nhật ký hành động
+ +
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Nhật ký hành động
⭳ Xuất CSV
⟳ Làm mới
▤
25Tổng sự kiện · +6.2%
✓
19Thành công · 76%
✕
6Thất bại · 24%
🛡
4Chặn bảo mật
✨ AI
✕ Xóa lọc
Thời gianAgentLoạiMáyHành độngKết quảChi tiết
15/08 · 09:42CACowork AgentGọi MCPDESKTOP-DEMOms365__send_mail✓ Thành côngĐã gửi email đến nam.pdt@company.com — chủ đề "Báo cáo tuần Q3".
15/08 · 09:38CACowork AgentCông cụDESKTOP-DEMOread_file✓ Thành côngĐã đọc ./docs/installation.md (172 dòng).
15/08 · 09:31SASecurity AgentChặn bảo mậtDESKTOP-DEMOdangerous_command✕ Thất bạiChặn lệnh: rm -rf / --no-preserve-root
15/08 · 09:20CACowork AgentQuyềnDESKTOP-DEMOpath_outside_sandbox✕ Thất bạiChặn đọc C:\Users\NamPDT\Documents\personal.xlsx — ngoài sandbox.
15/08 · 08:55SASecurity AgentGọi MCPDESKTOP-DEMObrave__web_search✓ Thành côngTrả về 8 kết quả cho "CVE-2026-1234".
14/08 · 17:45SCscheduleCông cụDESKTOP-DEMOrun_command✓ Thành côngpython scripts/report.py — thoát mã 0.
Hiển thị 1-25 của 25 sự kiện
‹
1
›
+ +
+
Vấn đề
  • Bảng thật (ui/monitoring_tab.py:_EventTable) gộp cả 4 loại sự kiện (tool_call/mcp_call/security_block/permission) vào tab này nhưng không có cột phân loại — phải tự suy ra loại hành động từ cột "Tên".
  • Bộ lọc thật (_wrap_with_filter) chỉ có 1 ô tìm + nút ✨AI; 3 dropdown Loại/Trạng thái/Thời gian, 4 thẻ KPI, phân trang và nút Xuất CSV trong 12.ui-redesign-action-logs.html đều là tính năng mới, chưa có trong code.
  • Bảng thật giới hạn ngầm _MAX_ROWS dòng mới nhất và không phân trang — không biết còn bao nhiêu sự kiện bị ẩn.
+
Thay đổi
  • Thêm cột Loại dạng pill màu (Công cụ/Gọi MCP/Chặn bảo mật/Quyền) trước cột Hành động, tái dùng đúng bảng màu .pill đã áp cho Sự kiện bảo mật (mục 10).
  • Vượt phạm vi "chỉ sắp xếp lại": 4 thẻ KPI, 3 dropdown lọc, phân trang và nút Xuất CSV cần bổ sung logic đếm/lọc/phân trang/export ở ui/monitoring_tab.py trước khi hiện thực — hiện bảng thật chỉ lọc bằng 1 ô tìm text.
+
+
13. Monitoring ▸ Trạng thái Agentui/monitoring_tab.py:132
+
+

Agent nào đang bật và nguồn định nghĩa.

+
Hiện tại
Monitoring ▸ Trạng thái Agent
+ +
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Trạng thái Agent
⟳ Làm mới
AgentTrạng tháiNguồn
👤Cowork Agent● RảnhLượt đang chạy của tab Cowork
⏱Task Agent● RảnhTask đang chạy trong Schedule Task
◈Knowledge Agent● RảnhÔ hỏi của GraphRAG
▦Planner Agent—Một giai đoạn trong lượt Cowork/Task đang chạy (update_plan) — không theo dõi riêng
⬡Reasoning Agent—Luồng suy luận (reasoning) của model trong lượt đang chạy — không theo dõi riêng
🛡Security Agent● BậtKiểm duyệt prompt/đính kèm/lệnh của Agent Security — chạy inline trong lượt đang chạy
+
+
Vấn đề
  • Bảng thật 3 cột (Agent · Đang chạy · Nguồn, ui/monitoring_tab.py:_refresh_agent_status) chỉ dùng chữ/icon đơn cho trạng thái — khó quét nhanh dòng nào đang chạy khi bảng dài.
+
Thay đổi
  • Giữ nguyên 3 cột Agent · Trạng thái · Nguồn (không chuyển sang lưới thẻ, không thêm cột mới) — chỉ thêm icon màu theo loại agent trước tên và badge màu cho Trạng thái (xanh khi đang chạy/bật, xám khi "—") thay cho chữ thuần.
+
+
14. Monitoring ▸ Agents Adminui/monitoring_tab.py:132
+
+

Quản trị agent hệ thống. Cũng là nơi chọn model cho robot trợ giúp.

+
Hiện tại
Monitoring ▸ Agents Admin
+

Nút Kiểm tra: probe provider thật

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Agents Admin
⟳ Làm mới
+ Thêm
Agent quản lý hệ thống, dùng chung mọi máy (lưu trong thư mục dùng chung): agent trợ giúp và agent chạy Schedule Task. Đây KHÔNG phải agent để chọn trong Cowork hay Co4E.
TênVai tròModelKích hoạtTrạng tháiCập nhật
SASecurity AgentBảo mậtOllama — qwen2.5:7bOK11/08 09:30✎ 🗑
GAGraphRAG AgentTri thứcOllama — qwen2.5:14bOK10/08 14:22✎ 🗑
MAMonitor AgentGiám sátMặc định — qwen2.5:7bKiểm tra…09/08 11:05✎ 🗑
HAHelp AssistantHỗ trợMặc định — qwen2.5:7bChưa kiểm tra08/08 16:40✎ 🗑
+
Kiểm kê control — 11 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
agent.name if agent else ô nhập—ui\agents_admin_tab.py:55giữ nguyên tại chỗ
agent.prompt if agent else ô nhập nhiều dòng—ui\agents_admin_tab.py:65giữ nguyên tại chỗ
—droplistself._refresh_model_comboui\agents_admin_tab.py:70→ menu tài khoản ở đáy sidebar
Lấy danh sách model thực tế của provider này để chọn từ dropnútself._load_live_modelsui\agents_admin_tab.py:89giữ nguyên tại chỗ
Kích hoạtô tick—ui\agents_admin_tab.py:98giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\agents_admin_tab.py:101giữ nguyên tại chỗ
0bảng—ui\agents_admin_tab.py:169giữ nguyên tại chỗ
Thêmnútself._addui\agents_admin_tab.py:178giữ nguyên tại chỗ
Sửanútself._editui\agents_admin_tab.py:182giữ nguyên tại chỗ
Xóanútself._deleteui\agents_admin_tab.py:185giữ nguyên tại chỗ
Kiểm tranútself._check_allui\agents_admin_tab.py:188giữ nguyên tại chỗ
+
+
Vấn đề
  • Cột Vai trò/Kích hoạt/Trạng thái chỉ là chữ hoặc icon đơn — khó phân biệt nhanh giữa các dòng khi bảng dài.
  • Bỏ [Kiểm tra tất cả] khỏi thanh công cụ chính làm mất lối vào hành động thật (_check_all trong ui/agents_admin_tab.py) — cần chỗ khác để gọi (icon riêng theo dòng, hoặc menu ⋯).
+
Thay đổi
  • Vai trò/Trạng thái chuyển sang badge màu + avatar viết tắt tên trước Tên agent; nút Sửa/Xoá dời vào từng dòng thay vì thanh công cụ rời bên dưới.
  • Thanh công cụ chính chỉ còn 2 nút: Làm mới (giống nút monitoring.refresh toàn màn hình) và + Thêm (giống nút agents_admin.add_btn thật) — Sửa/Xoá đã dời vào từng dòng nên không cần ở đây nữa.
+
+
15. Monitoring ▸ Công cụui/monitoring_tab.py:132
+
+

Bật/tắt công cụ dựng sẵn và khai báo kết nối ngoài. Màn duy nhất còn hiện dải tab bên trong.

+
Hiện tại
Monitoring ▸ Công cụ
+

Tool: công cụ dựng sẵn + tự kiểm tra Internet · Connector: MCP · REST · MS365 · Jira

+
Đề xuất — bố cục mới
Tab Tool
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Tool
Connector
📄
read_file
Đọc nội dung file text trong thư mục làm việc
📁
list_dir
Liệt kê file/thư mục con tại một đường dẫn
✎
write_file
Tạo file mới hoặc ghi đè toàn bộ file
✏
edit_file
Sửa một đoạn chính xác trong file có sẵn
▤
run_command
Chạy lệnh shell trong thư mục làm việc
⭳
install_package
Cài package Python (pip) vào môi trường app
⤓
fetch_url
Lấy nội dung trang web/tài liệu theo URL
🌐 Kiểm tra Internet
✓ Kết nối OK · 42ms
🔍
jira_search
Tìm issue Jira bằng JQL, trả về danh sách tóm tắt
🔗
jira_get_issue
Đọc chi tiết 1 issue Jira theo mã (vd ABX-123)
+ +
Tab Connector
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Tool
Connector
Kết nối tới connector bên ngoài
Một nơi duy nhất cho mọi nguồn tool ngoài — nhóm theo CAD (NX/CATIA/SolidWorks/AutoCAD), CAE (ANSA/ABAQUS/HyperWorks/ANSYS), MS365 (Microsoft 365/OneDrive/SharePoint) và Other (MCP server bất kỳ). MS365 tự kết nối qua server tích hợp sau khi đăng nhập; còn lại bạn trỏ mỗi connector tới MCP server bạn đã có hoặc REST API nó cung cấp (không kèm SDK hãng nào).
🔧CAD(NX / CATIA / SolidWorks / AutoCAD)
AutoCAD
MCP server (stdio)✎ Sửa  ·  🗑 Xóa
📐CAE(ANSA / ABAQUS / HyperWorks / ANSYS)
ANSA
MCP server (stdio)✎ Sửa  ·  🗑 Xóa
☁MS365(Microsoft 365 / OneDrive / SharePoint)
OneDrive
Tích hợp, tự kết nối
SharePoint
Tích hợp, tự kết nối
🔌Other(any generic MCP server)
Jira
Tích hợp, tự kết nối · chưa cấu hình✎ Sửa
+ Thêm connector…
+
Kiểm kê control — 15 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
—ô tickon_toggleui\tools_admin_tab.py:32giữ nguyên tại chỗ
0bảng—ui\tools_admin_tab.py:57giữ nguyên tại chỗ
Kiểm tra Internetnútself._test_internetui\tools_admin_tab.py:72giữ nguyên tại chỗ
Làm mớinútself.refreshui\tools_admin_tab.py:80→ lên sidebar cùng RECENTS
Dán bất kỳ link Jira nào — tự điền Base URLô nhậpself._on_pasteui\connectors_panel.py:42giữ nguyên tại chỗ
jira.get('base_url', ô nhập—ui\connectors_panel.py:46giữ nguyên tại chỗ
jira.get('email', ô nhập—ui\connectors_panel.py:48giữ nguyên tại chỗ
jira.get('api_token', ô nhập—ui\connectors_panel.py:49giữ nguyên tại chỗ
Kiểm tra kết nốinútself._testui\connectors_panel.py:58giữ nguyên tại chỗ
Lưunútself._save_closeui\connectors_panel.py:60giữ nguyên tại chỗ
Kết nối tới connector bên ngoàiô tickself._on_connect_external_toggledui\connectors_panel.py:133giữ nguyên tại chỗ
—câylambda *_: self._ext_edit()ui\connectors_panel.py:144giữ nguyên tại chỗ
Thêm connector…nútself._ext_addui\connectors_panel.py:155giữ nguyên tại chỗ
Sửanútself._ext_editui\connectors_panel.py:159giữ nguyên tại chỗ
Xóanútself._ext_deleteui\connectors_panel.py:162giữ nguyên tại chỗ
+
+
Vấn đề
  • Màn duy nhất còn hiện dải tab — 7 màn Monitoring kia bị ẩn. Không nhất quán.
  • Tab Tool/Connector lọt thỏm trong một mục nav.
  • Cấu hình kết nối tách khỏi Settings → thiết lập ở 2 nơi.
  • Tab Tool liệt kê 9 tool thật (core/tools.py:TOOL_SPECS) dạng chữ phẳng (☑/☐) — không thấy ngay tool nào đang bật khi lướt nhanh.
  • Tab Connector là cây phân cấp 4 nhóm (CAD/CAE/MS365/Other, ui/connectors_panel.py) — phải bung từng nhóm mới thấy có gì bên trong.
+
Thay đổi
  • Bỏ ẩn dải tab cho toàn bộ Monitoring → 8 tab một hàng; Tool/Connector ngang hàng.
  • Tab Tool: 9 tool thành lưới thẻ căn trái (không kéo giãn lấp đầy hàng) — icon màu theo loại + công tắc gạt bật/tắt, thay ô tick trong list phẳng.
  • Tab Connector: 4 nhóm thật thành khối nhóm theo catalog (không gộp vào tab Tool) — mỗi catalog là 1 tiêu đề (icon + tên + loại máy/phần mềm), bên dưới là hàng thẻ nhỏ căn trái, mỗi thẻ 1 option thật kèm công tắc gạt bật/tắt (không phải badge tĩnh) — đúng bản chất mỗi option đều chuyển được trạng thái, nhất quán với công tắc ở tab Tool; giữ đúng cấu trúc phân cấp 2 tầng của cây thật thay vì gộp phẳng vào 1 thẻ/catalog. Công tắc "Kết nối ngoài" tổng vẫn ở trên cùng. Thẻ của connector do người dùng tự thêm (AutoCAD, ANSA) có thêm ✎ Sửa/🗑 Xóa đúng như _ext_edit/_ext_delete thật; Jira (built-in) chỉ có ✎ Sửa (mở dialog cấu hình riêng, không xóa được); OneDrive/SharePoint (built-in MS365) không có nút nào — thật cũng chỉ bật/tắt, không sửa/xóa.
  • Nút Kiểm tra Internet dời từ thanh trên (tách khỏi tool nào) vào đúng bên trong thẻ fetch_url, khớp tools_admin_tab.py:_fetch_url_desc_cell — nút này gắn với khả năng fetch_url, không phải một hành động chung của cả tab.
+
+
16. Monitoring ▸ Iconui/monitoring_tab.py:132
+
+

Thư viện icon, dùng lại khi đặt icon cho agent Co4E.

+
Hiện tại
Monitoring ▸ Icon
+ +
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Icon
+ Thêm icon
Dán SVG
Xoá
🔍 Tìm icon theo tên…
ICON TÍCH HỢP
✛
plus
✓
check
✕
close
✎
edit
🗑
trash
⟳
refresh
🔍
search
⚙
settings
🤖
robot
🛡
shield
🔧
wrench
★
star
ICON TÙY CHỈNH
🧠
brain
⌥
code
☁
cloud
+
Kiểm kê control — 4 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Tìm icon có sẵn…ô nhậpself._reload_builtinui\icons_admin_tab.py:43giữ nguyên tại chỗ
Thêm tệp SVGnútself._add_iconui\icons_admin_tab.py:58giữ nguyên tại chỗ
Dán SVGnútself._add_from_svg_textui\icons_admin_tab.py:60giữ nguyên tại chỗ
Xóa tùy chỉnhnútself._delete_iconui\icons_admin_tab.py:62giữ nguyên tại chỗ
+
+
Vấn đề
  • Ô icon trong lưới hiện tại không có viền/hover rõ khi rê chuột hay khi đang chọn.
+
Thay đổi
  • Ô tìm kiếm thêm icon kính lúp bên trái; mỗi ô icon có viền accent khi hover/chọn — cùng ngôn ngữ thẻ với Agent/Công cụ.
+
+
17. Settingsui/settings_dialog.py:26
+
+

Thiết lập toàn app. Cuộn dọc, không mục lục.

+
Hiện tại
Settings
+

5 nhóm: Ngôn ngữ · Provider · Bảo mật (khoá mật khẩu) · Tham số · Routing

+
Đề xuất — bố cục mới
Cài đặt
Chungngôn ngữ · giao diện · khay
AI ProviderOllama · qwen2.5-coder
Bảo mật sandbox🔒 cần mở khoá
Tham sốđính kèm · GraphRAG · giới hạn
Auto Model Routingđang Tắt
Ngôn ngữ hiển thị
EnglishTiếng Việt日本語
Giao diện
SángHệ thốngTối
Giữ chạy nền trong khay hệ thống khi đóng
Hiện thông báo khay khi tác vụ xong hoặc lỗi
Nhà cung cấp AI
Ollama (local models)
Base URL
http://localhost:11434
API Key
••••••••
Model
qwen2.5-coder:7b ⭳ Tải · ⚗ Test kết nối
Nhập mật khẩu…
Unlock
🔒 Locked
Xác nhận trước khi Cowork chạy lệnh
Chặn mạng cho lệnh do agent chạy
Enable Agent Security (kiểm tra lệnh)
AI check commands
Đính kèm
Số tệp tối đa
−20+
tệp
Token tối đa mỗi tệp
−500+
nghìn
Cấu trúc & GraphRAG
Số node tối đa
−500+
node
Số cạnh tối đa
−500+
cạnh
Giới hạn sandbox
CPU
Không giới hạn
Bộ nhớ
2.048 MB
Disk I/O
2.048 MB
Chế độ
TắtAutoManual
Chính sách
Chất lượngChi phíĐộ trễCân bằng
Ngưỡng tăng điểm tối thiểu
−5+
%
Timeout xác nhận
−60+
giây
Chu kỳ đánh giá lại
−24+
giờ
Đồng thời mỗi provider
−2+
Judge model
(để trống · dùng model đang chọn)
⟳ Đánh giá lại ngay
Huỷ
Lưu
+ +
Kiểm kê control — 23 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Giữ chạy nền trong khay hệ thống khi đóng cửa sổô tick—ui\settings_dialog.py:56giữ nguyên tại chỗ
Hiện thông báo khay hệ thống khi tác vụ xong hoặc lỗiô tick—ui\settings_dialog.py:59giữ nguyên tại chỗ
—droplistself._on_provider_edit_changedui\settings_dialog.py:70→ menu tài khoản ở đáy sidebar
conf.get('base_url', ô nhập—ui\settings_dialog.py:77giữ nguyên tại chỗ
ô nhập—ui\settings_dialog.py:103giữ nguyên tại chỗ
Unlocknútself._sandbox_unlockui\settings_dialog.py:107giữ nguyên tại chỗ
Xác nhận trước khi Cowork chạy lệnhô tick—ui\settings_dialog.py:121giữ nguyên tại chỗ
Chặn mạng cho lệnh do agent chạyô tick—ui\settings_dialog.py:126giữ nguyên tại chỗ
Enable Agent Security (command validationô tick—ui\settings_dialog.py:136giữ nguyên tại chỗ
AI check commandsô tick—ui\settings_dialog.py:142giữ nguyên tại chỗ
Số tệp tối đa đính kèm vào một tin nhắn.ô số—ui\settings_dialog.py:178giữ nguyên tại chỗ
Giới hạn nội dung mỗi tệp đính kèm đưa vào prompt; phần vượtô số—ui\settings_dialog.py:183giữ nguyên tại chỗ
Giới hạn số node trong đồ thị Cấu trúc (0 = không giới hạn).ô số—ui\settings_dialog.py:194giữ nguyên tại chỗ
Giới hạn số cạnh trong đồ thị Cấu trúc (0 = không giới hạn).ô số—ui\settings_dialog.py:200giữ nguyên tại chỗ
routing.get('judge_model', ô nhập—ui\settings_dialog.py:279giữ nguyên tại chỗ
Đánh giá lại ngaynútself._routing_reassess_nowui\settings_dialog.py:282giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\settings_dialog.py:299giữ nguyên tại chỗ
valueô nhập—ui\settings_dialog.py:316giữ nguyên tại chỗ
Tảinútlambda: self._load_models(self.provider_combo.currentData(), combo, stui\settings_dialog.py:368giữ nguyên tại chỗ
Test kết nốinútlambda: self._test_connection(self.provider_combo.currentData(), statuui\settings_dialog.py:374giữ nguyên tại chỗ
codeô nhập—ui\settings_dialog.py:496giữ nguyên tại chỗ
Copy mãnútlambda: QGuiApplication.clipboard().setText(code)ui\settings_dialog.py:503giữ nguyên tại chỗ
Mở linknútlambda: webbrowser.open(flow.get('verification_uri_complete') or url)ui\settings_dialog.py:506giữ nguyên tại chỗ
+
+
Vấn đề
  • Năm group cuộn dọc, không mục lục.
  • Sandbox khoá bằng mật khẩu hard-code (settings_dialog.py:115).
  • Provider/Ngôn ngữ/Giao diện ở top bar, tách khỏi Settings.
+
Thay đổi
  • Thêm cột mục lục bên trái; gom Ngôn ngữ/Giao diện vào nhóm Chung (AI Provider vẫn là nhóm riêng như hiện tại, không gộp).
  • Đổi cách hiển thị field theo đúng loại dữ liệu: checkbox ☑/☐ → toggle switch, dropdown ít lựa chọn (2–4 giá trị) → segmented control, số nhập tay → stepper, 3 nhóm con trong Tham số → gom thành card — vẫn đúng field/giá trị mặc định thật từ ui/settings_dialog.py, chỉ đổi cách trình bày chứ không đổi dữ liệu.
+
+
18. Task Editorui/task_editor_dialog.py:55
+
+

Khai báo tác vụ hẹn giờ: nội dung, lịch lặp, phụ thuộc, thông báo.

+
Hiện tại
Task Editor
+

5 nhóm: Cơ bản · Lịch · Đầu vào · Phụ thuộc · Thực thi

+
Đề xuất — bố cục mới
+

Cùng 1 dialog TaskEditorDialog cho cả Thêm task và Sửa task — chỉ đổi tiêu đề (Thêm/Sửa), bố cục dưới đây dùng chung cho cả hai. Layout theo đúng kiểu màn Cài đặt: danh sách 5 nhóm bên trái thay cho 3 tab wizard, đúng 5 QGroupBox thật (không phải 3 bước tự đặt ra).

+
Sửa task
Cơ bảntiêu đề · loại chạy · model
Lịchchạy 1 lần · lặp lại
Đầu vàoprompt · tệp · liên kết
Phụ thuộctask kế tiếp · chờ hoàn tất
Thực thithử lại · timeout · phê duyệt
Chế độ
Bình thườngTự động
Tiêu đề
Gửi báo cáo doanh thu hằng ngày 08:00
Mô tả
Gom số liệu ngày hôm trước, dựng bảng và gửi email cho nhóm kế toán. ✨ Sinh prompt từ mô tả
Project
Báo cáo tài chính Q3
Loại chạy
AI AgentCo4E Flow
Nhà cung cấp
Ollama (local models)
Model
qwen2.5-coder:7b ⭳ Tải model
Skill
(Không dùng)
Ưu tiên
medium
Trạng thái
backlog
Bật lịch chạy
Thời điểm chạy
2026-08-17 08:00 AM
Lặp lại
Hằng ngày
Cron (khi lặp = tuỳ chỉnh)
0 8 * * * — chọn mẫu có sẵn
Chỉ ngày làm việc (bỏ T7/CN)
Bỏ qua ngày nghỉ lễ
Mã quốc gia lễ
VN
Kênh thông báo
KhôngTeamsOutlook
Email nhận thông báo
ketoan@company.com
Prompt thủ công
Tổng hợp số liệu doanh thu ngày hôm trước từ file đính kèm, dựng bảng tóm tắt.
Tệp đính kèm
📄 sales_template.xlsx
📄 Q3_report_outline.docx
+ Thêm tệp
🗑 Xoá
Liên kết
🔗 https://intranet.company.com/sales-dashboard
+ Thêm liên kết
🗑 Xoá
Task kế tiếp
(Không có)
Chế độ chạy tiếp
Không tự động chạy tiếp
Dùng output làm input task sau
Chờ các task này xong (fan-in)
☑ Gom số liệu doanh thu
☐ Dựng slide trình bày Q3
Số lần thử lại
0 lần
Timeout
600 giây
Cần phê duyệt (chờ bấm Chạy ngay)
Huỷ
Lưu
+ +
Kiểm kê control — 26 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
self.task.get('title', ô nhập—ui\task_editor_dialog.py:96giữ nguyên tại chỗ
self.task.get('description', ô nhập nhiều dòng—ui\task_editor_dialog.py:100giữ nguyên tại chỗ
nútself._gen_prompt_from_descriptionui\task_editor_dialog.py:102giữ nguyên tại chỗ
—droplistself._refresh_model_comboui\task_editor_dialog.py:135→ menu tài khoản ở đáy sidebar
Tải danh sách model của provider nàynútself._load_live_modelsui\task_editor_dialog.py:147giữ nguyên tại chỗ
AI agent = chạy một agent Cowork với model đã chọn. Co4E flodroplistself._on_run_kind_changedui\task_editor_dialog.py:170giữ nguyên tại chỗ
Flow Co4E đã lưu mà task này sẽ chạy (có sẵn hoặc của bạn).droplist—ui\task_editor_dialog.py:176giữ nguyên tại chỗ
Thông thường = chạy một lần (hoặc thủ công). Tự động = cronjdroplistself._on_task_mode_changedui\task_editor_dialog.py:188giữ nguyên tại chỗ
Bật lịch chạyô tick—ui\task_editor_dialog.py:217giữ nguyên tại chỗ
—droplistself._on_repeat_changedui\task_editor_dialog.py:232giữ nguyên tại chỗ
sched.get('cron_expression') or ô nhập—ui\task_editor_dialog.py:238giữ nguyên tại chỗ
Chọn một lịch mẫu — sẽ điền đúng cú pháp vào ô cron.droplistself._on_cron_sampleui\task_editor_dialog.py:242giữ nguyên tại chỗ
Chỉ ngày làm việc (bỏ T7/CNô tick—ui\task_editor_dialog.py:258giữ nguyên tại chỗ
Bỏ qua ngày nghỉ lễô tick—ui\task_editor_dialog.py:260giữ nguyên tại chỗ
sched.get('holiday_country', 'VN') or 'VNô nhập—ui\task_editor_dialog.py:262giữ nguyên tại chỗ
—droplistself._on_notify_changedui\task_editor_dialog.py:274giữ nguyên tại chỗ
ex_sched.get('notify_email', '') or ô nhập—ui\task_editor_dialog.py:280giữ nguyên tại chỗ
inp.get('manual_text') or ô nhập nhiều dòng—ui\task_editor_dialog.py:313giữ nguyên tại chỗ
—nútself._add_filesui\task_editor_dialog.py:324giữ nguyên tại chỗ
—nútlambda: self._remove_selected(self.files_list)ui\task_editor_dialog.py:328giữ nguyên tại chỗ
—nútself._add_linkui\task_editor_dialog.py:349giữ nguyên tại chỗ
—nútlambda: self._remove_selected(self.links_list)ui\task_editor_dialog.py:353giữ nguyên tại chỗ
—droplistself._check_chainui\task_editor_dialog.py:376giữ nguyên tại chỗ
Dùng output task này làm input task sauô tick—ui\task_editor_dialog.py:385giữ nguyên tại chỗ
Cần phê duyệt (không tự chạy theo lịch; chờ bấm Chạy ngayô tick—ui\task_editor_dialog.py:421giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\task_editor_dialog.py:430giữ nguyên tại chỗ
+
+
Vấn đề
  • Năm group dọc — form dài nhất app, không thấy đang ở bước nào.
+
Thay đổi
  • Đổi sang layout danh sách bên trái + panel bên phải giống màn Cài đặt (thay vì chia tab) — 5 mục danh sách khớp đúng 5 QGroupBox thật (Cơ bản · Lịch · Đầu vào · Phụ thuộc · Thực thi, xem ui/task_editor_dialog.py), luôn thấy đang ở nhóm nào và còn bao nhiêu nhóm nữa — nhất quán với cách điều hướng ở Cài đặt thay vì mỗi màn một kiểu.
+
+
19. Skills managerui/skills_dialog.py:108
+
+

Quản lý skill — khối hướng dẫn tái dùng, gõ /skill để chèn.

+
Hiện tại
Skills manager
+

Ô tick: chính là bật/tắt skill

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 13 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
skill.name if skill else ô nhập—ui\skills_dialog.py:34giữ nguyên tại chỗ
skill.description if skill else ô nhập—ui\skills_dialog.py:39giữ nguyên tại chỗ
Tạo từ mô tảnútself._gen_instructionsui\skills_dialog.py:44giữ nguyên tại chỗ
skill.instructions if skill else ô nhập nhiều dòng—ui\skills_dialog.py:50giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\skills_dialog.py:55giữ nguyên tại chỗ
Tự động tạonútself._auto_generateui\skills_dialog.py:127giữ nguyên tại chỗ
Từ file template…nútself._from_templateui\skills_dialog.py:132giữ nguyên tại chỗ
Nhập…nútself._importui\skills_dialog.py:136giữ nguyên tại chỗ
Xuất .mdnútself._export_mdui\skills_dialog.py:140giữ nguyên tại chỗ
Nhân bảnnútself._duplicateui\skills_dialog.py:144giữ nguyên tại chỗ
Sửanútself._editui\skills_dialog.py:148giữ nguyên tại chỗ
Xóanútself._deleteui\skills_dialog.py:151giữ nguyên tại chỗ
Đóngnútself.acceptui\skills_dialog.py:154giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
20. Skill editorui/skills_dialog.py:23
+
+

Soạn skill: tên, mô tả, hướng dẫn.

+
Hiện tại
Skill editor
+

✨: sinh hướng dẫn từ mô tả ngắn

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+ +
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
21. File view & AI editui/file_edit_dialog.py:50
+
+

Xem và nhờ AI sửa tệp, mở từ panel Files trong chat.

+
Hiện tại
File view & AI edit
+

Tệp nhị phân: trích văn bản, chỉ đọc · Lưu: tạo .bak trước khi ghi

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 8 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
spô nhập—ui\file_edit_dialog.py:66giữ nguyên tại chỗ
Mở file khác…nútself._browseui\file_edit_dialog.py:68giữ nguyên tại chỗ
Tải lại từ đĩanútself._reloadui\file_edit_dialog.py:73giữ nguyên tại chỗ
Mở một file để xem hoặc chỉnh sửa.ô nhập nhiều dòng—ui\file_edit_dialog.py:84giữ nguyên tại chỗ
Nói cho AI cách sửa file này (vd: 'sửa lỗi chính tả', 'dịch ô nhậpself._ai_editui\file_edit_dialog.py:94giữ nguyên tại chỗ
Sửa bằng AInútself._ai_editui\file_edit_dialog.py:97giữ nguyên tại chỗ
Lưunútself._saveui\file_edit_dialog.py:106giữ nguyên tại chỗ
Đóngnútself.rejectui\file_edit_dialog.py:110giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
22. Co4E agent editorui/co4e_agent_dialog.py:23
+
+

Định nghĩa agent Co4E: tính cách, quyền, model, skill.

+
Hiện tại
Co4E agent editor
+ +
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 9 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
agent.nameô nhập—ui\co4e_agent_dialog.py:32giữ nguyên tại chỗ
agent.role or 'AGENTô nhập—ui\co4e_agent_dialog.py:34giữ nguyên tại chỗ
agent.instructionsô nhập nhiều dòng—ui\co4e_agent_dialog.py:42giữ nguyên tại chỗ
Soạn bằng AInútself._ai_draftui\co4e_agent_dialog.py:46giữ nguyên tại chỗ
getatagent, 'context', ô nhập nhiều dòng—ui\co4e_agent_dialog.py:59giữ nguyên tại chỗ
Tải danh sách modelnútself._load_modelsui\co4e_agent_dialog.py:68giữ nguyên tại chỗ
Đính kèm tệpnútself._add_attachmentui\co4e_agent_dialog.py:99giữ nguyên tại chỗ
Bỏnútself._del_attachmentui\co4e_agent_dialog.py:102giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\co4e_agent_dialog.py:113giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
23. External connectorui/ext_connector_dialog.py:23
+
+

Khai báo kết nối ngoài, 2 chế độ.

+
Hiện tại
External connector
+

MCP (stdio): lệnh + tham số · REST: URL · key · header · Test: thử kết nối thật

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 11 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
—droplistself._apply_presetui\ext_connector_dialog.py:35giữ nguyên tại chỗ
connector.get('name', ô nhập—ui\ext_connector_dialog.py:43giữ nguyên tại chỗ
—droplistlambda i: self.stack.setCurrentIndex(self.mode_combo.currentData() != ui\ext_connector_dialog.py:47giữ nguyên tại chỗ
connector.get('command', ô nhập—ui\ext_connector_dialog.py:59giữ nguyên tại chỗ
'.join(connector.get('args', []) or []ô nhập—ui\ext_connector_dialog.py:62giữ nguyên tại chỗ
connector.get('base_url', ô nhập—ui\ext_connector_dialog.py:69giữ nguyên tại chỗ
connector.get('api_key', ô nhập—ui\ext_connector_dialog.py:72giữ nguyên tại chỗ
connector.get('auth_header', 'Authorizationô nhập—ui\ext_connector_dialog.py:75giữ nguyên tại chỗ
connector.get('auth_scheme', 'Bearerô nhập—ui\ext_connector_dialog.py:77giữ nguyên tại chỗ
Kiểm tra kết nốinútself._test_connectionui\ext_connector_dialog.py:90giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\ext_connector_dialog.py:103giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
24. Permission requestui/permission_dialog.py:13
+
+

Chốt chặn cuối trước khi agent làm việc có hậu quả. Bật Tự chạy thì bỏ qua bước này.

+
Hiện tại
Permission request
+

Xem trước: lệnh sắp chạy hoặc diff sắp ghi

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+ +
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
25. Admin agent editorui/agents_admin_tab.py:35
+
+

Soạn agent hệ thống: gắn vào chức năng nào, provider/model gì.

+
Hiện tại
Admin agent editor
+ +
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+ +
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
26. Login (dead screen — not wired)ui/login_dialog.py:57
+
+

Màn đăng nhập — đã dựng xong nhưng không nơi nào gọi. App khởi động thẳng với user \“local\”, quyền admin.

+
Hiện tại
Login (dead screen — not wired)
+

3 trang: Khởi tạo · Đăng nhập · Offline

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 9 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Thoátnútself.rejectui\login_dialog.py:87giữ nguyên tại chỗ
self.ctx.config.shared_dirô nhập—ui\login_dialog.py:121giữ nguyên tại chỗ
Chọn…nútself._bs_browseui\login_dialog.py:122giữ nguyên tại chỗ
Tạo tài khoản Adminnútself._bs_create_adminui\login_dialog.py:137giữ nguyên tại chỗ
cached_codeô nhập—ui\login_dialog.py:186giữ nguyên tại chỗ
self.ctx.config.auth.get('last_department', ô nhập—ui\login_dialog.py:199giữ nguyên tại chỗ
Đăng nhậpnútlambda: self._do_login(shared_dir)ui\login_dialog.py:209giữ nguyên tại chỗ
login.offline_btn', role=rolenútlambda: self._finish_login(Account(username=username, role=role, code=ui\login_dialog.py:256giữ nguyên tại chỗ
Thử lạinútself._retryui\login_dialog.py:263giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
27. Help dock — expanded panelui/help_agent_widget.py:79
+
+

Robot trợ giúp nổi, có mặt trên mọi màn. Cố tình không có công cụ.

+
Hiện tại
Help dock — expanded panel
+

3 trạng thái: tab mép → huy hiệu → panel chat

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 5 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
selfnútself._show_launcherui\help_agent_widget.py:169giữ nguyên tại chỗ
selfnútself._hide_to_edgeui\help_agent_widget.py:178giữ nguyên tại chỗ
headernútself._collapseui\help_agent_widget.py:214giữ nguyên tại chỗ
rowô nhậpself._sendui\help_agent_widget.py:236giữ nguyên tại chỗ
rownútself._sendui\help_agent_widget.py:241giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+ +

Phần 4 — Màn chết (chỉ ghi nhận)

+

Sáu màn có trong code nhưng không tới được — tổng 64 control +(accounts 18 · flow_dialog 28 · agent_manager 7 · skill_manager 7 · mcp_servers 4). +Không nằm trong kiểm kê phía trên vì không có đường nào tới; chỉ ghi nhận, không đề xuất gỡ.

+
Thành phầnVị tríTình trạng
AccountsTabui/accounts_tab.py:153Quản lý tài khoản/nhóm đầy đủ, nhưng không được gắn vào MonitoringTab.
LoginDialogui/login_dialog.py:57Màn đăng nhập hoàn chỉnh; app.py:860 bỏ qua, hard-code user “local”.
FlowBuilderDialogui/flow_dialog.py:34Bị Co4E thay thế; không nơi nào gọi.
AgentManagerTabui/agent_manager_tab.py:28Chỉ dùng bởi FlowBuilderDialog → cũng không tới được.
SkillManagerTabui/skill_manager_tab.pyChỉ dùng bởi FlowBuilderDialog → cũng không tới được.
McpServerEditDialogui/mcp_servers_dialog.py:15Bị ExtConnectorEditDialog thay thế.
+
Ngoài phạm vi: settings_dialog.py:115 hard-code mật khẩu +Sandbox; hai lớp cùng tên CustomAgent +(custom_agents.py:23 · co4e.py:117).
+
\ No newline at end of file diff --git a/i18n.py b/i18n.py index a0ac067..e3b8b2e 100644 --- a/i18n.py +++ b/i18n.py @@ -178,9 +178,27 @@ STRINGS: Dict[str, Dict[str, str]] = { "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"}, + # 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": { + "en": "Select a project first", "ja": "先にプロジェクトを選択してください", + "vi": "Chọn project trước"}, + # Rail header: the project a new chat will be created in, and what to do + # when there is no project yet. + "app.nav.project_pick": { + "en": "Project for new chats", "ja": "新しいチャットのプロジェクト", + "vi": "Project cho đoạn chat mới"}, + "app.nav.no_project": { + "en": "No project yet", "ja": "プロジェクトなし", "vi": "Chưa có project"}, + "app.nav.recents": {"en": "RECENTS", "ja": "最近", "vi": "GẦN ĐÂY"}, + "app.nav.all_projects": { + "en": "All projects…", "ja": "すべてのプロジェクト…", "vi": "Tất cả project…"}, + "app.nav.create_project_first": { + "en": "Create a project first", "ja": "先にプロジェクトを作成してください", + "vi": "Tạo project trước"}, # ---- workspace_tab.py (Projects — Claude-Projects style) ----------- - "workspace.header": {"en": "Workspace — Projects", "ja": "ワークスペース — プロジェクト", "vi": "Workspace — Projects"}, + "workspace.header": {"en": "Manage projects", "ja": "プロジェクト管理", "vi": "Quản lý project"}, "workspace.tab_cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"}, "workspace.tab_graphrag": {"en": "GraphRAG", "ja": "GraphRAG", "vi": "GraphRAG"}, "workspace.tab_project": {"en": "Project", "ja": "プロジェクト", "vi": "Project"}, @@ -360,6 +378,12 @@ STRINGS: Dict[str, Dict[str, str]] = { "các file đặt ở gốc thư mục đó (project knowledge)."), }, "workspace.new_project": {"en": "New project", "ja": "新規プロジェクト", "vi": "Project mới"}, + "workspace.projects_heading": {"en": "PROJECTS", "ja": "プロジェクト", "vi": "PROJECT"}, + "workspace.folder_label": {"en": "Workspace folder", "ja": "作業フォルダ", "vi": "Thư mục làm việc"}, + "workspace.counts": { + "en": "{chats} chats · {tasks} tasks", + "ja": "チャット {chats} · タスク {tasks}", + "vi": "{chats} đoạn chat · {tasks} task"}, "workspace.delete": {"en": "Delete", "ja": "削除", "vi": "Xóa"}, "workspace.delete_confirm": { "en": "Delete project “{name}”? Its conversations and files are kept (threads move to General).", @@ -373,19 +397,19 @@ STRINGS: Dict[str, Dict[str, str]] = { "vi": "Project của hội thoại này không còn tồn tại — không thể mở."}, "workspace.name": {"en": "Name", "ja": "名前", "vi": "Tên"}, "workspace.description": {"en": "Description", "ja": "説明", "vi": "Mô tả"}, - "workspace.instructions": {"en": "Instructions (shared project context)", "ja": "指示(プロジェクト共有コンテキスト)", "vi": "Instructions (ngữ cảnh chung của project)"}, + "workspace.instructions": {"en": "Instructions", "ja": "Instructions", "vi": "Instructions"}, "workspace.instructions_placeholder": { "en": "e.g. \"All answers in Vietnamese. We are building the X reporting tool; always follow the naming rules …\"", "ja": "例:「回答はすべて日本語で。X レポートツールを開発中。命名規則に従うこと …」", "vi": "vd: \"Trả lời bằng tiếng Việt. Team đang xây tool báo cáo X; luôn theo quy tắc đặt tên …\"", }, - "workspace.browse": {"en": "Change folder…", "ja": "フォルダ変更…", "vi": "Đổi thư mục…"}, + "workspace.browse": {"en": "Change", "ja": "変更", "vi": "Đổi"}, "workspace.browse_tooltip": { "en": "Choose the project's workspace folder (agent sandbox + shared knowledge root)", "ja": "プロジェクトのワークスペースフォルダを選択(エージェントのサンドボックス+共有ナレッジのルート)", "vi": "Chọn thư mục workspace của project (sandbox của agent + gốc chứa knowledge chung)", }, - "workspace.open_folder": {"en": "Open folder", "ja": "フォルダを開く", "vi": "Mở thư mục"}, + "workspace.open_folder": {"en": "Open", "ja": "開く", "vi": "Mở"}, "workspace.save": {"en": "Save project", "ja": "プロジェクトを保存", "vi": "Lưu project"}, "workspace.saved": {"en": "Saved project {name}.", "ja": "プロジェクト {name} を保存しました。", "vi": "Đã lưu project {name}."}, "workspace.threads": {"en": "Conversations in this project", "ja": "このプロジェクトの会話", "vi": "Hội thoại trong project này"}, @@ -468,7 +492,9 @@ STRINGS: Dict[str, Dict[str, str]] = { "chat.assistant": {"en": "Assistant", "ja": "アシスタント", "vi": "Assistant"}, "chat.error": {"en": "Error", "ja": "エラー", "vi": "Lỗi"}, "help_agent.title": { - "en": "App Assistant", "ja": "アプリアシスタント", "vi": "Trợ lý App"}, + # The audit page names this AI Assistant, and keeps it the same in every + # language — it is a product name, not a description. + "en": "AI Assistant", "ja": "AI Assistant", "vi": "AI Assistant"}, "help_agent.greeting": { "en": "Hello {name}, have a great working day! How can I help you use the app?", "ja": "こんにちは {name} さん、良い一日を!アプリの使い方について何かお手伝いできますか?", @@ -478,16 +504,27 @@ STRINGS: Dict[str, Dict[str, str]] = { "en": "Ask how to use the app…", "ja": "アプリの使い方を質問…", "vi": "Hỏi cách sử dụng app…"}, "help_agent.open_tooltip": { - "en": "App Assistant — help using the app", - "ja": "アプリアシスタント — アプリの使い方をサポート", - "vi": "Trợ lý App — hỗ trợ sử dụng app"}, + "en": "AI Assistant — help using the app", + "ja": "AI Assistant — アプリの使い方をサポート", + "vi": "AI Assistant — hỗ trợ sử dụng app"}, "help_agent.collapse_tooltip": { "en": "Minimize", "ja": "最小化", "vi": "Thu nhỏ"}, "help_agent.hide_tooltip": { "en": "Hide to the edge", "ja": "端に隠す", "vi": "Ẩn vào cạnh phải"}, + "help_agent.dot_hint": { + "en": "right-click to hide", + "ja": "右クリックで非表示", + "vi": "chuột phải để ẩn"}, + # The name on the launcher pill. Deliberately the same in every language — + # it is a product name, and it only shows on hover, so length is not a + # constraint the way it was on a permanently visible badge. + "help_agent.badge": { + "en": "AI Assistant", "ja": "AI Assistant", "vi": "AI Assistant"}, + "help_agent.more_tooltip": { + "en": "More", "ja": "その他", "vi": "Thêm"}, "help_agent.show_tooltip": { - "en": "Show the App Assistant", "ja": "アプリアシスタントを表示", - "vi": "Hiện App Assistant"}, + "en": "Show the AI Assistant", "ja": "AI Assistant を表示", + "vi": "Hiện AI Assistant"}, "help_agent.empty_reply": { "en": "(no answer)", "ja": "(回答なし)", "vi": "(không có phản hồi)"}, "help_agent.error": { @@ -886,6 +923,14 @@ STRINGS: Dict[str, Dict[str, str]] = { "schedtask.script_placeholder": { "en": "(script tasks only) e.g. python report.py", "ja": "(Scriptタスクのみ)例: python report.py", "vi": "(chỉ task Script) vd: python report.py"}, + # The title/description block at the top of the Task editor had no name + # either — needed once the index had to list it. + "schedtask.g_basic": {"en": "Basics", "ja": "基本", "vi": "Thông tin chung"}, + # The three steps the editor is split into: what to do, when, and what it + # connects to. Each holds the same group boxes as before. + "schedtask.step_content": {"en": "Content", "ja": "内容", "vi": "Nội dung"}, + "schedtask.step_schedule": {"en": "Schedule", "ja": "スケジュール", "vi": "Lịch chạy"}, + "schedtask.step_link": {"en": "Links", "ja": "連携", "vi": "Liên kết"}, "schedtask.g_schedule": {"en": "Schedule Setup", "ja": "スケジュール設定", "vi": "Thiết lập lịch chạy"}, "schedtask.sched_enable": {"en": "Enable schedule", "ja": "スケジュールを有効化", "vi": "Bật lịch chạy"}, "schedtask.f_run_at": {"en": "Run at", "ja": "実行日時", "vi": "Chạy lúc"}, @@ -1241,7 +1286,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "dashboard.card_in": {"en": "Input", "ja": "入力", "vi": "Input"}, "dashboard.card_out": {"en": "Output", "ja": "出力", "vi": "Output"}, "dashboard.card_cache": {"en": "Cache", "ja": "キャッシュ", "vi": "Cache"}, - "dashboard.card_cost": {"en": "Total cost", "ja": "合計コスト", "vi": "Tổng chi phí"}, + "dashboard.card_cost": {"en": "Cost", "ja": "コスト", "vi": "Chi phí"}, "dashboard.card_turns": {"en": "{n} turns", "ja": "{n} ターン", "vi": "{n} lượt"}, "dashboard.prices_label": { "en": "Unit price (USD / 1M tokens):", "ja": "単価 (USD / 100万トークン):", @@ -1276,7 +1321,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "dashboard.ref_last_week": {"en": "Last week", "ja": "先週", "vi": "Tuần trước"}, "dashboard.ref_last_month": {"en": "Last month", "ja": "先月", "vi": "Tháng trước"}, "dashboard.ref_last_year": {"en": "Last year", "ja": "昨年", "vi": "Năm trước"}, - "usage.budget_title": {"en": "Budget", "ja": "予算", "vi": "Budget"}, + "usage.budget_title": {"en": "Budget", "ja": "予算", "vi": "Ngân sách"}, "usage.budget_no_budget": {"en": "No budget set", "ja": "予算未設定", "vi": "Chưa đặt Budget"}, "usage.budget_used_pct": {"en": "{pct}% used", "ja": "{pct}% 使用済み", "vi": "Đã dùng {pct}%"}, "usage.budget_over_warning": {"en": "⚠ Over 85% of budget used", @@ -1441,6 +1486,9 @@ STRINGS: Dict[str, Dict[str, str]] = { "vi": "Hiện thông báo khay hệ thống khi tác vụ xong hoặc lỗi"}, "settings.group.openai": {"en": "OpenAI-compatible (Internal Gateway)", "ja": "OpenAI 互換(社内ゲートウェイ)", "vi": "OpenAI-compatible (Gateway nội bộ)"}, "settings.group.anthropic": {"en": "Anthropic Claude", "ja": "Anthropic Claude", "vi": "Anthropic Claude"}, + # Name for the language/tray block at the top of Settings — it had none, + # 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.param_section_pricing": { @@ -1751,6 +1799,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "vi": "Xóa connector \"{name}\"?"}, "ext.add_title": {"en": "Add connector", "ja": "コネクタを追加", "vi": "Thêm connector"}, "ext.edit_title": {"en": "Edit connector", "ja": "コネクタを編集", "vi": "Sửa connector"}, + "ext.category_label": {"en": "Category", "ja": "カテゴリ", "vi": "Nhóm"}, "ext.preset_label": {"en": "App", "ja": "アプリ", "vi": "Ứng dụng"}, "ext.preset_custom": {"en": "(Custom…)", "ja": "(カスタム…)", "vi": "(Tuỳ chỉnh…)"}, "ext.name_label": {"en": "Display name", "ja": "表示名", "vi": "Tên hiển thị"}, @@ -2338,22 +2387,36 @@ STRINGS: Dict[str, Dict[str, str]] = { # ---- monitoring_tab.py (📊 Monitoring Dashboard) -------------------- "monitoring.title": {"en": "Monitoring Dashboard", "ja": "モニタリングダッシュボード", "vi": "Bảng giám sát"}, "monitoring.refresh": {"en": "Refresh", "ja": "更新", "vi": "Làm mới"}, - "monitoring.tab_security": {"en": "Security Events", "ja": "セキュリティイベント", "vi": "Sự kiện bảo mật"}, - "monitoring.tab_mcp": {"en": "MCP Call History", "ja": "MCP 呼び出し履歴", "vi": "Lịch sử gọi MCP"}, - "monitoring.tab_actions": {"en": "Action Logs", "ja": "アクションログ", "vi": "Nhật ký hành động"}, - "monitoring.tab_agents": {"en": "Agent Status", "ja": "エージェント状態", "vi": "Trạng thái Agent"}, + "monitoring.tab_security": {"en": "Security", "ja": "セキュリティ", "vi": "Bảo mật"}, + "monitoring.tab_mcp": {"en": "MCP", "ja": "MCP", "vi": "MCP"}, + "monitoring.tab_actions": {"en": "Actions", "ja": "アクション", "vi": "Hành động"}, + "monitoring.tab_agents": {"en": "Agent", "ja": "エージェント", "vi": "Agent"}, "monitoring.tab_accounts": {"en": "Accounts", "ja": "アカウント", "vi": "Tài khoản"}, "monitoring.col_time": {"en": "Time", "ja": "時刻", "vi": "Thời gian"}, "monitoring.col_role": {"en": "Agent Role", "ja": "エージェント役割", "vi": "Vai trò Agent"}, "monitoring.col_name": {"en": "Action", "ja": "アクション", "vi": "Hành động"}, "monitoring.col_result": {"en": "Result", "ja": "結果", "vi": "Kết quả"}, + # Security Events shows WHICH rule fired instead of a result that is always + # the same — every security_block is recorded with ok=False. + "monitoring.col_action": {"en": "Action", "ja": "アクション", "vi": "Hành động"}, + # The fourth KPI tile on Overview, as the wireframe labels it. + "monitoring.overview_calls": {"en": "Calls", "ja": "呼び出し", "vi": "Lượt gọi"}, + # The fold under the Sandbox summary line — the wireframe shows only the + # summary, so the ID / created / uptime / limits rows live behind this. + "monitoring.overview_disk_free": { + "en": "{size} free", "ja": "空き {size}", "vi": "{size} trống"}, + "monitoring.overview_disk_label": {"en": "Disk", "ja": "ディスク", "vi": "Đĩa"}, + "monitoring.overview_sbx_detail": { + "en": "Details", "ja": "詳細", "vi": "Chi tiết"}, "monitoring.col_detail": {"en": "Detail", "ja": "詳細", "vi": "Chi tiết"}, "monitoring.col_account": {"en": "Account", "ja": "アカウント", "vi": "Tài khoản"}, "monitoring.col_machine": {"en": "Machine", "ja": "マシン", "vi": "Máy"}, "monitoring.col_agent": {"en": "Agent", "ja": "エージェント", "vi": "Agent"}, - "monitoring.col_active": {"en": "Active", "ja": "稼働中", "vi": "Đang chạy"}, "monitoring.col_source": {"en": "Source", "ja": "ソース", "vi": "Nguồn"}, "monitoring.active_n": {"en": "{n} running", "ja": "{n} 件実行中", "vi": "{n} đang chạy"}, + "monitoring.idle": {"en": "Idle", "ja": "アイドル", "vi": "Rảnh"}, + "monitoring.agent_status_title": { + "en": "Agent Status", "ja": "エージェント状態", "vi": "Trạng thái Agent"}, "monitoring.source_cowork": { "en": "Cowork tab's active turns", "ja": "Cowork タブの実行中ターン", "vi": "Lượt đang chạy của tab Cowork"}, @@ -2384,7 +2447,7 @@ STRINGS: Dict[str, Dict[str, str]] = { # ---- monitoring_tab.py — Overview card dashboard --------------------- "monitoring.tab_overview": {"en": "Overview", "ja": "概要", "vi": "Tổng quan"}, "monitoring.overview_usage_title": { - "en": "Token Usage & Cost", "ja": "トークン使用量とコスト", "vi": "Sử dụng token & Chi phí"}, + "en": "Token & Cost", "ja": "トークンとコスト", "vi": "Token & Chi phí"}, "monitoring.overview_currency": {"en": "Currency:", "ja": "通貨:", "vi": "Tiền tệ:"}, "monitoring.tab_agents_admin": { "en": "Agents Admin", "ja": "エージェント管理", "vi": "Agents Admin"}, @@ -2418,8 +2481,8 @@ STRINGS: Dict[str, Dict[str, str]] = { "下から独自のSVGアイコンを追加でき、名前ですぐ使えます。", "vi": "Các icon dùng cho agent và flow. Gõ tên vào ô Icon của step/agent để dùng. Thêm icon SVG " "của bạn ở dưới — dùng được ngay bằng tên."}, - "icons_admin.search": {"en": "Search built-in icons…", "ja": "組込みアイコンを検索…", "vi": "Tìm icon có sẵn…"}, - "icons_admin.builtin": {"en": "Built-in icons", "ja": "組込みアイコン", "vi": "Icon có sẵn"}, + "icons_admin.search": {"en": "Search icons by name…", "ja": "名前でアイコンを検索…", "vi": "Tìm icon theo tên…"}, + "icons_admin.builtin": {"en": "Built-in icons", "ja": "組込みアイコン", "vi": "Icon tích hợp"}, "icons_admin.custom": {"en": "Custom icons", "ja": "カスタムアイコン", "vi": "Icon tùy chỉnh"}, "icons_admin.add": {"en": "Add SVG file", "ja": "SVGファイルを追加", "vi": "Thêm tệp SVG"}, "icons_admin.paste": {"en": "Paste SVG", "ja": "SVGを貼付", "vi": "Dán SVG"}, @@ -2431,9 +2494,6 @@ STRINGS: Dict[str, Dict[str, str]] = { "icons_admin.select_custom": {"en": "Select a custom icon to delete.", "ja": "削除するカスタムアイコンを選択してください。", "vi": "Hãy chọn một icon tùy chỉnh để xóa."}, - "tools_admin.col_name": {"en": "Tool", "ja": "ツール", "vi": "Tool"}, - "tools_admin.col_desc": {"en": "Description", "ja": "説明", "vi": "Mô tả"}, - "tools_admin.col_enabled": {"en": "Enabled", "ja": "有効", "vi": "Bật"}, "tools_admin.jira_note": { "en": "Jira connection setup moved to the Connector tab → set it up there; here you only turn " "the jira_search / jira_get_issue tools on or off.", @@ -2468,10 +2528,9 @@ STRINGS: Dict[str, Dict[str, str]] = { "en": "Double-click to connect Jira (paste any Jira link — no per-request setup after that).", "ja": "ダブルクリックで Jira に接続(Jira リンクを貼るだけ、以降は設定不要)。", "vi": "Nhấp đúp để kết nối Jira (dán bất kỳ link Jira nào — sau đó không cần thiết lập gì thêm)."}, - "connectors.dbl_configure": { - "en": "Double-click a connector to configure it.", - "ja": "コネクタをダブルクリックして設定します。", - "vi": "Nhấp đúp vào một connector để thiết lập."}, + "connectors.builtin_auto": { + "en": "Built-in, connects automatically", "ja": "組み込み、自動接続", + "vi": "Tích hợp, tự kết nối"}, "connectors.connect_external": { "en": "Connect to external connectors", "ja": "外部コネクタに接続する", @@ -2624,6 +2683,19 @@ STRINGS: Dict[str, Dict[str, str]] = { "vi": "Chạy flow đã chọn ở nền — nhiều flow chạy song song"}, "co4e.running_flows": {"en": "Running flows", "ja": "実行中のフロー", "vi": "Flow đang chạy"}, "co4e.runs_tab": {"en": "Flow Status", "ja": "フロー状態", "vi": "Flow Status"}, + # The flow tab strip was removed, so its pinned Flow Status tab became a + # toggle in the flow toolbar — and that page needs its own way back. + "co4e.tt_runs_tab": { + "en": "Show every flow run", "ja": "すべてのフロー実行を表示", + "vi": "Xem toàn bộ lần chạy flow"}, + "co4e.back_to_flow": {"en": "Back to flow", "ja": "フローに戻る", "vi": "Về flow"}, + "co4e.new_flow_ready": { + "en": "New flow — type a name, then drag agents onto the canvas", + "ja": "新しいフロー — 名前を入力し、エージェントをキャンバスへ", + "vi": "Flow mới — đặt tên rồi kéo agent vào canvas"}, + "co4e.tt_back_to_flow": { + "en": "Back to the flow editor", "ja": "フローエディタに戻る", + "vi": "Quay lại màn dựng flow"}, "co4e.runs_tab_n": {"en": "Flow Status ({n})", "ja": "フロー状態 ({n})", "vi": "Flow Status ({n})"}, "co4e.runs_col_flow": {"en": "Flow", "ja": "フロー", "vi": "Flow"}, "co4e.runs_col_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"}, @@ -2693,6 +2765,9 @@ STRINGS: Dict[str, Dict[str, str]] = { "ja": "フローとチャット — /agent: または /skill:", "vi": "Chat với flow — dùng /agent: hoặc /skill:"}, "co4e.send": {"en": "Send", "ja": "送信", "vi": "Gửi"}, + "co4e.tab_basic": {"en": "Basic", "ja": "基本", "vi": "Cơ bản"}, + "co4e.tab_model_perm": {"en": "Model & Permission", "ja": "モデルと権限", "vi": "Model & Quyền"}, + "co4e.tab_skills_files": {"en": "Skills & Files", "ja": "スキルとファイル", "vi": "Skills & Tệp"}, "co4e.f_label": {"en": "Label", "ja": "ラベル", "vi": "Nhãn"}, "co4e.f_role": {"en": "Role", "ja": "ロール", "vi": "Vai trò"}, "co4e.f_name": {"en": "Name", "ja": "名前", "vi": "Tên"}, @@ -2743,6 +2818,9 @@ 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.edit_row_tooltip": {"en": "Edit", "ja": "編集", "vi": "Sửa"}, + "agents_admin.delete_row_tooltip": {"en": "Delete", "ja": "削除", "vi": "Xóa"}, "agents_admin.hint": { "en": "System-management agents shared across every machine (stored in the shared accounts folder): the help agent and Schedule Task executors. These are NOT the agents you pick in Cowork or Co4E.", "ja": "全マシンで共有されるシステム管理用エージェント(共有フォルダーに保存):ヘルプエージェントやスケジュールタスクの実行エージェントなど。CoworkやCo4Eで選択するエージェントではありません。", @@ -2754,8 +2832,6 @@ STRINGS: Dict[str, Dict[str, str]] = { "en": "Delete agent \"{name}\"?", "ja": "エージェント「{name}」を削除しますか?", "vi": "Xóa agent \"{name}\"?"}, "agents_admin.add_btn": {"en": "Add", "ja": "追加", "vi": "Thêm"}, - "agents_admin.edit_btn": {"en": "Edit", "ja": "編集", "vi": "Sửa"}, - "agents_admin.delete_btn": {"en": "Delete", "ja": "削除", "vi": "Xóa"}, "agents_admin.f_name": {"en": "Name", "ja": "名前", "vi": "Tên"}, "agents_admin.f_kind": {"en": "App function", "ja": "アプリ機能", "vi": "Chức năng App"}, "agents_admin.f_prompt": {"en": "Instructions", "ja": "指示", "vi": "Chỉ dẫn"}, @@ -2787,7 +2863,7 @@ STRINGS: Dict[str, Dict[str, str]] = { "agents_admin.col_enabled": {"en": "Enabled", "ja": "有効", "vi": "Kích hoạt"}, "agents_admin.col_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"}, "agents_admin.col_updated": {"en": "Updated", "ja": "更新", "vi": "Cập nhật"}, - "agents_admin.check_btn": {"en": "Check", "ja": "チェック", "vi": "Kiểm tra"}, + "agents_admin.check_btn": {"en": "Check all", "ja": "すべてチェック", "vi": "Kiểm tra tất cả"}, "agents_admin.check_tooltip": { "en": "Check each agent's effective provider/model connectivity", "ja": "各エージェントの実効プロバイダ/モデルの接続性を確認", @@ -2835,12 +2911,67 @@ STRINGS: Dict[str, Dict[str, str]] = { "en": "AI turns your question into a filter keyword (e.g. \"which commands failed today?\").", "ja": "質問をAIがフィルターキーワードに変換します。", "vi": "AI chuyển câu hỏi thành từ khóa lọc (vd: \"hôm nay lệnh nào bị lỗi?\")."}, + "monitoring.security_detail_title": { + "en": "Event details", "ja": "イベント詳細", "vi": "Chi tiết sự kiện"}, + "monitoring.security_detail_close": { + "en": "Close", "ja": "閉じる", "vi": "Đóng"}, + "monitoring.security_events_title": { + "en": "Security Events", "ja": "セキュリティイベント", "vi": "Sự kiện bảo mật"}, + "monitoring.mcp_history_title": { + "en": "MCP Call History", "ja": "MCP 呼び出し履歴", "vi": "Lịch sử gọi MCP"}, + "monitoring.action_logs_title": { + "en": "Action Logs", "ja": "アクションログ", "vi": "Nhật ký hành động"}, + "monitoring.col_detail_block": { + "en": "Block detail", "ja": "ブロック詳細", "vi": "Chi tiết chặn"}, + + # ---- event-detail panel (ui-audit_v2.html openDetail()) -------------- + "monitoring.detail_section_general": { + "en": "General info", "ja": "基本情報", "vi": "Thông tin chung"}, + "monitoring.detail_section_action": { + "en": "Action", "ja": "アクション", "vi": "Hành động"}, + "monitoring.detail_section_metadata": { + "en": "Metadata", "ja": "メタデータ", "vi": "Metadata"}, + "monitoring.detail_type": {"en": "Type", "ja": "種類", "vi": "Loại"}, + "monitoring.detail_status": {"en": "Status", "ja": "ステータス", "vi": "Trạng thái"}, + "monitoring.detail_event_id": {"en": "Event ID", "ja": "イベントID", "vi": "Event ID"}, + "monitoring.detail_policy": {"en": "Policy", "ja": "ポリシー", "vi": "Policy"}, + "monitoring.detail_severity": {"en": "Severity", "ja": "重大度", "vi": "Severity"}, + "monitoring.detail_copy": {"en": "Copy", "ja": "コピー", "vi": "Copy"}, + "monitoring.detail_copied": {"en": "Copied", "ja": "コピー済み", "vi": "Đã copy"}, + + # Trạng thái pill — which rule fired, phrased as the enforcement outcome + # (distinct wording from the Loại/action_* labels below, matching + # ui-audit_v2.html's statusInfo() vs actionLabel). + "monitoring.status_blocked": {"en": "Blocked", "ja": "ブロック済み", "vi": "Đã chặn"}, + "monitoring.status_path": {"en": "Path blocked", "ja": "パスをブロック", "vi": "Path chặn"}, + "monitoring.status_network": {"en": "Network blocked", "ja": "ネットワークをブロック", "vi": "Mạng chặn"}, + "monitoring.status_secret": {"en": "Secret leaked", "ja": "シークレット漏洩", "vi": "Bí mật lộ"}, + "monitoring.status_ok": {"en": "Succeeded", "ja": "成功", "vi": "Thành công"}, + "monitoring.status_failed": {"en": "Failed", "ja": "失敗", "vi": "Thất bại"}, + + "monitoring.severity_critical": {"en": "CRITICAL", "ja": "CRITICAL", "vi": "CRITICAL"}, + "monitoring.severity_medium": {"en": "MEDIUM", "ja": "MEDIUM", "vi": "MEDIUM"}, + "monitoring.severity_info": {"en": "INFO", "ja": "INFO", "vi": "INFO"}, + + # Loại field — a human label for the raw event name (audit_log ``name``). + "monitoring.action_prompt": {"en": "Risky prompt", "ja": "危険なプロンプト", "vi": "Prompt rủi ro"}, + "monitoring.action_dangerous_command": { + "en": "Dangerous command", "ja": "危険なコマンド", "vi": "Lệnh nguy hiểm"}, + "monitoring.action_install_package": { + "en": "Package install", "ja": "パッケージインストール", "vi": "Cài đặt gói"}, + "monitoring.action_path_outside_sandbox": { + "en": "Path outside sandbox", "ja": "サンドボックス外のパス", "vi": "Path ngoài sandbox"}, + "monitoring.action_network_blocked": { + "en": "Network blocked", "ja": "ネットワークブロック", "vi": "Mạng bị chặn"}, + "monitoring.action_secret_in_output": { + "en": "Secret disclosed", "ja": "シークレット漏洩", "vi": "Tiết lộ bí mật"}, + "monitoring.overview_activity_title": { - "en": "Recent Activity", "ja": "最近のアクティビティ", "vi": "Hoạt động gần đây"}, + "en": "Recent log", "ja": "最近のログ", "vi": "Nhật ký gần đây"}, "monitoring.overview_no_activity": { "en": "No activity yet.", "ja": "まだアクティビティはありません。", "vi": "Chưa có hoạt động nào."}, "monitoring.overview_resource_title": { - "en": "Resource Usage", "ja": "リソース使用状況", "vi": "Sử dụng tài nguyên"}, + "en": "Resources", "ja": "リソース", "vi": "Tài nguyên"}, "monitoring.overview_res_cpu": {"en": "CPU", "ja": "CPU", "vi": "CPU"}, "monitoring.overview_res_mem": {"en": "Memory", "ja": "メモリ", "vi": "Bộ nhớ"}, "monitoring.overview_res_disk": {"en": "Disk I/O", "ja": "ディスク I/O", "vi": "Disk I/O"}, @@ -2867,7 +2998,9 @@ STRINGS: Dict[str, Dict[str, str]] = { "monitoring.pricing_col_input": {"en": "Input price", "ja": "入力単価", "vi": "Giá input"}, "monitoring.pricing_col_output": {"en": "Output price", "ja": "出力単価", "vi": "Giá output"}, "monitoring.overview_sandbox_details_title": { - "en": "Sandbox Details", "ja": "サンドボックス詳細", "vi": "Chi tiết Sandbox"}, + # One section now, holding both the sandbox facts and the permissions. + "en": "Sandbox & Permissions", "ja": "サンドボックスと権限", + "vi": "Sandbox & Quyền"}, "monitoring.overview_sandbox_id": {"en": "Sandbox ID", "ja": "サンドボックス ID", "vi": "Sandbox ID"}, "monitoring.overview_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"}, "monitoring.overview_status_running": {"en": "Running", "ja": "実行中", "vi": "Đang chạy"}, diff --git a/podman-compose.preview.vibeflow.yaml b/podman-compose.preview.vibeflow.yaml new file mode 100644 index 0000000..1344e09 --- /dev/null +++ b/podman-compose.preview.vibeflow.yaml @@ -0,0 +1,23 @@ +services: + cowork-desktop: + image: python:3.11-slim-bookworm + container_name: cowork-local-desktop-preview + working_dir: /workspace + volumes: + - /workspace:/workspace:Z + - pip-cache:/root/.cache/pip:Z + ports: + - "6080:6080" + environment: + PYTHONUNBUFFERED: "1" + QT_QPA_PLATFORM: "vnc:size=1280x800:depth=32" + QT_QPA_VNC_HOST: "127.0.0.1" + QT_QPA_VNC_PORT: "5900" + QSG_RHI_BACKEND: "software" + PYTHONPATH: "/opt" + entrypoint: ["/bin/sh", "/workspace/.vibeflow-preview/entrypoint.sh"] + command: [] + restart: unless-stopped + +volumes: + pip-cache: {} diff --git a/preview-desktop b/preview-desktop new file mode 100644 index 0000000..e69de29 diff --git a/requirements (cloud copy).txt b/requirements (cloud copy).txt new file mode 100644 index 0000000..09f75e4 --- /dev/null +++ b/requirements (cloud copy).txt @@ -0,0 +1,9 @@ +PySide6>=6.6 +pydantic>=2 +requests +psutil +pygments +openpyxl +python-pptx +networkx +pytest diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a762b54 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,24 @@ +# Cowork-Local BamBOO — dependencies +# Install: pip install -r requirements.txt + +# --- Core UI framework --- +PySide6>=6.6.0 + +# --- HTTP client --- +requests>=2.31.0 + +# --- Process & resource monitoring --- +psutil>=5.9.0 + +# --- Document handling --- +openpyxl>=3.1.0 # Excel (.xlsx) creation & reading +python-pptx>=0.6.0 # PowerPoint (.pptx) editing +pypdf>=4.0.0 # PDF text extraction (preferred) +# PyPDF2>=3.0.0 # PDF fallback (optional, pypdf preferred) + +# --- Microsoft 365 integration --- +msal>=1.24.0 # OAuth device-code flow for MS365 +keyring>=24.0.0 # OS credential store (token cache) + +# --- MCP (Model Context Protocol) --- +mcp>=1.0.0 # MCP client SDK (stdio transport) \ No newline at end of file diff --git a/slides/cowork-local-bamboo/deck.html b/slides/cowork-local-bamboo/deck.html new file mode 100644 index 0000000..1569d14 --- /dev/null +++ b/slides/cowork-local-bamboo/deck.html @@ -0,0 +1,677 @@ + + + + + +Cowork-Local BamBOO + + + +
+
Cowork-Local BamBOO
+
1 / N
+
+ + + +
+
+ + + + + + + + +

Cowork-Local BamBOO

+

Enterprise AI Business Assistant

+ Slide 1 / 11 +
+

Mục đích ứng dụng

+
+
+
Vấn đề
+
    +
  • Nhân viên văn phòng cần AI hỗ trợ tác vụ hàng ngày: tổng hợp báo cáo, phân tích dữ liệu, tạo tài liệu.
  • +
  • Giải pháp hiện tại quá đơn giản (chat thuần túy) hoặc quá phức tạp (cần kiến thức lập trình).
  • +
  • Thiếu công cụ AI doanh nghiệp: bảo mật, quản lý tài khoản, tích hợp hệ thống văn phòng.
  • +
+
+
+
Giải pháp — Cowork-Local BamBOO
+
    +
  • Ứng dụng desktop AI đa năng cho doanh nghiệp.
  • +
  • Hỗ trợ 3 ngôn ngữ: Việt, Anh, Nhật.
  • +
  • Tích hợp Microsoft 365 (OneDrive, SharePoint).
  • +
  • Quản lý tài khoản (Admin/Sub-admin/User), phân quyền, giám sát chi phí.
  • +
  • Không cần kiến thức lập trình — dùng như chat, kết quả là file thực tế.
  • +
+
+
+ Slide 2 / 13 +
+

Kiến trúc tổng quan

+
+
+
UI Layer (PySide6/Qt)
+
Dashboardthống kê chi phí
+
ScheduleKanban board
+
Workspace5 sub-tabs
+
MonitoringSecurity · MCP · Logs
+
Settings · Login · Help
+
+
+
Core Business Logic
+
Chat AgentCowork
+
Code Agent
+
Co4E WorkflowDAG multi-agent
+
Schedule Taskcron + chaining
+
Projects · Accounts · Skills
+
Doc Extract · PPTX · XLSX
+
+
+
Routing & Providers
+
Auto Model Routingclassify → score → select
+
OpenAI Compatible
+
Anthropic · Ollama
+
Copilot · Codex
+
Model Pricing · Benchmark
+
+
+
Security & Integration
+
Agent Security3 lớp bảo mật
+
Sandbox Managerrisk-based
+
MCP Client + Servers
+
MS365 · Jira · Ext
+
Audit Log · Usage Tracker
+
Doc Extract · Image Gen
+
+
+
Bảo mật xuyên suốt: Risk Classifier → Backend Selector → Execution (Direct / Integrity Job / AppContainer / Win Sandbox).
+
Kiến trúc 4 lớp: UI → Core → Routing/Providers → Security/Integration. Mỗi layer có thể mở rộng độc lập. Tổng cộng 50+ module trong core/, 30+ UI components. Hỗ trợ Windows, macOS, Linux. I18n: Tiếng Việt, English, 日本語. Dark/Light theme tích hợp sẵn.
+
Thiết kế modular: mỗi layer giao tiếp qua interface rõ ràng, dễ dàng thay thế hoặc mở rộng thành phần.
+ Slide 3 / 13 +
+

Luồng xử lý chính: Chat Cowork

+
+
User Input + file
+ → +
Chat Agentapply skills · rules · project ctx
+ → +
Auto Model Routingclassify → rank → switch
+ → +
Agent Security L1prompt validate
+
+
+
Provider Chat Loopstreaming response
+ → +
Tool Callingfile / command / MCP / MS365
+ → +
Security L2+L3attachment + command check
+ → +
Output Files.xlsx · .pptx · .docx · .md
+
+
Kết quả là file thực tế — không chỉ là câu trả lời chat, AI tạo ra tài liệu/báo cáo có thể dùng ngay.
+
Tool calling hỗ trợ: file I/O, command execution, MCP connectors, MS365 Graph API, Jira, và external connectors framework.
+
Đa provider: OpenAI, Anthropic, Ollama, GitHub Copilot, Codex — tự động chọn model phù hợp.
+ Slide 4 / 13 +
+ +
+

Luồng xử lý: Co4E Workflow

+
+
Wave 0Step A
+ → +
Wave 1 (parallel)Sub 1 + Sub 2 chạy đồng thời
+ → +
Wave 2 (join)Coordinator tổng hợp
+
+
+
+
Mỗi step
+
    +
  • Agent persona (built-in / custom)
  • +
  • Model riêng
  • +
  • Permission preset
  • +
  • Self-verify (quality gate)
  • +
  • Skills đính kèm
  • +
+
+
+
Run modes
+
    +
  • Auto — AI tự thực hiện
  • +
  • Plan — read-only
  • +
  • Manual — step-by-step
  • +
+
+
+
Lưu ý
+
    +
  • Workflow là DAG — không có retry/loop/condition/branch tự động.
  • +
  • Parallel node chạy sub-agent đồng thời + join stage.
  • +
+
+
+ Slide 5 / 13 +
+ +
+

Luồng xử lý: Schedule Task

+
+
Backlog
+ → +
Scheduled
+ → +
Running
+ → +
Done
+
+
+
+
Trạng thái phụ
+
    +
  • Paused
  • +
  • Failed
  • +
  • Waiting Input
  • +
+
+
+
Lập lịch
+
    +
  • One-shot · Daily · Weekly · Monthly · Cron
  • +
  • Skip: working days + holiday calendar
  • +
  • Task chaining (fan-in depends_on)
  • +
+
+
+
Kiểm soát
+
    +
  • Retry: max_retry
  • +
  • Timeout: per-task (600s)
  • +
  • Notify: Teams webhook / Outlook desktop
  • +
+
+
+
Hỗ trợ import task từ CSV/Excel, tự động chain theo thứ tự, và lịch nghỉ lễ (VN/JP/US/KR…).
+ Slide 6 / 13 +
+

Chức năng hiện tại (1/4)

+

Chat & Agent

+ + + + + + + + + +
Chức năngMô tảTrạng thái
Cowork ChatChat với AI, đính kèm file, nhận output file thực tế✅ Hoàn chỉnh
Code AgentAgent chuyên biệt cho task phát triển phần mềm✅ Hoàn chỉnh
AI EditChỉnh sửa file bằng AI, hỗ trợ tạo ảnh minh họa✅ Hoàn chỉnh
Help AgentTrợ lý hỗ trợ sử dụng app, luôn sẵn sàng✅ Hoàn chỉnh
Multi-providerOpenAI, Anthropic, Ollama, GitHub Copilot, Codex✅ Hoàn chỉnh
+

Workspace & Project

+ + + + + + + + +
Chức năngMô tảTrạng thái
ProjectsMỗi project có instructions + sandbox riêng✅ Hoàn chỉnh
Structure GraphĐồ thị cấu trúc từ code/tài liệu (AST-based)✅ Hoàn chỉnh
Folder ViewerXem & chỉnh sửa file (PDF/DOCX/XLSX)✅ Hoàn chỉnh
TerminalTerminal tích hợp trong app✅ Hoàn chỉnh
+
Tổng cộng 9 tính năng trong nhóm Chat & Agent và Workspace & Project, tất cả đã hoàn chỉnh và sẵn sàng sử dụng. Multi-provider hỗ trợ OpenAI, Anthropic, Ollama, GitHub Copilot, Codex.
+ Slide 7 / 13 +
+ +
+

Chức năng hiện tại (2/4) — Automation · Integration

+

Automation

+ + + + + + + + +
Chức năngMô tảTrạng thái
Co4E WorkflowDAG workflow đa bước, multi-agent, chạy song song✅ Hoàn chỉnh
Schedule TaskLên lịch task tự động, Kanban board, cron, chaining✅ Hoàn chỉnh
SkillsThư viện skill tích hợp sẵn (5 skills), Skill Manager✅ Hoàn chỉnh
Plan ChecklistAgent tự động tạo & theo dõi checklist công việc✅ Hoàn chỉnh
+

Integration

+ + + + + + + + +
Chức năngMô tảTrạng thái
Microsoft 365OneDrive (đọc/ghi), SharePoint (đọc) — auto-connect✅ Hoàn chỉnh
MCP ConnectorsKết nối external tools qua Model Context Protocol✅ Hoàn chỉnh
JiraRead-only: search issues, get issue details✅ Hoàn chỉnh
Ext ConnectorsFramework CAD/CAE/MS365/Other✅ Hoàn chỉnh
+ Slide 8 / 13 +
+ +
+

Chức năng hiện tại (3/4) — Administration

+ + + + + + + + + + + + + +
Chức năngMô tảTrạng thái
Tài khoản & RBACAdmin / Sub-admin / User, import/export Excel✅ Hoàn chỉnh
GroupsNhóm tài khoản để phân quyền theo nhóm✅ Hoàn chỉnh
DashboardThống kê token usage & chi phí, biểu đồ spline✅ Hoàn chỉnh
Auto Model RoutingBenchmark model, tự động định tuyến (Auto/Manual/Off)✅ Hoàn chỉnh
Agent Security3 lớp bảo mật: prompt, attachment, command✅ Hoàn chỉnh
SandboxRisk-based: Direct/Integrity/AppContainer/Win Sandbox✅ Hoàn chỉnh
MonitoringOverview, Security, MCP, Logs, Agent Status✅ Hoàn chỉnh
Audit Logtool_call, permission, security_block, mcp_call✅ Hoàn chỉnh
Model PricingBảng giá model, tùy chỉnh USD/token✅ Hoàn chỉnh
+ Slide 9 / 13 +
+ +
+

Chức năng hiện tại (4/4) — Document & File

+ + + + + + + + + +
Chức năngMô tảTrạng thái
Doc ExtractTrích xuất text từ PDF/DOCX/XLSX/PPTX/images✅ Hoàn chỉnh
PPTX EditTạo và chỉnh sửa PowerPoint files✅ Hoàn chỉnh
XLSX WriteTạo Excel files với styling✅ Hoàn chỉnh
Image GenTạo ảnh bằng AI✅ Hoàn chỉnh
Link FetchFetch URL preview cho task attachments✅ Hoàn chỉnh
+
Tổng cộng: 30+ tính năng đã hoàn chỉnh, sẵn sàng dùng trong doanh nghiệp.
+
Doc Extract hỗ trợ PDF, DOCX, XLSX, PPTX, images. PPTX Edit tạo và chỉnh sửa slide với font/styling. XLSX Write tạo Excel có màu sắc, border.
+
AI tạo file trực tiếp — từ câu lệnh chat, AI sinh ra tài liệu/báo cáo/ảnh dùng ngay được.
+ Slide 10 / 13 +
+

Hướng dẫn build & chạy ứng dụng

+
+
+
Yêu cầu hệ thống
+
    +
  • Python 3.10+ (khuyến nghị 3.11/3.12)
  • +
  • OS: Windows 10/11, macOS, Linux
  • +
  • Network: cần internet để cài dependencies & gọi API AI
  • +
+
Cài & chạy
+
    +
  • pip install -r requirements.txt
  • +
  • python -m cowork_local hoặc python __main__.py
  • +
+
+
+
Cấu hình API key
+
    +
  • Settings (⚙) → chọn provider → nhập API key & base URL.
  • +
  • Hoặc đặt qua environment variables: OPENAI_API_KEY, OPENAI_BASE_URL…
  • +
+
Lưu ý
+
    +
  • Cấu hình lưu tại ~/.cowork_local/config.json
  • +
  • Một số package (như opendataloader-pdf) tự cài khi cần lần đầu.
  • +
  • Lỗi No module named cowork_local → chạy python __main__.py.
  • +
+
+
+ Slide 11 / 13 +
+ +
+

Kịch bản Demo

+
+
+
Demo 1 — File → AI → Báo cáo + OneDrive
+
    +
  • Mở Cowork Chat, đính kèm file báo cáo doanh thu (.xlsx/.pdf).
  • +
  • Gõ: phân tích số liệu, tạo báo cáo .xlsx có màu + viết .md lên OneDrive.
  • +
  • AI: đọc → phân tích → tạo Excel → upload text lên OneDrive → trả link.
  • +
+
Demo 1: OneDrive write chỉ hỗ trợ text files (.md, .txt). Demo 2: Teams webhook + Outlook desktop notification. Demo 3: Auto mode chạy toàn bộ workflow tự động.
+
+
+
Demo 2 — Schedule Task tự động
+
    +
  • Vào Schedule → tạo task, đặt lịch "8h sáng thứ 2 hàng tuần".
  • +
  • Nội dung: đọc file doanh thu, phân tích, tạo báo cáo .xlsx.
  • +
  • Bật working_days_only + skip_holidays.
  • +
  • Task tự chạy, kết quả lưu trong task artifacts.
  • +
+
+
+
Demo 3 — Co4E Workflow phân tích dự án
+
    +
  • Step 1 (Research): đọc code, phân tích kiến trúc.
  • +
  • Step 2 (Implement - parallel): 2 sub-agent cùng chạy (unit test + docs).
  • +
  • Step 3 (Join + Review): tổng hợp, kiểm tra chất lượng.
  • +
  • Chạy Auto mode → AI tự thực hiện từng bước.
  • +
+
+
+ Slide 12 / 13 +
+ +
+

Tóm tắt

+
+
+
Dễ dùng & Đa năng
+
    +
  • Giao diện chat, không cần code.
  • +
  • Chat, workflow, schedule, code, Structure Graph, skills.
  • +
+
+
+
Bảo mật & Tiết kiệm
+
    +
  • 3 lớp Agent Security + Sandbox risk-based.
  • +
  • Auto Model Routing, theo dõi chi phí.
  • +
+
+
+
Tích hợp & Quản trị
+
    +
  • MS365 (OneDrive/SharePoint), MCP, Jira.
  • +
  • RBAC, Dashboard, Audit Log, Groups.
  • +
+
+
+
Cowork-Local BamBOO — công cụ AI doanh nghiệp toàn diện: dễ dùng, bảo mật, tiết kiệm, tích hợp, quản trị, đa năng.
+
30+ tính năng đã hoàn chỉnh — sẵn sàng triển khai trong doanh nghiệp ngay hôm nay.
+ Slide 13 / 13 +
+ + + diff --git a/slides/cowork-local-bamboo/deck.pdf b/slides/cowork-local-bamboo/deck.pdf new file mode 100644 index 0000000..71b5091 Binary files /dev/null and b/slides/cowork-local-bamboo/deck.pdf differ diff --git a/slides/cowork-local-bamboo/deck.pptx b/slides/cowork-local-bamboo/deck.pptx new file mode 100644 index 0000000..367e6a9 Binary files /dev/null and b/slides/cowork-local-bamboo/deck.pptx differ diff --git a/tests/pytest-cache-files-kggsphad/CACHEDIR.TAG b/tests/pytest-cache-files-kggsphad/CACHEDIR.TAG new file mode 100644 index 0000000..e69de29 diff --git a/theme.py b/theme.py index 9768e70..fd6160c 100644 --- a/theme.py +++ b/theme.py @@ -1,295 +1,907 @@ -"""Qt style sheets giving the app a modern, dark design-tool look (deep -ocean-blue surfaces, large rounded corners, a deep-sea gradient accent, and -colorful pill badges) inspired by contemporary dashboard UIs.""" -from __future__ import annotations - -# Brand accent — deep sea blue palette with teal-cyan gradients. -ACCENT = "#0096C7" -ACCENT_HOVER = "#48CAE4" -ACCENT2 = "#0077B6" # deeper ocean blue for gradients -GRADIENT = f"qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {ACCENT2}, stop:1 {ACCENT})" -GRADIENT_HOVER = f"qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #023E8A, stop:1 {ACCENT_HOVER})" - -_DARK = f""" -* {{ font-family: "Segoe UI", "Helvetica Neue", "Arial", "Yu Gothic UI", "Meiryo", sans-serif; font-size: 13px; }} -QMainWindow, QWidget {{ background: #0A1628; color: #E0F0FF; }} -QMainWindow::separator {{ background: #0A1628; width: 4px; height: 4px; }} -QStatusBar {{ background: #0A1628; color: #5C8DB8; border-top: 1px solid #132240; }} -QSplitter::handle {{ background: #0A1628; }} -/* Separate the nav rail (its own panel + divider) from the content area. */ -QWidget#navWrap {{ background: #0D1F35; border-right: 1px solid #17263f; }} -QWidget#navWrap QTreeWidget, QWidget#navWrap QListWidget {{ background: transparent; border: none; }} -QWidget#contentArea {{ background: #0A1628; }} -/* Compact icons app-wide (they looked oversized on scaled displays). */ -QPushButton, QToolButton, QComboBox, QTabBar {{ qproperty-iconSize: 14px 14px; }} -QTreeWidget#navrail {{ qproperty-iconSize: 16px 16px; }} - -/* Square the pane (tabs above stay rounded): a rounded pane lets its square - child pages poke past the corners — the "rectangle behind the rounded box". */ -QTabWidget::pane {{ background: #0D1F35; border: 1px solid #132240; border-radius: 0px; top: 2px; }} -QTabBar {{ background: transparent; }} -QTabBar::tab {{ - background: #111D32; color: #5C8DB8; padding: 9px 22px; margin: 0 6px 8px 0; - border-radius: 10px; border: 1px solid #132240; font-weight: 500; -}} -QTabBar::tab:selected {{ background: {GRADIENT}; color: white; border: none; font-weight: 700; }} -QTabBar::tab:hover:!selected {{ background: #172A45; color: #B0D4F1; }} -/* Co4E flow "browser" tabs: sit FLUSH in their row (no floating bottom gap, so - the icon/label is vertically centred) and use the app's panel/accent surfaces - so the strip reads as part of the app. */ -QTabBar#flowTabs::tab {{ background: #0D1F35; color: #8FB2D4; border: 1px solid #17263f; - padding: 6px 10px; margin: 0 3px 0 0; min-height: 22px; border-radius: 8px; }} -QTabBar#flowTabs::tab:selected {{ background: {GRADIENT}; color: white; border: 1px solid transparent; }} -QTabBar#flowTabs::tab:hover:!selected {{ background: #172A45; color: #E0F0FF; }} -/* The "new flow" (+) button — styled as the last tab in the strip. */ -QPushButton#flowAddBtn {{ background: #0D1F35; color: #8FB2D4; border: 1px solid #17263f; - border-radius: 8px; padding: 6px 0; font-size: 15px; font-weight: bold; min-height: 22px; }} -QPushButton#flowAddBtn:hover {{ background: #172A45; color: #E0F0FF; }} -/* Co4E sidebar (Workflows/Agents/Skills): transparent icon tabs with a subtle - translucent selection + the normal text colour (matches the app's lists, - not a bright fill). */ -QTabBar#co4eSideTabs {{ qproperty-iconSize: 18px 18px; }} -QTabBar#co4eSideTabs::tab {{ background: transparent; color: #8FB2D4; border: none; - border-radius: 6px; padding: 5px; margin: 0 6px 0 0; }} -QTabBar#co4eSideTabs::tab:selected {{ background: rgba(0,150,199,0.28); color: #E0F0FF; }} -QTabBar#co4eSideTabs::tab:hover:!selected {{ background: #172A45; color: #B0D4F1; }} -/* Co4E canvas frame — match the app's other framed surfaces (not a faint hairline). */ -QGraphicsView#co4eCanvas {{ background: #0D1F35; border: 1px solid #1A2D4A; border-radius: 10px; }} - -QGroupBox {{ - background: #0D1F35; border: 1px solid #132240; border-radius: 18px; - margin-top: 16px; padding: 14px 10px 10px 10px; font-weight: 700; -}} -QGroupBox::title {{ - subcontrol-origin: margin; subcontrol-position: top left; left: 12px; top: 2px; - padding: 0 6px; color: #5C8DB8; letter-spacing: 0.5px; -}} - -QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QTreeView, QListView, QTreeWidget, QListWidget {{ - background: #111D32; color: #E0F0FF; border: 1px solid #1A2D4A; border-radius: 10px; - selection-background-color: {ACCENT}; selection-color: white; -}} -/* Scroll containers must NOT paint their own square panel behind rounded - children (that square is what shows as a "rectangle under the rounded box"). - The corner where scrollbars meet is squared off too — keep it transparent. */ -QScrollArea {{ background: transparent; border: none; }} -QAbstractScrollArea::corner {{ background: transparent; }} -QPlainTextEdit:focus, QTextEdit:focus, QLineEdit:focus {{ border: 1px solid {ACCENT}; }} -QTreeView::item, QListView::item {{ padding: 3px 2px; border-radius: 6px; }} -QTreeView::item:hover, QListView::item:hover {{ background: #172A45; }} -QTreeView::item:selected, QListView::item:selected {{ background: rgba(0,150,199,0.28); color: #E0F0FF; }} -QHeaderView::section {{ background: #111D32; color: #5C8DB8; border: none; border-bottom: 1px solid #132240; padding: 6px; }} - -QPushButton {{ - background: #132240; color: #E0F0FF; border: 1px solid #1A2D4A; - border-radius: 10px; padding: 8px 16px; -}} -QPushButton:hover {{ background: #1A3050; border-color: #234070; }} -QPushButton:pressed {{ background: #111D32; }} -QPushButton:disabled {{ color: #3A5A78; background: #0F1A28; border-color: #132240; }} -QPushButton#primary {{ background: {GRADIENT}; color: white; border: none; font-weight: 700; padding: 8px 18px; }} -QPushButton#primary:hover {{ background: {GRADIENT_HOVER}; }} -QPushButton#primary:disabled {{ background: #1A2D4A; color: #3A5A78; }} -QPushButton#danger {{ background: #E5484D; color: white; border: none; font-weight: 600; }} -QPushButton#danger:hover {{ background: #EF6368; }} -QPushButton#navMenuBtn {{ - background: transparent; border: none; border-radius: 8px; padding: 5px 6px; - font-weight: 700; font-size: 11px; letter-spacing: 1px; color: #5C8DB8; text-align: left; -}} -QPushButton#navMenuBtn:hover {{ background: #132240; color: #E0F0FF; }} -QPushButton#navMenuBtn:pressed {{ background: #111D32; }} - -QLabel#badge {{ background: #0A2A3A; color: #48CAE4; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#badgeSuccess {{ background: #0A2A20; color: #48D9A0; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#badgePurple {{ background: #1A1A3A; color: #9B8FF7; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#badgePink {{ background: #2A1A30; color: #D980C0; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#badgeWarn {{ background: #0A2A3A; color: #48CAE4; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#hint {{ color: #5C8DB8; }} -QLabel#warning {{ color: #48CAE4; font-weight: 600; }} - -QComboBox {{ background: #111D32; border: 1px solid #1A2D4A; border-radius: 10px; padding: 6px 10px; }} -QComboBox:hover {{ border-color: #234070; }} -QComboBox::drop-down {{ border: none; width: 22px; }} -QComboBox QAbstractItemView {{ - background: #111D32; border: 1px solid #1A2D4A; border-radius: 10px; - selection-background-color: {ACCENT}; selection-color: white; outline: none; -}} - -QScrollBar:vertical {{ background: transparent; width: 11px; margin: 2px; }} -QScrollBar::handle:vertical {{ background: #1A2D4A; border-radius: 5px; min-height: 28px; }} -QScrollBar::handle:vertical:hover {{ background: {ACCENT}; }} -QScrollBar:horizontal {{ background: transparent; height: 11px; margin: 2px; }} -QScrollBar::handle:horizontal {{ background: #1A2D4A; border-radius: 5px; min-width: 28px; }} -QScrollBar::handle:horizontal:hover {{ background: {ACCENT}; }} -QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; width: 0; border: none; background: none; }} - -QCheckBox {{ spacing: 8px; }} -QCheckBox::indicator, QRadioButton::indicator {{ - width: 16px; height: 16px; background: #111D32; border: 1px solid #1A2D4A; border-radius: 5px; -}} -QRadioButton::indicator {{ border-radius: 9px; }} -QCheckBox::indicator:hover, QRadioButton::indicator:hover {{ border-color: {ACCENT}; }} -QCheckBox::indicator:checked, QRadioButton::indicator:checked {{ - background: {GRADIENT}; border-color: {ACCENT}; -}} -QCheckBox::indicator:disabled {{ border-color: #132240; background: #0F1A28; }} -QCheckBox::indicator:checked:disabled, QRadioButton::indicator:checked:disabled {{ - background: #1A3050; border-color: #234070; -}} - -QMenu {{ background: #111D32; color: #E0F0FF; border: 1px solid #1A2D4A; border-radius: 10px; padding: 4px; }} -QMenu::item {{ padding: 6px 16px; border-radius: 6px; }} -QMenu::item:selected {{ background: {ACCENT}; color: white; }} - -QToolTip {{ background: #172A45; color: #E0F0FF; border: 1px solid #1A2D4A; border-radius: 6px; padding: 4px 8px; }} -""" - -_LIGHT = f""" -* {{ font-family: "Segoe UI", "Helvetica Neue", "Arial", "Yu Gothic UI", "Meiryo", sans-serif; font-size: 13px; }} -QMainWindow, QWidget {{ background: #E8F4FD; color: #1A2332; }} -QStatusBar {{ background: #E8F4FD; color: #5C8DB8; border-top: 1px solid #B8D4E8; }} -/* Separate the nav rail (its own panel + divider) from the content area. */ -QWidget#navWrap {{ background: #EDF5FB; border-right: 1px solid #C4DBEC; }} -QWidget#navWrap QTreeWidget, QWidget#navWrap QListWidget {{ background: transparent; border: none; }} -QWidget#contentArea {{ background: #E8F4FD; }} -/* Compact icons app-wide (they looked oversized on scaled displays). */ -QPushButton, QToolButton, QComboBox, QTabBar {{ qproperty-iconSize: 14px 14px; }} -QTreeWidget#navrail {{ qproperty-iconSize: 16px 16px; }} - -QTabWidget::pane {{ background: #FFFFFF; border: 1px solid #B8D4E8; border-radius: 0px; top: 2px; }} -QTabBar {{ background: transparent; }} -QTabBar::tab {{ - background: #D0E8F5; color: #3A6B8C; padding: 9px 22px; margin: 0 6px 8px 0; - border-radius: 10px; border: 1px solid #B8D4E8; font-weight: 500; -}} -QTabBar::tab:selected {{ background: {GRADIENT}; color: white; border: none; font-weight: 700; }} -QTabBar::tab:hover:!selected {{ background: #B8D4E8; color: #1A2332; }} -/* Co4E flow "browser" tabs — light-theme counterpart (see dark block). */ -QTabBar#flowTabs::tab {{ background: #EDF5FB; color: #3A6B8C; border: 1px solid #C4DBEC; - padding: 6px 10px; margin: 0 3px 0 0; min-height: 22px; border-radius: 8px; }} -QTabBar#flowTabs::tab:selected {{ background: {GRADIENT}; color: white; border: 1px solid transparent; }} -QTabBar#flowTabs::tab:hover:!selected {{ background: #D0E8F5; color: #1A2332; }} -/* The "new flow" (+) button (light) — styled as the last tab in the strip. */ -QPushButton#flowAddBtn {{ background: #EDF5FB; color: #3A6B8C; border: 1px solid #C4DBEC; - border-radius: 8px; padding: 6px 0; font-size: 15px; font-weight: bold; min-height: 22px; }} -QPushButton#flowAddBtn:hover {{ background: #D0E8F5; color: #1A2332; }} -/* Co4E sidebar (light) — transparent tabs, translucent selection, dark text. */ -QTabBar#co4eSideTabs {{ qproperty-iconSize: 18px 18px; }} -QTabBar#co4eSideTabs::tab {{ background: transparent; color: #3A6B8C; border: none; - border-radius: 6px; padding: 5px; margin: 0 6px 0 0; }} -QTabBar#co4eSideTabs::tab:selected {{ background: rgba(0,150,199,0.20); color: #1A2332; }} -QTabBar#co4eSideTabs::tab:hover:!selected {{ background: #D0E8F5; color: #1A2332; }} -/* Co4E canvas frame (light). */ -QGraphicsView#co4eCanvas {{ background: #FFFFFF; border: 1px solid #B8D4E8; border-radius: 10px; }} - -QGroupBox {{ - background: #FFFFFF; border: 1px solid #B8D4E8; border-radius: 18px; - margin-top: 16px; padding: 14px 10px 10px 10px; font-weight: 700; -}} -QGroupBox::title {{ - subcontrol-origin: margin; subcontrol-position: top left; left: 12px; top: 2px; - padding: 0 6px; color: #5C8DB8; letter-spacing: 0.5px; -}} - -QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QTreeView, QListView, QTreeWidget, QListWidget {{ - background: white; color: #1A2332; border: 1px solid #C0D8EC; border-radius: 10px; - selection-background-color: {ACCENT}; selection-color: white; -}} -QScrollArea {{ background: transparent; border: none; }} -QAbstractScrollArea::corner {{ background: transparent; }} -QPlainTextEdit:focus, QTextEdit:focus, QLineEdit:focus {{ border: 1px solid {ACCENT}; }} -QTreeView::item, QListView::item {{ padding: 3px 2px; border-radius: 6px; }} -QTreeView::item:hover, QListView::item:hover {{ background: #E0EFFA; }} -QTreeView::item:selected, QListView::item:selected {{ background: rgba(0,150,199,0.18); color: #1A2332; }} -QHeaderView::section {{ background: #E8F4FD; color: #5C8DB8; border: none; border-bottom: 1px solid #B8D4E8; padding: 6px; }} - -QPushButton {{ - background: white; color: #1A2332; border: 1px solid #C0D8EC; - border-radius: 10px; padding: 8px 16px; -}} -QPushButton:hover {{ background: #E0EFFA; }} -QPushButton:disabled {{ color: #8AA8C0; background: #F0F8FC; }} -QPushButton#primary {{ background: {GRADIENT}; color: white; border: none; font-weight: 700; padding: 8px 18px; }} -QPushButton#primary:hover {{ background: {GRADIENT_HOVER}; }} -QPushButton#danger {{ background: #E5484D; color: white; border: none; font-weight: 600; }} -QPushButton#danger:hover {{ background: #EF6368; }} -QPushButton#navMenuBtn {{ - background: transparent; border: none; border-radius: 8px; padding: 5px 6px; - font-weight: 700; font-size: 11px; letter-spacing: 1px; color: #5C8DB8; text-align: left; -}} -QPushButton#navMenuBtn:hover {{ background: #D0E8F5; color: #1A2332; }} -QPushButton#navMenuBtn:pressed {{ background: #B8D4E8; }} - -QLabel#badge {{ background: #D0ECF8; color: #0077B6; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#badgeSuccess {{ background: #D0F5E8; color: #1B7A3D; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#badgePurple {{ background: #E8E0FF; color: #6238C9; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#badgePink {{ background: #F8E0F0; color: #B93A85; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#badgeWarn {{ background: #D0ECF8; color: #0077B6; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#hint {{ color: #5C8DB8; }} -QLabel#warning {{ color: #0077B6; font-weight: 600; }} - -QComboBox {{ background: white; border: 1px solid #C0D8EC; border-radius: 10px; padding: 6px 10px; }} -QComboBox::drop-down {{ border: none; width: 22px; }} -QComboBox QAbstractItemView {{ - background: white; border: 1px solid #C0D8EC; border-radius: 10px; - selection-background-color: {ACCENT}; selection-color: white; outline: none; -}} - -QScrollBar:vertical {{ background: transparent; width: 11px; margin: 2px; }} -QScrollBar::handle:vertical {{ background: #C0D8EC; border-radius: 5px; min-height: 28px; }} -QScrollBar::handle:vertical:hover {{ background: {ACCENT}; }} -QScrollBar:horizontal {{ background: transparent; height: 11px; margin: 2px; }} -QScrollBar::handle:horizontal {{ background: #C0D8EC; border-radius: 5px; min-width: 28px; }} -QScrollBar::handle:horizontal:hover {{ background: {ACCENT}; }} -QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; width: 0; border: none; background: none; }} - -QCheckBox {{ spacing: 8px; }} -QCheckBox::indicator, QRadioButton::indicator {{ - width: 16px; height: 16px; background: white; border: 1px solid #C0D8EC; border-radius: 5px; -}} -QRadioButton::indicator {{ border-radius: 9px; }} -QCheckBox::indicator:hover, QRadioButton::indicator:hover {{ border-color: {ACCENT}; }} -QCheckBox::indicator:checked, QRadioButton::indicator:checked {{ - background: {GRADIENT}; border-color: {ACCENT}; -}} -QCheckBox::indicator:disabled {{ border-color: #D0E8F5; background: #F0F8FC; }} -QCheckBox::indicator:checked:disabled, QRadioButton::indicator:checked:disabled {{ - background: #B8D4E8; border-color: #8AB8D8; -}} - -QMenu {{ background: white; color: #1A2332; border: 1px solid #C0D8EC; border-radius: 10px; padding: 4px; }} -QMenu::item {{ padding: 6px 16px; border-radius: 6px; }} -QMenu::item:selected {{ background: {ACCENT}; color: white; }} - -QToolTip {{ background: #FFFFFF; color: #1A2332; border: 1px solid #C0D8EC; border-radius: 6px; padding: 4px 8px; }} -""" - -# Node colors used by the Graph view (shared light/dark). -NODE_COLORS = { - "user": "#3B82F6", - "assistant": ACCENT, - "tool": "#8B5CF6", - "result": "#22A06B", - "error": "#E5484D", -} - - -def resolve_theme(theme: str) -> str: - """Resolve 'system' to 'dark'/'light' based on the OS color scheme.""" - if theme != "system": - return theme - try: - from PySide6.QtCore import Qt - from PySide6.QtWidgets import QApplication - - app = QApplication.instance() - if app is not None: - scheme = app.styleHints().colorScheme() - return "light" if scheme == Qt.ColorScheme.Light else "dark" - except Exception: - pass - return "dark" - - -def stylesheet(theme: str) -> str: - return _LIGHT if resolve_theme(theme) == "light" else _DARK \ No newline at end of file +"""The app's visual system: semantic design tokens + one style sheet template. + +This replaces the previous mechanism, which was two hand-written Qt style +sheets (``_DARK`` / ``_LIGHT``) that duplicated each other and hard-coded ~105 +hex literals, with a single source of truth: + + Palette (tokens) -> _TEMPLATE (one QSS) -> stylesheet(theme) + +Rules of the system +------------------- +* **Nothing outside this module names a colour.** Widgets that paint with + ``QPainter`` (charts, canvases, syntax highlighters) call :func:`palette` and + read a token. Widgets that style themselves declaratively should instead be + given an ``objectName`` and styled in ``_TEMPLATE`` below. +* **Tokens are semantic, not literal.** ``danger``/``text_muted``/``code_string`` + — never ``blue``/``grey2``. Adding a theme means adding a :class:`Palette`, + not editing a style sheet. +* **No gradients, no glows.** The palette is Visual Studio Code's — "Dark + Modern" and "Light Modern", taken from the shipped theme JSON. Flat surfaces, + square-ish corners, one accent spent only on what the user acts on. Depth + comes from the surface ramp and hairline borders, not from colour. Note the + VS Code silhouette: the nav rail is *darker* than the content area, not + lighter. + +Contrast is held to WCAG AA (4.5:1) for body text and for text on filled +buttons. That is why ``accent`` and ``accent_solid`` are separate tokens: on a +dark background a blue readable *as text* is too light to carry white *as a +fill*, so each role gets the tint that passes. + +Four VS Code values fall below AA and are nudged just far enough to clear it — +dark line numbers (3.59:1), light faint text on the sidebar (4.28:1), light +green (4.33:1) and light amber (3.12:1). Each carries a comment naming the +original value, so the deviation is auditable rather than silent. +""" +from __future__ import annotations + +from dataclasses import dataclass, asdict +from string import Template + + +def _chevron_asset(direction: str, color: str) -> str: + """Render (and cache on disk) a small chevron PNG for QComboBox/QSpinBox + arrow subcontrols. QSS's ``image:`` property only accepts a resource or + file path, never a live QPixmap — and once ``::drop-down``/``::up-button``/ + ``::down-button`` are styled at all, Qt stops drawing its own built-in + arrow, so without this the controls show no affordance whatsoever.""" + import hashlib + import tempfile + from pathlib import Path + + key = hashlib.md5(f"{direction}-{color}".encode()).hexdigest()[:10] + path = Path(tempfile.gettempdir()) / "cowork_local_theme" / f"chevron_{key}.png" + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + from PySide6.QtCore import QPointF, Qt + from PySide6.QtGui import QColor, QPainter, QPixmap + + size = 12 + pm = QPixmap(size, size) + pm.fill(Qt.transparent) + p = QPainter(pm) + p.setRenderHint(QPainter.Antialiasing) + pen = p.pen() + pen.setColor(QColor(color)) + pen.setWidthF(1.6) + pen.setCapStyle(Qt.RoundCap) + pen.setJoinStyle(Qt.RoundJoin) + p.setPen(pen) + pts = ([QPointF(2.5, 4.5), QPointF(6, 8), QPointF(9.5, 4.5)] if direction == "down" + else [QPointF(2.5, 7.5), QPointF(6, 4), QPointF(9.5, 7.5)]) + p.drawPolyline(pts) + p.end() + pm.save(str(path)) + return path.as_posix() + + +@dataclass(frozen=True) +class Palette: + """Every colour and shape value the interface is allowed to use.""" + + name: str + + # --- surfaces: a 4-step ramp from the window back to the frontmost layer. + bg: str # window / canvas backdrop + surface: str # panels, cards, group boxes (NOT the nav rail) + surface_raised: str # inputs, lists, trees — things you type or pick in + overlay: str # menus, tooltips, popups (floats above everything) + sunken: str # logs, code, terminals — things you read into + hover: str # hover wash on rows, tabs, ghost buttons + active: str # pressed / held state + + # The nav rail gets its own step rather than borrowing `surface`. It is a + # permanent region of the window, not a card floating on the page. + # + # Following VS Code, the rail is *darker* than the content area (dark) or a + # shade off white (light). The step is small on purpose — VS Code separates + # the rail with a border, not a big tonal jump — so `nav_border` is doing + # real work here and must stay visible. + nav_bg: str + nav_border: str + nav_hover: str + nav_selected: str + + # --- lines + border: str # default hairline + border_strong: str # hairline that must survive next to a filled surface + focus_ring: str # keyboard/typing focus + + # --- text + text: str + text_muted: str # secondary copy, captions, group-box titles + text_faint: str # metadata, timestamps, placeholder + text_disabled: str + on_accent: str # text drawn on top of a filled accent/status surface + + # --- accent: `accent` tints text & icons, `accent_solid` fills buttons. + accent: str + accent_solid: str + accent_solid_hover: str + accent_solid_active: str + accent_soft: str # translucent wash for selected rows (QSS only) + accent_soft_hover: str + accent_wash: str # the same tint pre-blended to a solid, for Qt rich + # text (bgcolor=, ) where alpha is ignored + + # --- status + success: str + success_soft: str + warning: str + warning_soft: str + danger: str + danger_solid: str + danger_solid_hover: str + danger_soft: str + info: str + info_soft: str + purple: str + purple_soft: str + pink: str + pink_soft: str + + # --- selection (text selection inside editors and inputs) + selection_bg: str + selection_fg: str + + # --- scrollbars + scroll_handle: str + scroll_handle_hover: str + + # --- code & terminal + code_bg: str + code_fg: str + code_gutter_bg: str + code_gutter_fg: str + code_selection: str + code_comment: str + code_keyword: str + code_type: str + code_func: str + code_attr: str + code_string: str + code_number: str + code_error: str + + # --- diff / inline change badges + diff_add_bg: str + diff_add_fg: str + diff_del_bg: str + diff_del_fg: str + + # --- charts + chart_grid: str + chart_label: str + + # --- conversation & graph node roles + role_user: str + role_assistant: str + role_tool: str + role_result: str + role_error: str + + # --- shape & type + radius_sm: int + radius: int + radius_lg: int + font_family: str + font_size: int + font_mono: str + + +_FONT = '"Segoe UI Variable Text", "Segoe UI", "Inter", "Helvetica Neue", "Yu Gothic UI", "Meiryo", sans-serif' +_MONO = '"Cascadia Code", "JetBrains Mono", "Consolas", "SF Mono", monospace' + + +DARK = Palette( + name="dark", + # ---- VS Code "Dark Modern" ---------------------------------------------- + # Values taken from the shipped theme JSON. Where VS Code's own choice falls + # below WCAG AA it is nudged just far enough to pass; each such value carries + # a note with VS Code's original and the measured ratio. + bg="#1F1F1F", # editor.background + surface="#252526", # panel / card + surface_raised="#313131", # input.background + overlay="#252526", # menus, tooltips + sunken="#181818", # logs, terminals — below the ramp + hover="#2A2D2E", # list.hoverBackground + active="#37373D", # list.inactiveSelectionBackground + # The sidebar is DARKER than the editor — that is the VS Code silhouette. + nav_bg="#181818", # sideBar.background + nav_border="#2B2B2B", # sideBar.border + nav_hover="#2A2D2E", + nav_selected="#04395E", # list.activeSelectionBackground + border="#2B2B2B", # panel.border + border_strong="#3C3C3C", # input.border + focus_ring="#0078D4", # focusBorder + text="#CCCCCC", # editor.foreground + text_muted="#9D9D9D", # descriptionForeground + text_faint="#9A9A9A", # lifted: #8B8B8B was 3.82:1 on input surfaces + text_disabled="#5A5A5A", + on_accent="#FFFFFF", + accent="#4DAAFC", # textLink.foreground — accent as TEXT + accent_solid="#0078D4", # button.background — accent as FILL + accent_solid_hover="#026EC1", + accent_solid_active="#005FB8", + accent_soft="rgba(0,120,212,0.22)", + accent_soft_hover="rgba(0,120,212,0.32)", + accent_wash="#12283C", # pre-blended: Qt rich text ignores alpha + success="#89D185", # gitDecoration added + success_soft="rgba(137,209,133,0.16)", + warning="#CCA700", # editorWarning + warning_soft="rgba(204,167,0,0.16)", + danger="#F76464", # editorError #F14C4C lifted (4.29:1 on panels) + danger_solid="#C4302B", + danger_solid_hover="#D9433C", + danger_soft="rgba(241,76,76,0.16)", + info="#4DAAFC", + info_soft="rgba(77,170,252,0.16)", + purple="#C586C0", # Dark+ syntax purple + purple_soft="rgba(197,134,192,0.16)", + pink="#D16D9E", + pink_soft="rgba(209,109,158,0.16)", + selection_bg="#264F78", # editor.selectionBackground + selection_fg="#FFFFFF", + scroll_handle="#4E4E4E", # scrollbarSlider + scroll_handle_hover="#5A5A5A", + code_bg="#1F1F1F", + code_fg="#CCCCCC", + code_gutter_bg="#1F1F1F", + # VS Code uses #6E7681 for line numbers — only 3.59:1. Lifted to clear AA. + code_gutter_fg="#858D97", + code_selection="#264F78", + code_comment="#6A9955", # ---- Dark+ syntax, unchanged -------------- + code_keyword="#569CD6", + code_type="#4EC9B0", + code_func="#DCDCAA", + code_attr="#9CDCFE", + code_string="#CE9178", + code_number="#B5CEA8", + code_error="#F44747", + diff_add_bg="#1B3A1B", # diffEditor inserted, pre-blended + diff_add_fg="#89D185", + diff_del_bg="#4B1818", # diffEditor removed, pre-blended + diff_del_fg="#F76464", + chart_grid="#2B2B2B", + chart_label="#9D9D9D", + role_user="#4DAAFC", + role_assistant="#4EC9B0", + role_tool="#C586C0", + role_result="#89D185", + role_error="#F14C4C", + radius_sm=3, # VS Code is squarer than the previous look + radius=4, + radius_lg=6, + font_family=_FONT, + font_size=13, + font_mono=_MONO, +) + + +LIGHT = Palette( + name="light", + # ---- VS Code "Light Modern" --------------------------------------------- + bg="#FFFFFF", # editor.background + surface="#F8F8F8", # sideBar / panel + surface_raised="#FFFFFF", # input.background + overlay="#FFFFFF", + sunken="#F3F3F3", + hover="#F2F2F2", # list.hoverBackground + active="#E8E8E8", # list.activeSelectionBackground + nav_bg="#F8F8F8", # sideBar.background + nav_border="#E5E5E5", # sideBar.border + nav_hover="#F2F2F2", + nav_selected="#E4E6F1", # active row, tinted toward the accent + border="#E5E5E5", + border_strong="#CECECE", # input.border + focus_ring="#005FB8", # focusBorder + text="#3B3B3B", # editor.foreground + text_muted="#616161", + # VS Code uses #767676 — 4.28:1 on the sidebar. Darkened to clear AA there. + text_faint="#6E6E6E", + text_disabled="#A0A0A0", + on_accent="#FFFFFF", + accent="#005FB8", # textLink / button + accent_solid="#005FB8", + accent_solid_hover="#0258A8", + accent_solid_active="#004C97", + accent_soft="rgba(0,95,184,0.10)", + accent_soft_hover="rgba(0,95,184,0.16)", + accent_wash="#E6EEF8", + # VS Code green #388A34 is 4.33:1 and amber #BF8803 only 3.12:1 — both lifted. + success="#317A2D", + success_soft="#DFF3DE", + warning="#8F6500", # VS Code #BF8803 = 3.12:1 + warning_soft="#FBF0D0", + danger="#CD3131", # editorError + danger_solid="#CD3131", + danger_solid_hover="#B82A2A", + danger_soft="#FDEFEF", # lightened so #CD3131 clears AA on it + info="#005FB8", + info_soft="#DDEBF9", + purple="#6F42C1", + purple_soft="#EDE7FA", + pink="#B3247E", + pink_soft="#FAE3F0", + selection_bg="#ADD6FF", # editor.selectionBackground + selection_fg="#000000", + scroll_handle="#C1C1C1", + scroll_handle_hover="#A6A6A6", + code_bg="#FFFFFF", + code_fg="#3B3B3B", + code_gutter_bg="#F8F8F8", + code_gutter_fg="#656C76", # VS Code #6E7681 = 4.33:1 on the gutter + code_selection="#ADD6FF", + code_comment="#008000", # ---- Light+ syntax ------------------------ + code_keyword="#0000FF", + code_type="#267F99", + code_func="#795E26", + code_attr="#E50000", + code_string="#A31515", + code_number="#098658", + code_error="#CD3131", + diff_add_bg="#DBF4DB", + diff_add_fg="#1E6F1A", + diff_del_bg="#FBE3E3", + diff_del_fg="#B82A2A", + chart_grid="#E5E5E5", + chart_label="#616161", + role_user="#005FB8", + role_assistant="#267F99", + role_tool="#6F42C1", + role_result="#317A2D", + role_error="#CD3131", + radius_sm=3, + radius=4, + radius_lg=6, + font_family=_FONT, + font_size=13, + font_mono=_MONO, +) + + +_PALETTES = {"dark": DARK, "light": LIGHT} + + +# --------------------------------------------------------------------------- +# The one style sheet. `$token` placeholders are filled from the Palette above; +# use `${token}px` where a unit follows the name. +# +# Read it as a cascade: reset -> shell -> surfaces -> controls -> chrome. +# --------------------------------------------------------------------------- +_TEMPLATE = Template(""" +/* ---- reset ------------------------------------------------------------ */ +* { font-family: $font_family; font-size: ${font_size}px; } +QWidget { background: $bg; color: $text; } +QMainWindow::separator { background: $border; width: 1px; height: 1px; } +QSplitter::handle { background: $border; } +QSplitter::handle:horizontal { width: 1px; } +QSplitter::handle:vertical { height: 1px; } +QSplitter::handle:hover { background: $border_strong; } +QStatusBar { background: $bg; color: $text_faint; border-top: 1px solid $border; } +QToolTip { + background: $overlay; color: $text; border: 1px solid $border_strong; + border-radius: ${radius}px; padding: 5px 9px; +} + +/* Icons are drawn at text scale, not as decoration. */ +QPushButton, QToolButton, QComboBox, QTabBar { qproperty-iconSize: 15px 15px; } +QTreeWidget#navrail { qproperty-iconSize: 22px 16px; } + +/* ---- shell ------------------------------------------------------------ */ +QWidget#topbar { background: $bg; border: none; border-bottom: 1px solid $border; } +QLabel#brand { color: $text; font-size: 15px; font-weight: 700; background: transparent; } +QWidget#navWrap { background: $nav_bg; border-right: 1px solid $nav_border; } +QWidget#navWrap QTreeWidget, QWidget#navWrap QListWidget { background: transparent; border: none; } +QWidget#contentArea { background: $bg; } + +/* Rail rows sit on nav_bg, so they need their own hover/selected steps — the + generic ones are tuned against `bg` and wash out here. The active item also + carries a 2px accent marker, so which section you are in survives even at a + glance or for anyone who cannot separate the two greys. */ +QWidget#navWrap QTreeWidget::item, QWidget#navWrap QListWidget::item { + padding: 6px 4px; border-radius: ${radius}px; +} +QWidget#navWrap QTreeWidget::item:hover, QWidget#navWrap QListWidget::item:hover { + background: $nav_hover; +} +QWidget#navWrap QTreeWidget::item:selected, QWidget#navWrap QListWidget::item:selected { + background: $nav_selected; color: $text; + border-left: 2px solid $accent; font-weight: 600; +} +/* Rows the project gate is holding shut: still listed, visibly not open. */ +QWidget#navWrap QTreeWidget::item:disabled { color: $text_faint; } +/* The pinned bottom group (Dashboard, Monitoring) — one hairline separates it + from the list above so "occasional" reads apart from "everyday". */ +QTreeWidget#navrailBottom { border-top: 1px solid $nav_border; } +QScrollArea#navScroll { background: transparent; border: none; } +QScrollArea#navScroll > QWidget > QWidget { background: transparent; } +/* Monitoring ▸ Overview reads as titled sections down one column, the way the + audit page draws it — a quiet caps heading with the content flat underneath, + not six bordered boxes competing with the cards inside them. */ +QGroupBox#monSection { + background: transparent; border: none; margin-top: 16px; + padding: 6px 0 0 0; font-weight: 700; +} +QGroupBox#monSection::title { + subcontrol-origin: margin; subcontrol-position: top left; left: 2px; top: 0; + padding: 0; color: $text_faint; font-size: 11px; letter-spacing: 0.5px; +} +/* Segmented control: two-to-four choices shown side by side (language, theme) + instead of a drop-list you must open to see what the options even are. */ +QPushButton#segItem { + background: $surface_raised; color: $text_muted; border: 1px solid $border; + padding: 4px 12px; margin: 0; border-radius: 0; +} +QPushButton#segItem:hover { background: $hover; color: $text; } +QPushButton#segItem:checked { + background: $accent_solid; color: #FFFFFF; border-color: $accent_solid; font-weight: 600; +} +/* Table of contents down the left of the long dialogs (Settings, Task editor). */ +QListWidget#sectionIndex { background: $surface; border-right: 1px solid $border; } +QListWidget#sectionIndex::item { padding: 7px 10px; border-radius: ${radius}px; } +QListWidget#sectionIndex::item:hover { background: $hover; } +QListWidget#sectionIndex::item:selected { + background: $nav_selected; color: $text; font-weight: 600; +} +/* The strip under the typing box: agent · routing · usage · folder. Reads as + status, not as a second toolbar, so the eye lands on the input first. */ +QWidget#composerStatus { border-top: 1px solid $border; background: transparent; } +QWidget#composerStatus QLabel { color: $text_faint; font-size: 11px; } +QWidget#composerStatus QPushButton, QWidget#composerStatus QComboBox { + background: transparent; border: none; color: $text_muted; font-size: 11px; + padding: 2px 6px; border-radius: ${radius_sm}px; +} +QWidget#composerStatus QPushButton:hover, QWidget#composerStatus QComboBox:hover { + background: $hover; color: $text; +} +/* Folder: the current path, written as the screen's title. */ +QLabel#folderTitle { color: $text; font-size: 14px; font-weight: 600; background: transparent; } +/* A pair of view tabs inside a page header (Schedule, GraphRAG) — flatter and + quieter than the app's main tab bars, since they switch a view, not a page. */ +QTabBar#viewTabs::tab { + background: transparent; color: $text_muted; border: none; + padding: 4px 12px; margin: 0 2px; border-radius: ${radius}px; +} +QTabBar#viewTabs::tab:hover { background: $hover; color: $text; } +QTabBar#viewTabs::tab:selected { background: $active; color: $text; font-weight: 600; } +/* Co4E sidebar section headings — same quiet caps as the rail's RECENTS, so + "which list am I looking at" is answered on screen, not in a tooltip. */ +/* The action beside a Co4E section heading ("+ Mới", "Quản lý skill…"). The + wireframe writes these as small accent text; as full buttons they were the + loudest thing in the sidebar and each cost a row of height. */ +QPushButton#co4eSectionAction { + background: transparent; border: none; color: $accent; + font-size: 11px; font-weight: 600; padding: 1px 4px; + border-radius: ${radius_sm}px; qproperty-iconSize: 11px 11px; +} +QPushButton#co4eSectionAction:hover { background: $hover; color: $accent; } +QPushButton#co4eSectionAction:pressed { background: $active; } +QPushButton#co4eSectionHdr { + color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px; + background: transparent; border: none; text-align: left; padding: 2px 0; +} +QPushButton#co4eSectionHdr:hover { color: $text; } +/* Account row at the foot of the rail: who you are + the settings that follow + you (provider, language, theme). Separated by a hairline like the group above. */ +QWidget#navAccount { border-top: 1px solid $nav_border; } +QWidget#navAccount QComboBox { + background: $surface_raised; border: 1px solid $nav_border; color: $text; + padding: 3px 6px; border-radius: ${radius}px; +} +/* RECENTS section label — quiet, so the thread titles under it read first. */ +QLabel#navSectionHdr { + color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px; + padding: 8px 8px 2px 8px; background: transparent; +} +QTreeWidget#navRecents { border-top: 1px solid $nav_border; } + +/* Icon library cells. The audit page's note on this screen is that the cells + had no visible edge on hover or selection, so you could not tell what you + were about to pick — "cùng ngôn ngữ thẻ với Agent/Công cụ". */ +QListWidget#iconGrid { background: transparent; border: none; } +QListWidget#iconGrid::item { + border: 1px solid transparent; border-radius: ${radius}px; + color: $text_muted; padding: 4px; +} +QListWidget#iconGrid::item:hover { + border: 1px solid $accent; background: $hover; color: $text; +} +QListWidget#iconGrid::item:selected { + border: 1px solid $accent; background: $accent_wash; color: $text; +} +/* Screen title beside its actions, same weight the other admin screens use. */ +QLabel#monTitle { font-size: 15px; font-weight: 700; color: $text; } +/* Rail header — the primary action, so it is the one filled button up there. */ +QPushButton#navNewChatBtn { + background: $accent_solid; color: #FFFFFF; border: none; font-weight: 600; + padding: 7px 10px; border-radius: ${radius}px; text-align: left; +} +QPushButton#navNewChatBtn:hover { background: $accent_solid_hover; } +QPushButton#navNewChatBtn:disabled { background: $border_strong; color: $text_faint; } +QComboBox#navProjectPick { + background: $surface_raised; border: 1px solid $nav_border; color: $text; + padding: 4px 8px; border-radius: ${radius}px; +} +/* Stand-in for the picker while the rail is 54px wide: icon only, no arrow — + the arrow would eat a third of the width for no information. */ +QToolButton#navProjectPickMini { + background: $surface_raised; border: 1px solid $nav_border; border-radius: ${radius}px; + padding: 4px; qproperty-iconSize: 16px 16px; +} +QToolButton#navProjectPickMini:hover { background: $nav_hover; } +QToolButton#navProjectPickMini:disabled { background: transparent; border-color: $border; } +QToolButton#navProjectPickMini::menu-indicator { image: none; width: 0; } + +QPushButton#navSettingsBtn { + background: transparent; border: none; color: $text_muted; + /* 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; +} +QPushButton#navSettingsBtn:hover { background: $nav_hover; color: $text; } +QPushButton#navSettingsBtn:pressed { background: $active; } + + +/* ---- surfaces --------------------------------------------------------- */ +QGroupBox { + background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px; + margin-top: 14px; padding: 16px 12px 12px 12px; font-weight: 600; +} +QGroupBox::title { + subcontrol-origin: margin; subcontrol-position: top left; left: 12px; top: 1px; + padding: 0 6px; color: $text_muted; font-size: 12px; font-weight: 600; +} +QFrame[frameShape="4"], QFrame[frameShape="5"] { background: $border; border: none; } +QScrollArea { background: transparent; border: none; } +QAbstractScrollArea::corner { background: transparent; } + +/* ---- tabs: an underline, not a pill. ------------------------------------- + The old pill tabs read as buttons and fought the real buttons for + attention. A 2px rule under the active label is quieter and unambiguous. */ +QTabWidget::pane { background: $bg; border: none; border-top: 1px solid $border; top: -1px; } +QTabBar { background: transparent; qproperty-drawBase: 0; } +QTabBar::tab { + background: transparent; color: $text_muted; padding: 9px 14px; margin: 0 2px 0 0; + border: none; border-bottom: 2px solid transparent; font-weight: 500; +} +QTabBar::tab:selected { color: $text; border-bottom: 2px solid $accent; font-weight: 600; } +QTabBar::tab:hover:!selected { color: $text; background: $hover; } + +/* Co4E flow strip — browser-style tabs, so these stay enclosed. */ +QTabBar#flowTabs::tab { + background: $surface; color: $text_muted; border: 1px solid $border; + border-radius: ${radius}px; padding: 6px 10px; margin: 0 3px 0 0; min-height: 22px; +} +QTabBar#flowTabs::tab:selected { background: $surface_raised; color: $text; border-color: $border_strong; } +QTabBar#flowTabs::tab:hover:!selected { background: $hover; color: $text; } +QPushButton#flowAddBtn { + background: transparent; color: $text_muted; border: 1px solid $border; + border-radius: ${radius}px; padding: 6px 0; font-size: 15px; min-height: 22px; +} +QPushButton#flowAddBtn:hover { background: $hover; color: $text; } + +/* Co4E icon sidebar — no chrome until it is the active one. */ +QTabBar#co4eSideTabs { qproperty-iconSize: 18px 18px; } +QTabBar#co4eSideTabs::tab { + background: transparent; color: $text_muted; border: none; + border-radius: ${radius}px; padding: 6px; margin: 0 4px 0 0; +} +QTabBar#co4eSideTabs::tab:selected { background: $accent_soft; color: $text; } +QTabBar#co4eSideTabs::tab:hover:!selected { background: $hover; color: $text; } +QGraphicsView#co4eCanvas { + background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px; +} + +/* ---- text entry & item views ------------------------------------------ */ +QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QSpinBox, QDoubleSpinBox, +QTreeView, QListView, QTableView, QTreeWidget, QListWidget, QTableWidget { + background: $surface_raised; color: $text; border: 1px solid $border; + border-radius: ${radius}px; selection-background-color: $selection_bg; + selection-color: $selection_fg; outline: 0; +} +QPlainTextEdit, QTextEdit, QLineEdit, QSpinBox, QDoubleSpinBox { padding: 6px 8px; } +QPlainTextEdit:hover, QTextEdit:hover, QLineEdit:hover { border-color: $border_strong; } +QPlainTextEdit:focus, QTextEdit:focus, QLineEdit:focus, +QSpinBox:focus, QDoubleSpinBox:focus { border: 1px solid $focus_ring; } +QLineEdit:disabled, QPlainTextEdit:disabled, QTextEdit:disabled { + background: $surface; color: $text_disabled; +} +/* Explicit up/down button geometry: once ANY spin-box subcontrol is styled, + Qt uses exactly this rect for both painting AND hit-testing, so the + clickable area can no longer drift from what's drawn (the previous + unstyled default arrows misaligned their own click region at 125%/150% + Windows display scaling — this pins both to the same rect instead). */ +QSpinBox::up-button, QDoubleSpinBox::up-button { + subcontrol-origin: border; subcontrol-position: top right; + width: 18px; height: 15px; border: none; background: transparent; +} +QSpinBox::down-button, QDoubleSpinBox::down-button { + subcontrol-origin: border; subcontrol-position: bottom right; + width: 18px; height: 15px; border: none; background: transparent; +} +QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover, +QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover { background: $hover; } +QSpinBox::up-button:pressed, QDoubleSpinBox::up-button:pressed, +QSpinBox::down-button:pressed, QDoubleSpinBox::down-button:pressed { background: $active; } +QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { image: url($chevron_up); width: 9px; height: 9px; } +QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { image: url($chevron_down); width: 9px; height: 9px; } +QSpinBox::up-arrow:disabled, QSpinBox::down-arrow:disabled, +QDoubleSpinBox::up-arrow:disabled, QDoubleSpinBox::down-arrow:disabled { image: none; } + +QTreeView::item, QListView::item, QTableView::item { padding: 4px 3px; border-radius: ${radius_sm}px; } +QTreeView::item:hover, QListView::item:hover { background: $hover; } +QTreeView::item:selected, QListView::item:selected, QTableView::item:selected { + background: $accent_soft; color: $text; +} +/* The platform style draws its own dotted/solid focus rect on the current + cell on top of the selection tint above — visible as a stray light border + on a click. The selection tint already marks "current row"; drop the rect. */ +QTreeView::item:focus, QListView::item:focus, QTableView::item:focus { outline: none; border: none; } +/* Kanban lanes (Schedule Task): seven lanes share the board width, so a card's + own inset competes with the other six for space the same way the inter-lane + gap did — trimmed to match. */ +QListWidget#kanbanLane::item { padding: 3px 2px; } +QHeaderView::section { + background: $bg; color: $text_muted; border: none; + border-bottom: 1px solid $border; padding: 7px 6px; font-weight: 600; +} + +/* ---- buttons ----------------------------------------------------------- + Default is a quiet outline. Weight is reserved for #primary / #danger, so + at most one button per view should carry a fill. */ +QPushButton { + background: $surface_raised; color: $text; border: 1px solid $border_strong; + border-radius: ${radius}px; padding: 7px 14px; font-weight: 500; +} +QPushButton:hover { background: $hover; border-color: $border_strong; } +QPushButton:pressed { background: $active; } +QPushButton:disabled { color: $text_disabled; background: $surface; border-color: $border; } +QPushButton:focus { border: 1px solid $focus_ring; } + +QPushButton#primary { + background: $accent_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600; +} +QPushButton#primary:hover { background: $accent_solid_hover; } +QPushButton#primary:pressed { background: $accent_solid_active; } +QPushButton#primary:disabled { background: $surface; color: $text_disabled; border-color: $border; } + +QPushButton#danger { + background: $danger_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600; +} +QPushButton#danger:hover { background: $danger_solid_hover; } +QPushButton#danger:disabled { background: $surface; color: $text_disabled; border-color: $border; } + +/* Ghost buttons: nav section headers and icon-only chrome. */ +QPushButton#navMenuBtn { + background: transparent; border: none; border-radius: ${radius}px; padding: 5px 6px; + font-weight: 600; font-size: 11px; letter-spacing: 0.6px; color: $text_faint; text-align: left; +} +QPushButton#navMenuBtn:hover { background: $hover; color: $text; } +QPushButton#navMenuBtn:pressed { background: $active; } + +QToolButton { + background: transparent; color: $text_muted; border: none; + border-radius: ${radius}px; padding: 5px; +} +QToolButton:hover { background: $hover; color: $text; } +QToolButton:pressed { background: $active; } +QToolButton::menu-indicator { image: none; } + +/* ---- pickers ----------------------------------------------------------- */ +QComboBox { + background: $surface_raised; color: $text; border: 1px solid $border_strong; + border-radius: ${radius}px; padding: 6px 10px; +} +QComboBox:hover { background: $hover; } +QComboBox:focus { border-color: $focus_ring; } +QComboBox:disabled { color: $text_disabled; background: $surface; border-color: $border; } +QComboBox::drop-down { border: none; width: 20px; } +QComboBox::down-arrow { image: url($chevron_down); width: 10px; height: 10px; } +QComboBox::down-arrow:disabled { image: none; } +QComboBox QAbstractItemView { + background: $overlay; color: $text; border: 1px solid $border_strong; + border-radius: ${radius}px; padding: 4px; outline: none; + selection-background-color: $accent_soft; selection-color: $text; +} + +QMenu { background: $overlay; color: $text; border: 1px solid $border_strong; + border-radius: ${radius}px; padding: 4px; } +QMenu::item { padding: 6px 14px; border-radius: ${radius_sm}px; } +QMenu::item:selected { background: $accent_soft; color: $text; } +QMenu::item:disabled { color: $text_disabled; } +QMenu::separator { height: 1px; background: $border; margin: 4px 6px; } + +QMenuBar { background: $bg; color: $text; border-bottom: 1px solid $border; } +QMenuBar::item { padding: 5px 10px; border-radius: ${radius_sm}px; background: transparent; } +QMenuBar::item:selected { background: $hover; } + +/* ---- toggles ----------------------------------------------------------- */ +QCheckBox, QRadioButton { spacing: 8px; background: transparent; } +QCheckBox::indicator, QRadioButton::indicator { + width: 16px; height: 16px; background: $surface_raised; + border: 1px solid $border_strong; border-radius: ${radius_sm}px; +} +QRadioButton::indicator { border-radius: 9px; } +QCheckBox::indicator:hover, QRadioButton::indicator:hover { border-color: $accent; } +QCheckBox::indicator:checked, QRadioButton::indicator:checked { + background: $accent_solid; border-color: $accent_solid; +} +QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { + background: $surface; border-color: $border; +} +QCheckBox::indicator:checked:disabled, QRadioButton::indicator:checked:disabled { + background: $border_strong; border-color: $border_strong; +} + +QSlider::groove:horizontal { height: 4px; background: $border; border-radius: 2px; } +QSlider::sub-page:horizontal { background: $accent_solid; border-radius: 2px; } +QSlider::handle:horizontal { + width: 14px; height: 14px; margin: -6px 0; border-radius: 7px; + background: $surface_raised; border: 1px solid $border_strong; +} +QSlider::handle:horizontal:hover { border-color: $accent; } + +QProgressBar { + background: $surface; border: none; border-radius: 3px; + height: 6px; text-align: center; color: $text_muted; +} +QProgressBar::chunk { background: $accent_solid; border-radius: 3px; } + +/* ---- scrollbars: overlay-thin, no arrows. ------------------------------ */ +QScrollBar:vertical { background: transparent; width: 10px; margin: 2px; } +QScrollBar::handle:vertical { background: $scroll_handle; border-radius: 4px; min-height: 28px; } +QScrollBar::handle:vertical:hover { background: $scroll_handle_hover; } +QScrollBar:horizontal { background: transparent; height: 10px; margin: 2px; } +QScrollBar::handle:horizontal { background: $scroll_handle; border-radius: 4px; min-width: 28px; } +QScrollBar::handle:horizontal:hover { background: $scroll_handle_hover; } +QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; border: none; background: none; } +QScrollBar::add-page, QScrollBar::sub-page { background: none; } + +/* ---- badges & inline text tones --------------------------------------- + One shape, seven tones. Pick by meaning: badgeSuccess for a finished run, + badgeDanger for a failed one — not by which colour looks nice. badgeNeutral + is the odd one out: it deliberately uses OPAQUE tokens ($active/$text_muted, + not an rgba() *_soft one) since it renders inside table cells that can sit + over a selection tint — an rgba() background there would composite + differently selected vs not (see Monitoring ▸ Sự kiện bảo mật's Hành động + pill, which hit exactly this). */ +QLabel#badge, QLabel#badgeSuccess, QLabel#badgeWarn, QLabel#badgeDanger, +QLabel#badgePurple, QLabel#badgePink, QLabel#badgeNeutral { + border-radius: ${radius_sm}px; padding: 2px 8px; font-size: 12px; font-weight: 600; +} +QLabel#badge { background: $info_soft; color: $info; } +QLabel#badgeSuccess { background: $success_soft; color: $success; } +QLabel#badgeWarn { background: $warning_soft; color: $warning; } +QLabel#badgeDanger { background: $danger_soft; color: $danger; } +QLabel#badgePurple { background: $purple_soft; color: $purple; } +QLabel#badgePink { background: $pink_soft; color: $pink; } +QLabel#badgeNeutral { background: $active; color: $text_muted; } + +/* ---- event-detail panel (Monitoring ▸ Sự kiện bảo mật) ----------------- + A neutral, low-emphasis tag — the "Loại" chip: a category label with no + colour coding of its own (colour is reserved for the Trạng thái badge + beside it). */ +QLabel#neutralTag { + background: $surface_raised; border: 1px solid $border; border-radius: ${radius_sm}px; + padding: 2px 8px; font-size: 12px; +} +/* A short identifier shown as a bordered monospace chip (machine name, + event id). */ +QLabel#monoChip { + font-family: "Cascadia Code", Consolas, monospace; background: $surface_raised; + border: 1px solid $border; border-radius: ${radius_sm}px; padding: 1px 6px; font-size: 12px; +} +/* Section caption inside the panel — the same quiet caps heading as + Monitoring ▸ Overview's group titles (monSection::title above), with a + hairline under it since the panel has no group-box border of its own. */ +QLabel#detailSectionHdr { + color: $text_faint; font-size: 11px; font-weight: 700; letter-spacing: 0.5px; + padding-bottom: 4px; margin-top: 6px; border-bottom: 1px solid $border; +} +/* The blocked-detail text renders as a fixed dark "terminal" block — the + same look in both themes, like a code snippet, so it reads consistently + against whichever tint the row around it happens to carry. */ +QWidget#detailCodeBlock { background: #1B1A19; border-radius: ${radius}px; } +QLabel#detailCodeText { + color: #CCFF00; font-family: "Cascadia Code", Consolas, monospace; font-size: 12px; +} +QPushButton#detailCopyBtn { + background: rgba(255,255,255,0.15); color: #FFFFFF; border: none; + border-radius: ${radius_sm}px; padding: 3px 10px; font-size: 11px; +} +QPushButton#detailCopyBtn:hover { background: rgba(255,255,255,0.25); } + +QLabel { background: transparent; } +QLabel#hint { color: $text_muted; } +QLabel#faint { color: $text_faint; } +QLabel#warning { color: $warning; font-weight: 600; } +QLabel#error { color: $danger; font-weight: 600; } +QLabel#success { color: $success; font-weight: 600; } +QLabel#sectionTitle { color: $text; font-size: 15px; font-weight: 600; } + +/* ---- code, terminals & logs ------------------------------------------- + These read as "sunken" surfaces: the eye goes in, not across. */ +QPlainTextEdit#codeEditor, QPlainTextEdit#termOutput, QPlainTextEdit#logView { + background: $code_bg; color: $code_fg; border: none; + font-family: $font_mono; selection-background-color: $code_selection; +} +QLineEdit#termInput { + background: $code_bg; color: $code_fg; border: none; border-top: 1px solid $border; + font-family: $font_mono; border-radius: 0; padding: 7px 10px; +} +QLineEdit#termInput:focus { border-top-color: $accent; } + +/* The help-agent dock styles itself from these same tokens — it is a floating + overlay that re-applies on every theme switch. See ui/help_agent_widget.py. */ +""") + + +def resolve_theme(theme: str) -> str: + """Resolve ``'system'`` to ``'dark'``/``'light'`` from the OS colour scheme.""" + if theme in _PALETTES: + return theme + try: + from PySide6.QtCore import Qt + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() + if app is not None: + scheme = app.styleHints().colorScheme() + return "light" if scheme == Qt.ColorScheme.Light else "dark" + except Exception: + pass + return "dark" + + +def palette(theme: str) -> Palette: + """The token set for ``theme``. Painting code reads its colours from here.""" + return _PALETTES[resolve_theme(theme)] + + +# The theme the running app is currently showing. Painting code (paintEvent, +# QSyntaxHighlighter, canvas items) reads it via current_palette() instead of +# re-reading config.json — that used to cost a file open per repaint. +_active_theme = "dark" + + +def set_active_theme(theme: str) -> str: + """Record the theme the app just applied. Call this next to every + ``QApplication.setStyleSheet(stylesheet(...))``. Returns the resolved name.""" + global _active_theme + _active_theme = resolve_theme(theme) + return _active_theme + + +def current_theme() -> str: + """The resolved theme ('dark'/'light') the app is showing right now.""" + return _active_theme + + +def current_palette() -> Palette: + """Tokens for the theme the app is showing right now.""" + return _PALETTES[_active_theme] + + +def stylesheet(theme: str) -> str: + """The application-wide Qt style sheet for ``theme``.""" + p = palette(theme) + values = asdict(p) + values["chevron_down"] = _chevron_asset("down", p.text_muted) + values["chevron_up"] = _chevron_asset("up", p.text_muted) + return _TEMPLATE.substitute(values) + + +def role_colors(theme: str) -> dict[str, str]: + """Conversation/graph node colours keyed by role.""" + p = palette(theme) + return { + "user": p.role_user, + "assistant": p.role_assistant, + "tool": p.role_tool, + "result": p.role_result, + "error": p.role_error, + } diff --git a/tools/audit_gating.py b/tools/audit_gating.py new file mode 100644 index 0000000..e550b10 --- /dev/null +++ b/tools/audit_gating.py @@ -0,0 +1,102 @@ +"""List every show/hide/enable/disable rule, baseline vs now. + +The redesign was allowed to change the flow. It was NOT allowed to change what +is hidden or greyed out — those rules encode real preconditions, and dropping +one turns a guarded action into a broken one. + +Reports rules that disappeared, appeared, or changed target between the +pre-redesign commit and HEAD. +""" +from __future__ import annotations + +import re +import subprocess +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +BASE = "291a611" +CALL = re.compile( + r"(?P[\w\.\[\]\(\)_]*?)\.?(?PsetVisible|setHidden|setEnabled|" + r"setDisabled|setTabVisible|setTabEnabled|hide|show)\s*\(") + + +def files(): + out = subprocess.run(["git", "diff", "--name-only", f"{BASE}..HEAD", "--", "*.py"], + capture_output=True, text=True, encoding="utf-8").stdout + return [f for f in out.split() if f.endswith(".py") and not f.startswith("tools/")] + + +def _arg(s, start): + """Text inside the call's parentheses — the CONDITION, which matters as + much as the call being there at all.""" + depth, out = 0, [] + for ch in s[start:]: + if ch == "(": + depth += 1 + if depth == 1: + continue + elif ch == ")": + depth -= 1 + if depth == 0: + break + if depth >= 1: + out.append(ch) + return "".join(out).strip() + + +def rules(rev, path): + """{(target, verb): set(conditions)} for one revision of one file.""" + src = subprocess.run(["git", "show", f"{rev}:{path}"], + capture_output=True, text=True, encoding="utf-8", + errors="replace").stdout or "" + found = {} + for n, line in enumerate(src.splitlines(), 1): + s = line.strip() + if s.startswith("#") or s.startswith('"'): + continue + for m in CALL.finditer(s): + target, verb = m.group("target"), m.group("verb") + if not target or (verb in ("hide", "show") and not target): + continue + cond = _arg(s, m.end() - 1) or "-" + found.setdefault((target, verb), {}).setdefault(cond, n) + return found + + +def main() -> int: + gone, added, changed = [], [], [] + for path in files(): + old, new = rules(BASE, path), rules("HEAD", path) + for key in sorted(set(old) - set(new)): + gone.append((path, key, old[key])) + for key in sorted(set(new) - set(old)): + added.append((path, key, new[key])) + for key in sorted(set(new) & set(old)): + if set(old[key]) != set(new[key]): + changed.append((path, key, old[key], new[key])) + + print(f"=== A. LUAT BI BO ({len(gone)}) ===") + for path, (target, verb), conds in gone: + for cond, line in conds.items(): + print(f" {path}:{line:<5} {target}.{verb}({cond})") + + print() + print(f"=== B. DIEU KIEN DOI ({len(changed)}) ===") + for path, (target, verb), oldc, newc in changed: + print(f" {path} {target}.{verb}()") + for c in sorted(set(oldc) - set(newc)): + print(f" cu : ({c})") + for c in sorted(set(newc) - set(oldc)): + print(f" moi : ({c}) dong {newc[c]}") + + print() + print(f"=== C. LUAT MOI THEM ({len(added)}) ===") + for path, (target, verb), conds in added: + for cond, line in conds.items(): + print(f" {path}:{line:<5} {target}.{verb}({cond})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/audit_handwritten.py b/tools/audit_handwritten.py new file mode 100644 index 0000000..999bc2c --- /dev/null +++ b/tools/audit_handwritten.py @@ -0,0 +1,23 @@ +"""Hand-written audit sections, extracted from 10-18.ui-audit.html. + +GENERATED by tools/extract_handwritten.py — do not edit by hand; edit the +source HTML and re-run it. build_audit_page.py substitutes {{SHOT}} and +{{CONTROLS}} so those stay generated. +""" + +SECTIONS = { + 'monitoring-sự-kiện-bảo-mật': '
\n

Nhật ký lần agent chạm thứ nhạy cảm: lệnh bị chặn, truy cập ngoài sandbox.

\n
Hiện tại
{{SHOT}}\n

Trên: 4 thẻ KPI · Giữa: ô tìm + nút ✨AI, lọc Agent/Hành động/Thời gian · Dưới: bảng cột Hành động dạng badge màu (thay cho ✕/✓ luôn giống nhau) + phân trang, nhấp dòng mở panel chi tiết bên phải.

\n
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Sự kiện bảo mật
⭳ Xuất CSV
⟳ Làm mới
▦
1.247Tổng sự kiện · +12.3%
◔
84Hôm nay · -8.1%
⛔
23Chặn lệnh · +3 mới
🛡
156Chặn prompt · 12.5%
✨ AI
✕ Xoá lọc
☐Thời gianAgentTài khoảnMáyHành độngChi tiết chặn
Hiển thị 24 / 24 sự kiện
‹
1
›
✨
\n\n\n
\n
Vấn đề
  • Dải tab Monitoring bị ẩn sau accordion — không thấy 7 màn Giám sát còn lại từ đây.
  • Cột "Kết quả" (✓/✕) trong bảng thật luôn hiện ✕ vì audit_log.record("security_block",…) luôn truyền ok=False — dư thừa, không phân biệt được gì.
  • KPI · lọc Agent/Hành động/Thời gian · phân trang · panel chi tiết đều là tính năng mới: bản thật hiện chỉ có 1 ô tìm + nút ✨AI + bảng cuộn (xem ui/monitoring_tab.py:_wrap_with_filter).
\n
Thay đổi
  • Bỏ ẩn dải tab Monitoring (8 mục ngang hàng) — không đổi accordion nav rail.
  • Bỏ cột Kết quả luôn-giống-nhau; thay bằng cột Hành động dạng badge màu theo name thật (prompt/run_command/install_package).
  • Vượt phạm vi "chỉ sắp xếp lại": 4 thẻ KPI, 3 dropdown lọc, phân trang và panel chi tiết trượt — cần bổ sung logic đếm/lọc/phân trang ở ui/monitoring_tab.py trước khi hiện thực, và 2/4 loại "Hành động" (path ngoài sandbox, mạng chặn) chưa có nguồn ghi log thật.
\n
', + 'monitoring-lịch-sử-gọi-mcp': '
\n

Mọi lần agent gọi MCP server ngoài.

\n
Hiện tại
{{SHOT}}\n

Trên: 4 thẻ KPI · Giữa: ô tìm + nút ✨AI, lọc Server/Kết quả/Thời gian · Dưới: bảng có badge Kết quả (✓/✕ phân biệt thật theo ok) + phân trang, nhấp dòng mở panel chi tiết bên phải. Bảng hiện tại (ảnh trên) không có ô lọc/tìm kiếm nào — khác biệt không chủ đích so với 2 màn log kia.

\n
Đề xuất — bố cục mới
\n
\n
\n\n
📁 Báo cáo tài chính Q3▾
\n
+ Đoạn chat mới
\n
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
\n
\n
RECENTS
\n
📁 Báo cáo tài chính Q3
\n
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
\n
\n
\n
Dashboard
Monitoring
\n
👤 local · Ollama ▾
\n
\n
\n
Monitoring
\n
\n
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
\n
\n
\n
\n
Lịch sử gọi MCP
\n
\n
⭳ Xuất CSV
⟳ Làm mới
\n
\n
\n
▦
892Tổng cuộc gọi · +8.4%
\n
✓
823Thành công · 92.3%
\n
✕
69Thất bại · 7.7%
\n
◔
7Hôm nay · cuộc gọi
\n
\n
\n
✨ AI
\n\n\n\n
✕ Xoá lọc
\n
\n
Thời gianAgentTài khoảnMáyToolKết quảChi tiết
\n
Hiển thị 25 / 25 cuộc gọi
‹
1
›
\n
\n
\n
\n
✨
\n\n\n
\n
Vấn đề
  • Dải tab Monitoring bị ẩn sau accordion — không thấy 7 màn Giám sát còn lại từ đây.
  • Bảng Lịch sử gọi MCP thật hiện không có ô tìm/lọc nào — còn thiếu hơn cả Sự kiện bảo mật: ui/monitoring_tab.py:166-168 tạo thẳng self.mcp_table = _EventTable() rồi thêm vào tab, không bọc qua _wrap_with_filter như Sự kiện bảo mật/Nhật ký hành động — đúng như đã ghi ở ảnh Hiện tại.
  • KPI · lọc Server/Kết quả/Thời gian · phân trang · panel chi tiết đều là tính năng mới: bản thật chỉ có bảng 7 cột (Thời gian/Vai trò/Tài khoản/Máy/Tên/Kết quả/Chi tiết), không tìm không lọc không phân trang (xem ui/monitoring_tab.py:_EventTable).
  • 5 server trong bảng mẫu (M365/GitHub/Filesystem/Brave Search/PostgreSQL) chỉ là ví dụ minh hoạ lấy từ docstring core/mcp_client.py — server thật phụ thuộc Admin đã cấu hình connector nào ở Settings, không có sẵn mặc định.
\n
Thay đổi
  • Bỏ ẩn dải tab Monitoring (8 mục ngang hàng) — không đổi accordion nav rail.
  • Thêm ô tìm kiếm + nút ✨AI cho bảng này để đồng bộ với 2 bảng log kia — hiện là bảng log duy nhất trong 3 bảng không có tìm kiếm.
  • Vượt phạm vi "chỉ sắp xếp lại": 4 thẻ KPI, 3 dropdown lọc (Server/Kết quả/Thời gian), phân trang và panel chi tiết trượt — cần bổ sung logic đếm/lọc/phân trang ở ui/monitoring_tab.py trước khi hiện thực; danh sách server chỉ minh hoạ, không phải cấu hình mặc định.
\n
', + 'monitoring-nhật-ký-hành-động': '
\n

Nhật ký cấp ứng dụng: ai đổi cấu hình, ai chạy task.

\n
Hiện tại
{{SHOT}}\n\n
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Nhật ký hành động
⭳ Xuất CSV
⟳ Làm mới
▤
25Tổng sự kiện · +6.2%
✓
19Thành công · 76%
✕
6Thất bại · 24%
🛡
4Chặn bảo mật
✨ AI
✕ Xóa lọc
Thời gianAgentLoạiMáyHành độngKết quảChi tiết
15/08 · 09:42CACowork AgentGọi MCPDESKTOP-DEMOms365__send_mail✓ Thành côngĐã gửi email đến nam.pdt@company.com — chủ đề "Báo cáo tuần Q3".
15/08 · 09:38CACowork AgentCông cụDESKTOP-DEMOread_file✓ Thành côngĐã đọc ./docs/installation.md (172 dòng).
15/08 · 09:31SASecurity AgentChặn bảo mậtDESKTOP-DEMOdangerous_command✕ Thất bạiChặn lệnh: rm -rf / --no-preserve-root
15/08 · 09:20CACowork AgentQuyềnDESKTOP-DEMOpath_outside_sandbox✕ Thất bạiChặn đọc C:\\Users\\NamPDT\\Documents\\personal.xlsx — ngoài sandbox.
15/08 · 08:55SASecurity AgentGọi MCPDESKTOP-DEMObrave__web_search✓ Thành côngTrả về 8 kết quả cho "CVE-2026-1234".
14/08 · 17:45SCscheduleCông cụDESKTOP-DEMOrun_command✓ Thành côngpython scripts/report.py — thoát mã 0.
Hiển thị 1-25 của 25 sự kiện
‹
1
›
✨
\n\n
\n
Vấn đề
  • Bảng thật (ui/monitoring_tab.py:_EventTable) gộp cả 4 loại sự kiện (tool_call/mcp_call/security_block/permission) vào tab này nhưng không có cột phân loại — phải tự suy ra loại hành động từ cột "Tên".
  • Bộ lọc thật (_wrap_with_filter) chỉ có 1 ô tìm + nút ✨AI; 3 dropdown Loại/Trạng thái/Thời gian, 4 thẻ KPI, phân trang và nút Xuất CSV trong 12.ui-redesign-action-logs.html đều là tính năng mới, chưa có trong code.
  • Bảng thật giới hạn ngầm _MAX_ROWS dòng mới nhất và không phân trang — không biết còn bao nhiêu sự kiện bị ẩn.
\n
Thay đổi
  • Thêm cột Loại dạng pill màu (Công cụ/Gọi MCP/Chặn bảo mật/Quyền) trước cột Hành động, tái dùng đúng bảng màu .pill đã áp cho Sự kiện bảo mật (mục 10).
  • Vượt phạm vi "chỉ sắp xếp lại": 4 thẻ KPI, 3 dropdown lọc, phân trang và nút Xuất CSV cần bổ sung logic đếm/lọc/phân trang/export ở ui/monitoring_tab.py trước khi hiện thực — hiện bảng thật chỉ lọc bằng 1 ô tìm text.
\n
', + 'monitoring-trạng-thái-agent': '
\n

Agent nào đang bật và nguồn định nghĩa.

\n
Hiện tại
{{SHOT}}\n\n
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Trạng thái Agent
⟳ Làm mới
AgentTrạng tháiNguồn
👤Cowork Agent● RảnhLượt đang chạy của tab Cowork
⏱Task Agent● RảnhTask đang chạy trong Schedule Task
◈Knowledge Agent● RảnhÔ hỏi của GraphRAG
▦Planner Agent—Một giai đoạn trong lượt Cowork/Task đang chạy (update_plan) — không theo dõi riêng
⬡Reasoning Agent—Luồng suy luận (reasoning) của model trong lượt đang chạy — không theo dõi riêng
🛡Security Agent● BậtKiểm duyệt prompt/đính kèm/lệnh của Agent Security — chạy inline trong lượt đang chạy
✨
\n
\n
Vấn đề
  • Bảng thật 3 cột (Agent · Đang chạy · Nguồn, ui/monitoring_tab.py:_refresh_agent_status) chỉ dùng chữ/icon đơn cho trạng thái — khó quét nhanh dòng nào đang chạy khi bảng dài.
\n
Thay đổi
  • Giữ nguyên 3 cột Agent · Trạng thái · Nguồn (không chuyển sang lưới thẻ, không thêm cột mới) — chỉ thêm icon màu theo loại agent trước tên và badge màu cho Trạng thái (xanh khi đang chạy/bật, xám khi "—") thay cho chữ thuần.
\n
', + 'monitoring-agents-admin': '
\n

Quản trị agent hệ thống. Cũng là nơi chọn model cho robot trợ giúp.

\n
Hiện tại
{{SHOT}}\n

Nút Kiểm tra: probe provider thật

\n
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Agents Admin
⟳ Làm mới
+ Thêm
Agent quản lý hệ thống, dùng chung mọi máy (lưu trong thư mục dùng chung): agent trợ giúp và agent chạy Schedule Task. Đây KHÔNG phải agent để chọn trong Cowork hay Co4E.
TênVai tròModelKích hoạtTrạng tháiCập nhật
SASecurity AgentBảo mậtOllama — qwen2.5:7bOK11/08 09:30✎ 🗑
GAGraphRAG AgentTri thứcOllama — qwen2.5:14bOK10/08 14:22✎ 🗑
MAMonitor AgentGiám sátMặc định — qwen2.5:7bKiểm tra…09/08 11:05✎ 🗑
HAHelp AssistantHỗ trợMặc định — qwen2.5:7bChưa kiểm tra08/08 16:40✎ 🗑
✨
\n{{CONTROLS}}\n
\n
Vấn đề
  • Cột Vai trò/Kích hoạt/Trạng thái chỉ là chữ hoặc icon đơn — khó phân biệt nhanh giữa các dòng khi bảng dài.
  • Bỏ [Kiểm tra tất cả] khỏi thanh công cụ chính làm mất lối vào hành động thật (_check_all trong ui/agents_admin_tab.py) — cần chỗ khác để gọi (icon riêng theo dòng, hoặc menu ⋯).
\n
Thay đổi
  • Vai trò/Trạng thái chuyển sang badge màu + avatar viết tắt tên trước Tên agent; nút Sửa/Xoá dời vào từng dòng thay vì thanh công cụ rời bên dưới.
  • Thanh công cụ chính chỉ còn 2 nút: Làm mới (giống nút monitoring.refresh toàn màn hình) và + Thêm (giống nút agents_admin.add_btn thật) — Sửa/Xoá đã dời vào từng dòng nên không cần ở đây nữa.
\n
', + 'monitoring-công-cụ': '
\n

Bật/tắt công cụ dựng sẵn và khai báo kết nối ngoài. Màn duy nhất còn hiện dải tab bên trong.

\n
Hiện tại
{{SHOT}}\n

Tool: công cụ dựng sẵn + tự kiểm tra Internet · Connector: MCP · REST · MS365 · Jira

\n
Đề xuất — bố cục mới
Tab Tool
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Tool
Connector
📄
read_file
Đọc nội dung file text trong thư mục làm việc
📁
list_dir
Liệt kê file/thư mục con tại một đường dẫn
✎
write_file
Tạo file mới hoặc ghi đè toàn bộ file
✏
edit_file
Sửa một đoạn chính xác trong file có sẵn
▤
run_command
Chạy lệnh shell trong thư mục làm việc
⭳
install_package
Cài package Python (pip) vào môi trường app
⤓
fetch_url
Lấy nội dung trang web/tài liệu theo URL
🌐 Kiểm tra Internet
✓ Kết nối OK · 42ms
🔍
jira_search
Tìm issue Jira bằng JQL, trả về danh sách tóm tắt
🔗
jira_get_issue
Đọc chi tiết 1 issue Jira theo mã (vd ABX-123)
\n\n
Tab Connector
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Tool
Connector
Kết nối tới connector bên ngoài
Một nơi duy nhất cho mọi nguồn tool ngoài — nhóm theo CAD (NX/CATIA/SolidWorks/AutoCAD), CAE (ANSA/ABAQUS/HyperWorks/ANSYS), MS365 (Microsoft 365/OneDrive/SharePoint) và Other (MCP server bất kỳ). MS365 tự kết nối qua server tích hợp sau khi đăng nhập; còn lại bạn trỏ mỗi connector tới MCP server bạn đã có hoặc REST API nó cung cấp (không kèm SDK hãng nào).
🔧CAD(NX / CATIA / SolidWorks / AutoCAD)
AutoCAD
MCP server (stdio)✎ Sửa  ·  🗑 Xóa
📐CAE(ANSA / ABAQUS / HyperWorks / ANSYS)
ANSA
MCP server (stdio)✎ Sửa  ·  🗑 Xóa
☁MS365(Microsoft 365 / OneDrive / SharePoint)
OneDrive
Tích hợp, tự kết nối
SharePoint
Tích hợp, tự kết nối
🔌Other(any generic MCP server)
Jira
Tích hợp, tự kết nối · chưa cấu hình✎ Sửa
+ Thêm connector…
✨
\n{{CONTROLS}}\n
\n
Vấn đề
  • Màn duy nhất còn hiện dải tab — 7 màn Monitoring kia bị ẩn. Không nhất quán.
  • Tab Tool/Connector lọt thỏm trong một mục nav.
  • Cấu hình kết nối tách khỏi Settings → thiết lập ở 2 nơi.
  • Tab Tool liệt kê 9 tool thật (core/tools.py:TOOL_SPECS) dạng chữ phẳng (☑/☐) — không thấy ngay tool nào đang bật khi lướt nhanh.
  • Tab Connector là cây phân cấp 4 nhóm (CAD/CAE/MS365/Other, ui/connectors_panel.py) — phải bung từng nhóm mới thấy có gì bên trong.
\n
Thay đổi
  • Bỏ ẩn dải tab cho toàn bộ Monitoring → 8 tab một hàng; Tool/Connector ngang hàng.
  • Tab Tool: 9 tool thành lưới thẻ căn trái (không kéo giãn lấp đầy hàng) — icon màu theo loại + công tắc gạt bật/tắt, thay ô tick trong list phẳng.
  • Tab Connector: 4 nhóm thật thành khối nhóm theo catalog (không gộp vào tab Tool) — mỗi catalog là 1 tiêu đề (icon + tên + loại máy/phần mềm), bên dưới là hàng thẻ nhỏ căn trái, mỗi thẻ 1 option thật kèm công tắc gạt bật/tắt (không phải badge tĩnh) — đúng bản chất mỗi option đều chuyển được trạng thái, nhất quán với công tắc ở tab Tool; giữ đúng cấu trúc phân cấp 2 tầng của cây thật thay vì gộp phẳng vào 1 thẻ/catalog. Công tắc "Kết nối ngoài" tổng vẫn ở trên cùng. Thẻ của connector do người dùng tự thêm (AutoCAD, ANSA) có thêm ✎ Sửa/🗑 Xóa đúng như _ext_edit/_ext_delete thật; Jira (built-in) chỉ có ✎ Sửa (mở dialog cấu hình riêng, không xóa được); OneDrive/SharePoint (built-in MS365) không có nút nào — thật cũng chỉ bật/tắt, không sửa/xóa.
  • Nút Kiểm tra Internet dời từ thanh trên (tách khỏi tool nào) vào đúng bên trong thẻ fetch_url, khớp tools_admin_tab.py:_fetch_url_desc_cell — nút này gắn với khả năng fetch_url, không phải một hành động chung của cả tab.
\n
', + 'monitoring-icon': '
\n

Thư viện icon, dùng lại khi đặt icon cho agent Co4E.

\n
Hiện tại
{{SHOT}}\n\n
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Icon
+ Thêm icon
Dán SVG
Xoá
🔍 Tìm icon theo tên…
ICON TÍCH HỢP
✛
plus
✓
check
✕
close
✎
edit
🗑
trash
⟳
refresh
🔍
search
⚙
settings
🤖
robot
🛡
shield
🔧
wrench
★
star
ICON TÙY CHỈNH
🧠
brain
⌥
code
☁
cloud
✨
\n{{CONTROLS}}\n
\n
Vấn đề
  • Ô icon trong lưới hiện tại không có viền/hover rõ khi rê chuột hay khi đang chọn.
\n
Thay đổi
  • Ô tìm kiếm thêm icon kính lúp bên trái; mỗi ô icon có viền accent khi hover/chọn — cùng ngôn ngữ thẻ với Agent/Công cụ.
\n
', + 'dialog-settings': '
\n

Thiết lập toàn app. Cuộn dọc, không mục lục.

\n
Hiện tại
{{SHOT}}\n

5 nhóm: Ngôn ngữ · Provider · Bảo mật (khoá mật khẩu) · Tham số · Routing

\n
Đề xuất — bố cục mới
Cài đặt
Chungngôn ngữ · giao diện · khay
AI ProviderOllama · qwen2.5-coder
Bảo mật sandbox🔒 cần mở khoá
Tham sốđính kèm · GraphRAG · giới hạn
Auto Model Routingđang Tắt
Ngôn ngữ hiển thị
EnglishTiếng Việt日本語
Giao diện
SángHệ thốngTối
Giữ chạy nền trong khay hệ thống khi đóng
Hiện thông báo khay khi tác vụ xong hoặc lỗi
Nhà cung cấp AI
Ollama (local models)
Base URL
http://localhost:11434
API Key
••••••••
Model
qwen2.5-coder:7b ⭳ Tải · ⚗ Test kết nối
Nhập mật khẩu…
Unlock
🔒 Locked
Xác nhận trước khi Cowork chạy lệnh
Chặn mạng cho lệnh do agent chạy
Enable Agent Security (kiểm tra lệnh)
AI check commands
Đính kèm
Số tệp tối đa
−20+
tệp
Token tối đa mỗi tệp
−500+
nghìn
Cấu trúc & GraphRAG
Số node tối đa
−500+
node
Số cạnh tối đa
−500+
cạnh
Giới hạn sandbox
CPU
Không giới hạn
Bộ nhớ
2.048 MB
Disk I/O
2.048 MB
Chế độ
TắtAutoManual
Chính sách
Chất lượngChi phíĐộ trễCân bằng
Ngưỡng tăng điểm tối thiểu
−5+
%
Timeout xác nhận
−60+
giây
Chu kỳ đánh giá lại
−24+
giờ
Đồng thời mỗi provider
−2+
Judge model
(để trống · dùng model đang chọn)
⟳ Đánh giá lại ngay
Huỷ
Lưu
\n\n{{CONTROLS}}\n
\n
Vấn đề
  • Năm group cuộn dọc, không mục lục.
  • Sandbox khoá bằng mật khẩu hard-code (settings_dialog.py:115).
  • Provider/Ngôn ngữ/Giao diện ở top bar, tách khỏi Settings.
\n
Thay đổi
  • Thêm cột mục lục bên trái; gom Ngôn ngữ/Giao diện vào nhóm Chung (AI Provider vẫn là nhóm riêng như hiện tại, không gộp).
  • Đổi cách hiển thị field theo đúng loại dữ liệu: checkbox ☑/☐ → toggle switch, dropdown ít lựa chọn (2–4 giá trị) → segmented control, số nhập tay → stepper, 3 nhóm con trong Tham số → gom thành card — vẫn đúng field/giá trị mặc định thật từ ui/settings_dialog.py, chỉ đổi cách trình bày chứ không đổi dữ liệu.
\n
', + 'dialog-task-editor': '
\n

Khai báo tác vụ hẹn giờ: nội dung, lịch lặp, phụ thuộc, thông báo.

\n
Hiện tại
{{SHOT}}\n

5 nhóm: Cơ bản · Lịch · Đầu vào · Phụ thuộc · Thực thi

\n
Đề xuất — bố cục mới
\n

Cùng 1 dialog TaskEditorDialog cho cả Thêm task và Sửa task — chỉ đổi tiêu đề (Thêm/Sửa), bố cục dưới đây dùng chung cho cả hai. Layout theo đúng kiểu màn Cài đặt: danh sách 5 nhóm bên trái thay cho 3 tab wizard, đúng 5 QGroupBox thật (không phải 3 bước tự đặt ra).

\n
Sửa task
Cơ bảntiêu đề · loại chạy · model
Lịchchạy 1 lần · lặp lại
Đầu vàoprompt · tệp · liên kết
Phụ thuộctask kế tiếp · chờ hoàn tất
Thực thithử lại · timeout · phê duyệt
Chế độ
Bình thườngTự động
Tiêu đề
Gửi báo cáo doanh thu hằng ngày 08:00
Mô tả
Gom số liệu ngày hôm trước, dựng bảng và gửi email cho nhóm kế toán. ✨ Sinh prompt từ mô tả
Project
Báo cáo tài chính Q3
Loại chạy
AI AgentCo4E Flow
Nhà cung cấp
Ollama (local models)
Model
qwen2.5-coder:7b ⭳ Tải model
Skill
(Không dùng)
Ưu tiên
medium
Trạng thái
backlog
Bật lịch chạy
Thời điểm chạy
2026-08-17 08:00 AM
Lặp lại
Hằng ngày
Cron (khi lặp = tuỳ chỉnh)
0 8 * * * — chọn mẫu có sẵn
Chỉ ngày làm việc (bỏ T7/CN)
Bỏ qua ngày nghỉ lễ
Mã quốc gia lễ
VN
Kênh thông báo
KhôngTeamsOutlook
Email nhận thông báo
ketoan@company.com
Prompt thủ công
Tổng hợp số liệu doanh thu ngày hôm trước từ file đính kèm, dựng bảng tóm tắt.
Tệp đính kèm
📄 sales_template.xlsx
📄 Q3_report_outline.docx
+ Thêm tệp
🗑 Xoá
Liên kết
🔗 https://intranet.company.com/sales-dashboard
+ Thêm liên kết
🗑 Xoá
Task kế tiếp
(Không có)
Chế độ chạy tiếp
Không tự động chạy tiếp
Dùng output làm input task sau
Chờ các task này xong (fan-in)
☑ Gom số liệu doanh thu
☐ Dựng slide trình bày Q3
Số lần thử lại
0 lần
Timeout
600 giây
Cần phê duyệt (chờ bấm Chạy ngay)
Huỷ
Lưu
\n\n{{CONTROLS}}\n
\n
Vấn đề
  • Năm group dọc — form dài nhất app, không thấy đang ở bước nào.
\n
Thay đổi
  • Đổi sang layout danh sách bên trái + panel bên phải giống màn Cài đặt (thay vì chia tab) — 5 mục danh sách khớp đúng 5 QGroupBox thật (Cơ bản · Lịch · Đầu vào · Phụ thuộc · Thực thi, xem ui/task_editor_dialog.py), luôn thấy đang ở nhóm nào và còn bao nhiêu nhóm nữa — nhất quán với cách điều hướng ở Cài đặt thay vì mỗi màn một kiểu.
\n
', +} + +EXTRA_CSS = '.embed{width:100%;height:760px;border:1px solid var(--bd);border-radius:var(--r);\nbackground:#fff;display:block}\np.hint{margin:4px 0 8px}\n.wf .acct{margin-top:6px;padding:6px 7px;border-top:1px solid var(--navb);color:var(--mut);font-size:10px}\npadding:6px 8px;color:var(--tx);height:26px;box-sizing:border-box;overflow:hidden}\n.wf .inp.tall{min-height:52px;height:auto}\npadding:4px 9px;white-space:nowrap;align-self:center;height:26px;box-sizing:border-box;\ndisplay:inline-flex;align-items:center;justify-content:center;line-height:1}\n/* ---- section-10 interactive-preview vocabulary: literal palette from 10SuKienBaoMat.html ---- */\n.wf .kpi2{flex:1;background:var(--rz);border:1px solid var(--bd);border-radius:6px;padding:7px 9px;display:flex;align-items:center;gap:7px;min-width:0}\n.wf .kpi2 b{font-size:15px;display:block;line-height:1.2}\n.wf .ic{width:20px;height:20px;border-radius:5px;flex:none;display:flex;align-items:center;justify-content:center;font-size:10px}\n.wf .ic.blue{background:#DEECF9;color:#0078D4}.wf .ic.grn{background:#DFF6DD;color:#107C10}\n.wf .ic.red{background:#FDE7E9;color:#D13438}.wf .ic.pur{background:#F3E8FD;color:#8764B8}\n.wf .ic.amb{background:#FFF4CE;color:#795548}.wf .ic.gry{background:#F3F2F1;color:#605E5C}\n.wf .pill{display:inline-block;padding:2px 8px;border-radius:99px;font-size:9px;font-weight:700;white-space:nowrap}\n.wf .pill.red{background:#FDE7E9;color:#D13438}.wf .pill.grn{background:#E4F7C7;color:#498205}\n.wf .pill.teal{background:#D2F0EE;color:#008272}.wf .pill.org{background:#FDE6D9;color:#DA3B01}\n.wf .pill.blue{background:#DEECF9;color:#0078D4}.wf .pill.amb{background:#FFF4CE;color:#795548}\n.wf .pill.pur{background:#F3E8FD;color:#8764B8}.wf .pill.gry{background:#F3F2F1;color:#605E5C}\n.wf .r.wrap{flex-wrap:wrap}\n.wf .av{display:inline-flex;width:16px;height:16px;border-radius:50%;flex:none;align-items:center;\njustify-content:center;font-size:7px;font-weight:700;color:#fff;margin-right:4px}\n.wf .av.red{background:#D13438}.wf .av.blue{background:#0078D4}\n.wf .av.pur{background:#8764B8}.wf .av.grn{background:#107C10}.wf .av.amb{background:#FFB900}\n.wf .av.teal{background:#008272}.wf .av.dark{background:#24292F}.wf .av.olv{background:#498205}\n.wf .tblwrap{overflow-y:auto;overflow-x:hidden}\n.wf .evtbl{width:100%;border-collapse:collapse;font-size:9.5px}\n.wf .evtbl thead{position:sticky;top:0;background:var(--bg)}\n.wf .evtbl th{text-align:left;padding:4px 6px;color:var(--fnt);font-weight:700;text-transform:uppercase;\nfont-size:8px;letter-spacing:.04em;border-bottom:1px solid var(--bd);white-space:nowrap;cursor:default}\n.wf .evtbl td{padding:4px 6px;border-bottom:1px solid var(--bd);white-space:nowrap;vertical-align:middle}\n.wf .evtbl td.dt{white-space:normal;color:var(--fnt)}\n.wf .evtbl tbody tr{cursor:pointer}.wf .evtbl tbody tr:hover{background:var(--sf)}\n.wf .pane.hide{display:none}\n.wf .hide{display:none}\n/* ---- Settings field-display alternatives (toggle switch / stepper) ---- */\n.wf .tsw{display:inline-flex;align-items:center;width:32px;height:17px;border-radius:99px;\nbackground:var(--bds);position:relative;cursor:pointer;flex:none;transition:background .15s}\n.wf .tsw i{position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;\nbackground:#fff;transition:left .15s;box-shadow:0 1px 2px rgba(0,0,0,.25)}\n.wf .tsw.on{background:var(--ac)}\n.wf .tsw.on i{left:17px}\n.wf .stp{display:inline-flex;align-items:center;border:1px solid var(--bds);border-radius:4px;\noverflow:hidden;height:26px;box-sizing:border-box;flex:none}\n.wf .stp .sb{width:22px;height:100%;display:flex;align-items:center;justify-content:center;\nbackground:var(--rz);cursor:pointer;font-weight:700;color:var(--tx);user-select:none}\n.wf .stp .sb:hover{background:var(--sf)}\n.wf .stp .sv{padding:0 10px;min-width:44px;text-align:center;font-weight:600;background:var(--bg);\nheight:100%;display:flex;align-items:center;justify-content:center;\nborder-left:1px solid var(--bds);border-right:1px solid var(--bds)}\n.wf .frow{display:flex;align-items:center;gap:6px;min-height:0;padding:2px 0}\n.wf .seg{display:inline-flex;border:1px solid var(--bds);border-radius:4px;overflow:hidden;flex:none}\n.wf .seg span{background:var(--bg);color:var(--mut);padding:3px 10px;cursor:pointer;font-size:10px;white-space:nowrap}\n.wf .seg span.on{background:var(--ac);color:#fff;font-weight:600}\n.wf .pane.overlay{position:absolute;top:0;right:0;bottom:0;width:38%;z-index:5;\nbackground:var(--bg);border-left:1px solid var(--bd);box-shadow:-6px 0 14px rgba(0,0,0,.18)}\n.wf .dtl{padding:2px 0 0}\n.wf .dtl.grow{overflow-y:auto}\n.wf .pnlfoot{border-top:1px solid var(--bd);flex:none;padding-top:6px}\n#s10close{cursor:pointer}\n#s11close{cursor:pointer}\n.wf .dtl .hd3{font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--fnt);\npadding-bottom:3px;margin:8px 0 3px;border-bottom:1px solid var(--bd)}\n.wf .dtl .hd3:first-child{margin-top:0}\n.wf .dtl .fld{display:flex;justify-content:space-between;align-items:center;padding:3px 0;gap:6px}\n.wf .dtl .fld .lbl{color:var(--mut)}\n.wf .dtl .fld .val{font-weight:600;text-align:right}\n.wf .dtl .mono{font-family:"Cascadia Code",Consolas,monospace;background:var(--sf);border:1px solid var(--bd);\nborder-radius:3px;padding:1px 6px;font-size:9px;font-weight:400}\n.wf .dtl .tagn{background:var(--sf);border-radius:3px;padding:1px 7px;font-size:9px;font-weight:400}\n.wf .dtl .code{background:#1b1a19;color:#CCFF00;font-family:"Cascadia Code",Consolas,monospace;\nfont-size:9px;padding:6px 8px;border-radius:4px;margin:4px 0;display:flex;align-items:center;\njustify-content:space-between;gap:6px}\n.wf .dtl .code .cpy{background:rgba(255,255,255,.15);color:#fff;border-radius:3px;padding:2px 6px;\nfont-size:8px;white-space:nowrap;flex:none;cursor:pointer}\n.wf select.btn{appearance:none;-webkit-appearance:none;font:inherit;color:inherit;padding-right:16px;cursor:pointer;\nbackground-image:url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'8\' height=\'8\' viewBox=\'0 0 10 10\'%3E%3Cpath d=\'M1 3.5L5 7.5L9 3.5\' stroke=\'%23888\' stroke-width=\'1.5\' fill=\'none\' stroke-linecap=\'round\'/%3E%3C/svg%3E");\nbackground-repeat:no-repeat;background-position:right 4px center}\n.wf input.inp{font:inherit;color:inherit;outline:none;width:100%}\n.wf .srchwrap{position:relative;display:flex;align-items:center;min-width:0}\n.wf .srchwrap .inp{padding-right:48px}\n.wf .srchwrap .aibtn{position:absolute;right:3px;top:50%;transform:translateY(-50%);cursor:pointer;\nline-height:1;padding:5px 10px;border-radius:4px;font-size:11px}\n.wf .edge{background:var(--sf);border:1px solid var(--bds);border-right:none;border-radius:5px 0 0 5px;padding:14px 5px;color:var(--mut)}\n.wf .aibadge{background:#E6F6F4;border:1px solid #7FD0C4;border-radius:6px;padding:7px 11px;font-weight:700;color:#0F6E62;white-space:nowrap}\n.wf .dock.badge{background:#E6F6F4;border:1px solid #7FD0C4;border-radius:6px;padding:7px 11px;font-weight:700;color:#0F6E62;white-space:nowrap}\n.wf .dock.pnl{width:74%;height:76%;background:var(--sf);border:1px solid var(--bds);border-radius:6px;box-shadow:0 3px 10px rgba(16,32,64,.13)}\n.wf .edge{background:var(--sf);border:1px solid var(--bds);border-right:none;border-radius:5px 0 0 5px;padding:14px 5px;color:var(--mut)}\n.wf .aibadge{background:#E6F6F4;border:1px solid #7FD0C4;border-radius:6px;padding:7px 11px;font-weight:700;color:#0F6E62;white-space:nowrap}\n.wf .dock .badge{background:#E6F6F4;border:1px solid #7FD0C4;border-radius:6px;\n/* Menu ⋯ trong panel tro ly — ghep tu ui-audit.html */\n/* Chấm trợ lý 26px — ghép từ ui-audit.html */' + +EXTRA_JS = [ +] diff --git a/tools/build_audit_page.py b/tools/build_audit_page.py new file mode 100644 index 0000000..eb3746c --- /dev/null +++ b/tools/build_audit_page.py @@ -0,0 +1,1599 @@ +"""Generate docs/ui-audit.html — the UI/UX audit page. + +Reads docs/screens/manifest.json (produced by tools/capture_screens.py) and +emits one section per captured screen: the CURRENT screenshot on top, the +PROPOSED wireframe below, plus the problems found and what changes. + +Driven by the manifest so a screen can never be silently dropped: anything in +the manifest without an entry in ANALYSIS still gets a section, flagged as +"chưa phân tích". + +Run: python tools/build_audit_page.py +""" +from __future__ import annotations + +import base64 +import json +import sys +from datetime import datetime +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +DOCS = REPO / "docs" +MANIFEST = DOCS / "screens" / "manifest.json" +OUT = DOCS / "ui-audit.html" + +# Eight sections were written by hand and are richer than anything this script +# produces. They live in a data module (rebuilt by tools/extract_handwritten.py) +# and are merged in below, so ui-audit.html stays the ONE output file instead of +# a generated file plus a hand-edited copy that drift apart. +try: + from audit_handwritten import EXTRA_CSS as HAND_CSS + from audit_handwritten import EXTRA_JS as HAND_JS + from audit_handwritten import SECTIONS as HAND_SECTIONS +except ImportError: # pragma: no cover + sys.path.insert(0, str(REPO / "tools")) + from audit_handwritten import EXTRA_CSS as HAND_CSS + from audit_handwritten import EXTRA_JS as HAND_JS + from audit_handwritten import SECTIONS as HAND_SECTIONS + +# Screenshots are inlined as data: URIs so the page is ONE self-contained file — +# copy it anywhere and the images travel with it. `--external` opts out, leaving +# the images as `screens/*.png` next to a much smaller HTML. +STANDALONE = "--external" not in sys.argv + +# -------------------------------------------------------------------------- +# Per-screen analysis. `wf` is a wireframe of the PROPOSED layout, built from +# the tiny class vocabulary defined in CSS below (.r=row, .c=col, .b=box …). +# -------------------------------------------------------------------------- + +# Wireframes carry the SAME demo content as the screenshots above them, so the +# two can be compared like-for-like instead of "real app vs empty boxes". +# +# RECENTS is SCOPED TO THE ACTIVE PROJECT, matching today's behaviour: history is +# stored inside the project's own folder (config.py:590 → workspace_tab.py:453) +# and the sidebar groups threads per project (sidebar.py:193). A flat, global +# recents list would silently drop both — a regression, not a simplification. +RECENTS = ["📌 Gom số liệu doanh thu", "Dựng slide trình bày Q3"] + + +# Floating on every screen, pinned bottom-right — same corner as the app. +# The app spends 84×64px there: a 64px badge plus an 18px chevron beside it +# (help_agent_widget.py:34-37). That is a lot of permanent real estate for a +# thing you open a few times a day, so the proposal is one 26px dot. The label +# moves to hover/tooltip and to the panel header; nothing is removed. +DOCK_FAB = ('
' + '✨
') +DOCK_BADGE = DOCK_FAB # name kept: 16 screens already reference it + + +def rail(active: str = "", project: str = "Báo cáo tài chính Q3", + *, empty: bool = False) -> str: + """The proposed flat sidebar, with `active` highlighted. + + `empty=True` renders the no-project state. Running the app with zero + projects shows Cowork and GraphRAG simply *gone* from the menu; here they + stay put but dimmed, and the actions that need a project are disabled with + a reason rather than vanishing. + """ + items = ["Project", "Cowork", "Co4E", "Folder", "GraphRAG", "Schedule Task"] + needs_project = {"Cowork", "GraphRAG"} + rows = "".join( + f'
{n}
' + for n in items) + if empty: + return ('
' + '' + '
Chưa có project' + '▾
' + '
+ Đoạn chat mới
' + '
Tạo project trước
' + f'{rows}' + '
RECENTS
' + '
trống
' + '
' + '
Dashboard
Monitoring
' + '
Cài đặt
' + '
👤 local' + 'VN ▾🌙
') + recents = "".join(f'
{t}
' for t in RECENTS) + return ( + '
' + # The app already collapses the rail to icons only (150px ↔ 54px, + # app.py:409). Keep the control and keep it where it is. + '' + f'
📁 {project}▾
' + '
+ Đoạn chat mới
' + f'{rows}' + '
' + '
RECENTS
' + f'
📁 {project}
' + f'{recents}' + '
Tất cả project…
' + '
' + '
' + '
Dashboard
Monitoring
' + '
Cài đặt
' + '
👤 local' + 'VN ▾🌙
' + '
') + + +def rail_collapsed() -> str: + """The rail after the MENU button folds it to icons only (54px).""" + icons = ["▣", "▤", "◫", "⌥", "◈", "▦"] + rows = "".join(f'
{g}
' + for i, g in enumerate(icons)) + return ('
' + '' + '
+
' + f'{rows}
' + '
◔
◕
' + '
👤
') + + +def projbar(name: str = "") -> str: + """Deprecated: the project picker moved into the sidebar (see `rail`). + + It scopes ctx.active_project_id — global app state (state.py:60) — so a bar + inside each screen's content area wrongly implied it was per-screen, and left + it far from the "+ Đoạn chat mới" button it governs. + """ + return "" + + +def grp(label: str, action: str = "", *, collapse: str = "") -> str: + """A list-group heading, optionally with its own create/manage button. + + Putting "+" beside WORKFLOWS (and beside AGENTS) is what replaces the "+" + that used to live on the flow tab strip: removing the strip removed its + button too, and the create action has to land somewhere explicit. + """ + chev = {"left": "‹", "right": "›"}.get(collapse, "") + tail = f'{chev}' if chev else "" + act = f'{action}' if action else "" + return f'
{label}{act}{tail}
' + + +def li(text: str, sub: str = "", *, on: bool = False) -> str: + """One row in a list pane.""" + s = f'{sub}' if sub else "" + return f'
{text}{s}
' + + +def card(title: str, meta: str) -> str: + """One Kanban card.""" + return f'
{title}{meta}
' + + +# -------------------------------------------------------------------------- +# What each screen IS. `d` = one-paragraph purpose; `r` = the regions visible in +# the screenshot, left→right / top→bottom, so a reader can map the picture. +# -------------------------------------------------------------------------- +DESCRIPTIONS: dict[str, dict] = { + "dashboard": {"d": "Token đã tiêu và chi phí quy ra tiền, theo kỳ.", + "r": [("Header", "kỳ · granularity · metric · tiền tệ"), + ("6 thẻ", "Total · In · Out · Cache · Cost · Ngân sách"), + ("Biểu đồ", "spline + đường so sánh kỳ trước"), ("Thói quen", "top task tốn token")]}, + "schedule-kanban": {"d": "Kanban các tác vụ hẹn giờ. Bộ lập lịch chạy nền dù màn này đóng.", + "r": [("Lane", "một cột mỗi trạng thái"), + ("Thẻ", "kéo đổi lane · double-click sửa · chuột phải: Chạy ngay / Lịch sử")]}, + "schedule-calendar": {"d": "Cùng dữ liệu Kanban, xếp theo ngày.", + "r": [("Ô ngày", "nút + tạo task lúc 09:00 ngày đó")]}, + "workspace-project": {"d": "Khai báo project — đơn vị gom nhóm của app. Mỗi project có sandbox riêng " + "và Instructions chèn vào mọi chat. Đây là bộ chọn project duy nhất.", + "r": [("Trái", "danh sách project — chỉ hiện ở tab này"), + ("Phải", "Tên · Mô tả · Instructions · thư mục")]}, + "workspace-cowork": {"d": "Chat với agent. Agent đọc/ghi tệp trong sandbox, chạy lệnh, gọi MCP.", + "r": [("Lịch sử", "chat gom theo project; nhãn đậm = tên project, chỉ là nhãn"), + ("Hội thoại", "bong bóng theo trục thời gian"), ("Files", "tệp đầu ra, gập được"), + ("Composer", "Enter gửi · /skill · /agent"), + ("Hàng dưới", "thư mục sandbox (chỗ thứ 2 lộ project) · model · Định tuyến · Tự chạy")]}, + "workspace-co4e": {"d": "Xưởng dựng workflow node-graph. Lưu toàn cục, không theo project.", + "r": [("Sidebar", "3 tab icon: Workflows · Agents · Skills — kéo thả được"), + ("Dải tab", "Flow Status ghim + mỗi workflow một tab"), ("Canvas", "node và cạnh"), + ("Phải", "cấu hình bước đang chọn")]}, + "workspace-folder": {"d": "Duyệt tệp + nhờ AI sửa. AI không ghi đè — đề xuất diff, bấm Apply mới ghi.", + "r": [("Cây trái", "hệ thống tệp thật"), ("Viewer", "code / HTML / PDF / ảnh / bảng tính"), + ("Panel AI", "yêu cầu → plan → diff → Apply"), ("Terminal", "shell thật, không qua sandbox")]}, + "workspace-graphrag": {"d": "Đồ thị tri thức về cấu trúc mã/tài liệu + agent hỏi đáp trên đó.", + "r": [("Đồ thị", "node theo loại, cạnh có nhãn quan hệ"), ("Phải", "hỏi đáp dựa trên đồ thị")]}, + "monitoring-tổng-quan": {"d": "Chi phí, tài nguyên máy, sandbox, nhật ký gần đây.", + "r": [("Trái", "Token & chi phí · Hoạt động · Tài nguyên · Bảng giá model"), + ("Phải", "Sandbox · Quyền · Audit log")]}, + "monitoring-sự-kiện-bảo-mật": {"d": "Nhật ký lần agent chạm thứ nhạy cảm: lệnh bị chặn, truy cập ngoài sandbox.", + "r": [("Ô lọc", "có nút ✨ biến câu hỏi thành từ khoá")]}, + "monitoring-lịch-sử-gọi-mcp": {"d": "Mọi lần agent gọi MCP server ngoài.", + "r": [("Bảng", "không có ô lọc như 2 màn log kia — khác biệt không chủ đích")]}, + "monitoring-nhật-ký-hành-động": {"d": "Nhật ký cấp ứng dụng: ai đổi cấu hình, ai chạy task.", "r": []}, + "monitoring-trạng-thái-agent": {"d": "Agent nào đang bật và nguồn định nghĩa.", "r": []}, + "monitoring-agents-admin": {"d": "Quản trị agent hệ thống. Cũng là nơi chọn model cho robot trợ giúp.", + "r": [("Nút Kiểm tra", "probe provider thật")]}, + "monitoring-công-cụ": {"d": "Bật/tắt công cụ dựng sẵn và khai báo kết nối ngoài. " + "Màn duy nhất còn hiện dải tab bên trong.", + "r": [("Tool", "công cụ dựng sẵn + tự kiểm tra Internet"), ("Connector", "MCP · REST · MS365 · Jira")]}, + "monitoring-icon": {"d": "Thư viện icon, dùng lại khi đặt icon cho agent Co4E.", "r": []}, + "dialog-settings": {"d": "Thiết lập toàn app. Cuộn dọc, không mục lục.", + "r": [("5 nhóm", "Ngôn ngữ · Provider · Bảo mật (khoá mật khẩu) · Tham số · Routing")]}, + "dialog-task-editor": {"d": "Khai báo tác vụ hẹn giờ: nội dung, lịch lặp, phụ thuộc, thông báo.", + "r": [("5 nhóm", "Cơ bản · Lịch · Đầu vào · Phụ thuộc · Thực thi")]}, + "dialog-skills": {"d": "Quản lý skill — khối hướng dẫn tái dùng, gõ /skill để chèn.", + "r": [("Ô tick", "chính là bật/tắt skill")]}, + "dialog-skill-edit": {"d": "Soạn skill: tên, mô tả, hướng dẫn.", + "r": [("✨", "sinh hướng dẫn từ mô tả ngắn")]}, + "dialog-file-edit": {"d": "Xem và nhờ AI sửa tệp, mở từ panel Files trong chat.", + "r": [("Tệp nhị phân", "trích văn bản, chỉ đọc"), ("Lưu", "tạo .bak trước khi ghi")]}, + "dialog-co4e-agent": {"d": "Định nghĩa agent Co4E: tính cách, quyền, model, skill.", "r": []}, + "dialog-ext-connector": {"d": "Khai báo kết nối ngoài, 2 chế độ.", + "r": [("MCP (stdio)", "lệnh + tham số"), ("REST", "URL · key · header"), ("Test", "thử kết nối thật")]}, + "dialog-permission": {"d": "Chốt chặn cuối trước khi agent làm việc có hậu quả. " + "Bật Tự chạy thì bỏ qua bước này.", + "r": [("Xem trước", "lệnh sắp chạy hoặc diff sắp ghi")]}, + "dialog-agent-edit": {"d": "Soạn agent hệ thống: gắn vào chức năng nào, provider/model gì.", "r": []}, + "dialog-login": {"d": "Màn đăng nhập — đã dựng xong nhưng không nơi nào gọi. " + "App khởi động thẳng với user \“local\”, quyền admin.", + "r": [("3 trang", "Khởi tạo · Đăng nhập · Offline")]}, + "overlay-help-panel": {"d": "Trợ lý dùng app, nổi ở góc phải và có mặt trên mọi màn. " + "Cố tình không có công cụ — chỉ hỏi đáp cách dùng.", + "r": [("3 trạng thái", "tab mép phải → huy hiệu → panel 340×460, " + "luôn ghim góc dưới phải"), + ("3 nút", "› ẩn vào cạnh phải · — thu nhỏ về huy hiệu · " + "tab mép để hiện lại"), + ("Model", "chọn ở Monitoring ▸ Agents Admin, agent chức năng “help”")]}, +} + + +ANALYSIS: dict[str, dict] = { + "workspace-project": { + "problems": [ + "Pane trái đổi danh tính theo tab (workspace_tab.py:310-338): " + "Project → danh sách project, Cowork → History, còn lại → trống.", + "History chỉ tới được từ tab Cowork.", + "Bộ chọn project chỉ có ở tab Project.", + "2/3 chiều cao dưới là khoảng trống chết.", + "Header “Workspace — Projects” hiện ở mọi sub-tab.", + ], + "changes": [ + "History lên sidebar thành RECENTS, luôn thấy.", + "Thanh chọn project ở đầu trang, dùng chung mọi màn.", + "Pane trái cố định, không đổi danh tính.", + "Header đổi theo màn.", + ], + "wf": rail("Project") + ( + '
' + + projbar() + + '
Quản lý project
' + '
+ Project mới
' + '
' + '
' + '
PROJECT‹
' + + li("Trạm sạc EV — Cổng vận hành", "6 đoạn chat · 4 task") + + li("Báo cáo tài chính Q3", "2 đoạn chat · 3 task", on=True) + + li("Cổng tra cứu tài liệu ISO", "1 đoạn chat · 1 task") + + '
' + '
Tên
' + '
Báo cáo tài chính Q3
' + '
Mô tả
' + '
Tự động gom số liệu từ Excel phòng ban, dựng bảng tổng hợp và slide.
' + '
Instructions
' + '
Đơn vị tiền tệ mặc định VND. Làm tròn tới nghìn đồng.
' + 'Mọi con số phải truy được về file nguồn.
' + '
Thư mục làm việc
' + '
…\\workspaces\\bao-cao-tai-chinh-q3
' + '
Đổi
Mở
' + '
' + '
Lưu project
' + '
'), + }, + "workspace-cowork": { + "problems": [ + "Hàng dưới composer nhồi 5 control + usage/cost + nút thư mục " + "(chat_panel.py:142-181).", + "Không có lối tắt “chat mới” — phải chọn project → mở nav → Cowork.", + "Màn duy nhất thấy được History.", + "Ba pane bóp vùng đọc hội thoại còn chưa tới 60% bề ngang.", + "Biết project nào, nhưng không đổi được. Project hiện ở 2 chỗ (nhãn nhóm Lịch sử, " + "nhãn thư mục đáy) — cả hai chỉ là nhãn. Đổi phải quay về tab Project " + "(workspace_tab.py:323).", + ], + "changes": [ + "Bộ chọn project lên sidebar — nó là trạng thái toàn cục " + "(ctx.active_project_id), không phải của riêng màn nào.", + "Bộ chọn project + nút “+ Đoạn chat mới” đặt cạnh nhau ở đầu sidebar: " + "chọn project rồi bấm, không rời màn. Nút cũ trên toolbar giữ nguyên.", + "History lên sidebar, vẫn gom theo project + mục “Tất cả project…”.", + "Usage/cost xuống thanh trạng thái; vùng gõ chỉ còn nhập · đính kèm · gửi.", + ], + "wf": rail("Cowork") + ( + '
' + + projbar() + + '
Gom số liệu doanh thu
' + '
qwen2.5-coder
' + '
Skills
' + '
Cuộc trò chuyện mới
' + # Files pane kept — the app has it, collapsible, and it is where + # "Xem & sửa bằng AI" is reached from. + '
' + '
Có 6 file Excel trong thư mục input, gom lại thành 1 bảng ' + 'tổng hợp giúp mình.
' + '
Đã đọc cả 6 file. Lưu ý: PB_Marketing.xlsx để cột “Doanh thu” ' + 'ở cột F thay vì D và có 3 dòng trống ở cuối.

' + 'Mình đã chuẩn hoá và xuất tonghop_q3.xlsx — 1.284 dòng, tổng 42.7 tỷ VND.
' + '
' + '
TỆP ĐẦU RA (3)' + '›
' + + li("tonghop_q3.xlsx") + li("BaoCao_Q3.pptx") + li("README.md") + + '
' + '
Nhập yêu cầu… (Enter để gửi)
' + '
📎
Gửi
' + '
Agent: qwen2.5-coder · Định tuyến: Tắt  ·  ' + '↓292.8K ↑102.7K · $0.31  ·  📁 bao-cao-tai-chinh-q3
' + '
'), + }, + "workspace-co4e": { + "problems": [ + "Bốn lớp điều hướng chồng nhau: nav → tab icon sidebar → dải tab flow → panel phải.", + "Dải tab flow lặp lại danh sách Workflows ngay bên trái.", + "Panel cấu hình 11 trường dọc, phải cuộn.", + ], + "changes": [ + "Bỏ dải tab flow; chọn workflow từ danh sách trái.", + "3 tab icon → 3 mục có nhãn cùng danh sách.", + "Còn 2 lớp: chọn trái → sửa phải.", + ], + "wf": rail("Co4E") + ( + '
' + + projbar() + + '
Quy trình phát triển tính năng
' + '
+ Bước
Auto ▾
' + '
▷ Chạy
' + '
' + '
' + + grp("WORKFLOWS", "+ Mới", collapse="left") + + li("Quy trình phát triển tính năng", "5 bước · đã lưu", on=True) + + li("Rà soát bảo mật định kỳ", "2 bước · đã lưu") + + li("Dựng báo cáo từ Excel", "3 bước · đã lưu") + + grp("AGENTS (5)", "+ Mới") + + li("Phân tích yêu cầu", "ANALYST") + li("Thiết kế giải pháp", "ARCHITECT") + + li("Lập trình viên", "CODER") + li("Kiểm thử", "TESTER") + + li("Soạn tài liệu", "WRITER") + + grp("SKILLS (5)", "Quản lý…") + + li("Rà soát bảo mật · Viết test trước · Chuẩn hoá Excel · …") + + grp("LẦN CHẠY (6)") + + li("✓ Quy trình phát triển", "5/5 · 08-08 15:32") + + li("✕ Rà soát bảo mật", "3/5 · 08-06 16:32") + + li("■ Dựng báo cáo từ Excel", "1/5 · 08-04 18:32") + + '
' + '
✎
⧉
' + '
🗑
▷ Chạy nền
' + '
' + '
Phân tích yêu cầu
→
' + '
Thiết kế
→
' + '
Lập trình viên
→
' + '
Kiểm thử
' + '
CẤU HÌNH BƯỚC›
' + '
▾ Cơ bảnLập trình viên · CODER
' + '
Hiện thực theo thiết kế. Viết test trước khi sửa logic nghiệp vụ.
' + '
▸ Model & quyềnqwen2.5-coder · full
' + '
▸ Skills & tệpViết test trước
' + '
▸ Agent song songchưa có
' + '
' + '
'), + }, + "workspace-folder": { + "problems": [ + "Năm vùng cùng lúc: path · cây · viewer · panel AI · terminal.", + "Hàng nút viewer trộn 5 chức năng khác loại.", + ], + "changes": [ + "Path bar gộp vào tiêu đề.", + "Panel AI thành lớp phủ phải; terminal xuống đáy dạng thanh mỏng.", + ], + "wf": rail("Folder", "Trạm sạc EV — Cổng vận hành") + ( + '
' + + projbar("Trạm sạc EV — Cổng vận hành") + + '
Thư mục
' + '
…\\workspaces\\tram-sac-ev
' + '
Sửa
✨ AI
Lưu
' + '
' + '
' + + li("📁 src") + li(" 📄 main.py") + li(" 📁 billing") + + li("  📄 session.py", on=True) + li("📁 tests") + + li(" 📄 test_stations.py") + li("📄 README.md") + + '
' + '
' + '# billing/session.py
' + 'def close_session(sid):
' + '  s = repo.get(sid)
' + '  s.ended_at = None' + ' # ← lỗi tính tiền
' + '  return bill(s)
' + '
✨ AI SỬA TỆP›
' + '
Sửa lỗi tính dư tiền khi phiên bị ngắt đột ngột.
' + '
Lấy mốc heartbeat cuối làm ended_at.
' + '
- s.ended_at = None
' + '+ s.ended_at = last_heartbeat(sid)
' + '
' + '
Bỏ
Áp dụng
' + '
▸ Terminal — bật khi cần
'), + }, + "workspace-graphrag": { + "problems": [ + "Hai hàng toolbar riêng biệt — nên là một.", + "Nút Messages/Graph đổi hẳn nội dung pane nhưng trông như nút thường.", + ], + "changes": [ + "Gộp hai hàng toolbar thành một.", + "Messages/Graph thành cặp tab rõ ràng phía trên pane trái.", + ], + "wf": rail("GraphRAG", "Cổng tra cứu tài liệu ISO") + ( + '
' + + projbar("Cổng tra cứu tài liệu ISO") + + '
GraphRAG
' + '
…\\workspaces\\cong-tra-cuu-iso
' + '
Quét
Xuất PNG
' + '
' + '
Đồ thị
Tin nhắn
' + '
1.902 node · 3.418 cạnh
' + '
' + '
ISO 9001
→
' + '
Điều 7.5
→
' + '
Hồ sơ
' + '
HỎI ĐÁP TRÊN ĐỒ THỊ›
' + '
Điều khoản nào nói về kiểm soát hồ sơ?
' + '
Điều 7.5.3 — Kiểm soát thông tin dạng văn bản. ' + 'Có 12 tài liệu trùng số hiệu, xem trung_lap.md.
' + '
' + '
Đặt câu hỏi…
' + '
Hỏi
'), + }, + "dashboard": { + "problems": [ + "Tám control trên một hàng header.", + "Trùng Monitoring ▸ Tổng quan: cùng StatCard + BudgetCard.", + "Sáu thẻ số bằng nhau — không thấy đâu là chỉ số chính.", + ], + "changes": [ + "Header tách 2 hàng: thời gian / bộ lọc.", + "Nâng Chi phí làm thẻ chính, 4 thẻ còn lại phụ.", + ], + "wf": rail() + ( + '
' + '
Dashboard
' + '
◀
08/03 – 08/09
▶
' + '
Theo tuần ▾
Chi phí ▾
' + '
USD ▾
⟳
' + '
$0.31Tổng chi phí · 57 lượt
' + '
' + '
395.4KTổng token
' + '
292.8KInput
' + '
102.7KOutput
' + '
108.9KCache
' + '
' + '
' + '
Tốn nhiều nhất: Dựng slide trình bày — 105.4K (26%)
' + '
'), + }, + "schedule-kanban": { + "problems": [ + "7 lane bị cắt ở mép phải — lane Paused mất một nửa.", + "Kéo-thả có tác dụng thật: thả vào Running là chạy task ngay " + "(schedule_task_tab.py:265), không cảnh báo.", + "Kanban/Calendar là combo, không phải tab.", + ], + "changes": [ + "Giữ đủ 7 lane, thu hẹp cho vừa một màn. Không gộp lane nào.", + "Combo → cặp tab Kanban | Lịch.", + "Lane Running có viền cảnh báo.", + ], + # All 7 STATUSES are shown — merging any of them into an "other" menu + # would hide existing functionality, which this redesign must not do. + "wf": rail("Schedule Task") + ( + '
' + '
Kanban
Lịch
' + '
+ Task
✨ AI tạo
' + '
' + f'
BACKLOG (2)
' + f'{card("[AI] Xuất DS khách hàng B2B", "Chưa đặt lịch")}' + f'{card("Rà soát bảo mật trước release", "high")}' + '
' + f'
ĐÃ LÊN LỊCH (2)
' + f'{card("Quét lại chỉ mục ISO", "08-11 14:32")}' + f'{card("Báo cáo doanh thu 08:00", "08-09 14:32")}' + '
' + f'
ĐANG CHẠY (1) ⚠
' + f'{card("Đồng bộ heartbeat trạm sạc", "08-08 · high")}' + '
' + f'
CHỜ DUYỆT (1)
' + f'{card("Chờ kế toán duyệt số liệu T7", "Chưa đặt lịch")}' + '
' + f'
XONG (2)
' + f'{card("Sao lưu CSDL hằng đêm", "08-07 · critical")}' + f'{card("[AI] Slide tổng kết Q3", "Thành công")}' + '
' + f'
LỖI (1)
' + f'{card("Kiểm tra chứng chỉ TLS", "critical · Lỗi")}' + '
' + f'
TẠM DỪNG (1)
' + f'{card("Dọn log cũ hơn 90 ngày", "low")}' + '
' + '
' + '
Đủ 7 lane theo core.tasks.STATUSES — ' + 'không gộp, không giấu lane nào. Lane hẹp lại để vừa một màn, hết cuộn ngang.
'), + }, + "monitoring-tổng-quan": { + "problems": [ + "Sáu group box, hai cột — màn dày đặc nhất app.", + "Trộn 3 mối quan tâm: chi phí · tài nguyên · bảo mật.", + "Bảng giá model nhét chung hàng với thanh CPU/RAM.", + ], + "changes": [ + "Tách thành các mục có tiêu đề, cuộn dọc một cột chính.", + "Bảng giá model tách ra thành mục riêng.", + ], + "wf": rail() + ( + '
Monitoring
' + '
Tổng quan
Bảo mật
' + '
MCP
Hành động
Agent
' + '
Agents Admin
Công cụ
Icon
' + '
' + '
TOKEN & CHI PHÍ
' + '
395.4KTổng token
' + '
$0.31Chi phí
' + '
57Lượt gọi
' + '
—Ngân sách
' + '
TÀI NGUYÊN
' + '
CPU 34% · RAM 2.1/8 GB · Đĩa 41 GB trống
' + '
SANDBOX & QUYỀN
' + '
Tệp: chỉ trong workspace · Mạng: chặn · ' + 'Tiến trình: giới hạn 4
' + '
BẢNG GIÁ MODEL' + 'Nhập · Xuất · Thêm · Tự dò' + 'USD ▾
' + '
' + '
ModelVàoRa' + 'CacheĐơn vị
' + '
qwen2.5-coder:7b0.000.00' + '0.00/Mtok
' + '
gpt-4o-mini0.150.60' + '0.08/Mtok
' + '
NHẬT KÝ GẦN ĐÂY' + 'Xem tất cả
' + '
✕ Chặn đọc personal.xlsx (ngoài sandbox)
' + '✓ pytest tests/test_stations.py → 4 passed
' + '✕ jira.create_issue — 401 token hết hạn
' + '
'), + }, + "monitoring-công-cụ": { + "problems": [ + "Màn duy nhất còn hiện dải tab — 7 màn Monitoring kia bị ẩn. Không nhất quán.", + "Tab Tool/Connector lọt thỏm trong một mục nav.", + "Cấu hình kết nối tách khỏi Settings → thiết lập ở 2 nơi.", + ], + "changes": [ + "Bỏ ẩn dải tab cho toàn bộ Monitoring → 8 tab một hàng; Tool/Connector ngang hàng.", + ], + "wf": rail() + ( + '
Monitoring
' + '
Tổng quan
Bảo mật
' + '
MCP
Hành động
Agent
' + '
Agents Admin
Công cụ
' + '
Icon
' + '
Tool
Connector
' + '
Kiểm tra Internet
' + '
' + + li("read_file", "Đọc tệp trong sandbox — ☑ bật") + + li("write_file", "Ghi tệp trong sandbox — ☑ bật") + + li("run_command", "Chạy lệnh shell — ☑ bật") + + li("fetch_url", "Tải nội dung URL — ☑ bật · ✓ Internet OK") + + li("image_gen", "Sinh ảnh — ☐ tắt") + + '
'), + }, + "overlay-help-panel": { + "problems": [ + "Hai vùng bấm cho một tính năng. Huy hiệu mở, chevron ẩn — nằm sát nhau, " + "dễ bấm nhầm.", + "Vùng bấm quá nhỏ. Chevron rộng 18px, tab mép 16px " + "(help_agent_widget.py:36-38) — dưới ngưỡng ~24px để bấm thoải mái, " + "nhất là trên màn cảm ứng.", + "Ba trạng thái, thừa một. “Nép mép” và “huy hiệu” đều nghĩa là đang đóng; " + "người dùng phải học hai kiểu đóng và hai đường quay lại.", + "Chiếm 84×64px vĩnh viễn ngay góc dưới phải (huy hiệu 64 + khe 2 + " + "chevron 18 — help_agent_widget.py:34-37) — ở màn Cowork nó nằm đè " + "lên vùng nút Gửi, dù cả ngày chỉ mở vài lần.", + "Huy hiệu dùng chính icon app (help_agent_widget.py:49-53, " + "dự phòng là glyph robot) nên nhìn không khác gì icon cửa sổ; nhãn " + "“Trợ lý App” / “App Assistant” / “アプリアシスタント” nói chỗ dùng " + "chứ không nói nó là gì.", + ], + "changes": [ + "Một chấm 26px, không chữ. Bỏ luôn chevron rời — chỗ chiếm giảm từ " + "84×64 xuống 26×26 (−88% diện tích). Vẫn là một vùng bấm, " + "26px ≥ ngưỡng bấm thoải mái.", + "Tên: “AI Assistant” — giữ nguyên ở cả 3 ngôn ngữ, sửa đúng một " + "khoá help_agent.title (i18n.py:470) thay cho " + "“App Assistant / Trợ lý App / アプリアシスタント”. Tên dài không còn là vấn đề " + "vì nó không nằm trên màn lúc bình thường.", + "Chữ chỉ hiện khi rê chuột / focus bàn phím — chấm nở thành pill " + "“✨ AI Assistant”. Lúc bình thường màn hình không có chữ nào thừa.", + "“Ẩn trợ lý” dời vào menu ⋯ trong header panel, cạnh “Thu nhỏ”. " + "Không mất chức năng — chỉ chuyển tới lúc người dùng đang tương tác.", + "Thường ngày chỉ còn 2 trạng thái: đóng ↔ mở. Ẩn hẳn thành lựa chọn hiếm.", + "Tab mép nới từ 16px → 28px cho bấm được.", + "Ở màn có ô nhập dưới đáy (Cowork), chấm nâng lên trên hàng nhập, " + "không đè nút Gửi.", + ], + "wf": ('
' + '
Trợ lý — thu gọn còn một chấm
' + '
' + # 1. at rest — drawn to scale beside the old footprint + '
Bình thường
' + '
' + '
cũ 84×64
' + '
✨
' + '
26×26 · không chữ, không chevron · −88% diện tích
' + # 2. hover — the label appears only on demand + '
Rê chuột / focus
' + '
' + '✨' + 'AI Assistant
' + '
tên chỉ hiện lúc cần
' + # 3. open — hide lives in the ⋯ menu + '
Mở — “Ẩn” nằm trong menu ⋯
' + '
' + '
✨AI Assistant' + '
— ⋯
' + '
Thu nhỏ về chấm
' + '
Ẩn trợ lý vào cạnh phải
' + '
Đổi model…
' + '
Xin chào Nam, mình giúp gì khi bạn dùng app?
' + '
' + '
Hỏi về cách dùng app…
' + '
Gửi
' + # 4. hidden — wider edge tab + '
Đã ẩn
' + '
‹
' + '
tab mép 28px
' + '
'), + }, + "dialog-settings": { + "problems": [ + "Năm group cuộn dọc, không mục lục.", + "Sandbox khoá bằng mật khẩu hard-code (settings_dialog.py:115).", + "Provider/Ngôn ngữ/Giao diện ở top bar, tách khỏi Settings.", + ], + "changes": [ + "Thêm cột mục lục bên trái; gom Provider/Ngôn ngữ/Giao diện vào đây.", + ], + "wf": ('
Cài đặt
' + '
' + + li("Chung", "ngôn ngữ · giao diện · khay", on=True) + + li("AI Provider", "Ollama · qwen2.5-coder") + + li("Bảo mật sandbox", "🔒 cần mở khoá") + + li("Tham số", "đính kèm · GraphRAG · tài nguyên") + + li("Auto Model Routing", "đang Tắt") + + '
' + '
Ngôn ngữ hiển thị
' + '
Tiếng Việt (VN)
' + '
Giao diện
Theo hệ thống
' + '
Nhà cung cấp AI
Ollama (local models)
' + '
Thu nhỏ xuống khay khi đóng
☑ Bật
' + '
' + '
Huỷ
Lưu
' + '
'), + }, + "dialog-task-editor": { + "problems": [ + "Năm group dọc — form dài nhất app, không thấy đang ở bước nào.", + ], + "changes": ["Chia 3 bước có tab: Nội dung → Lịch chạy → Liên kết."], + "wf": ('
Sửa task
' + '
① Nội dung
' + '
② Lịch chạy
③ Liên kết
' + '
Tiêu đề
' + '
Gửi báo cáo doanh thu hằng ngày 08:00
' + '
Mô tả
' + '
Gom số liệu ngày hôm trước, dựng bảng và gửi email ' + 'cho nhóm kế toán.
' + '
Project
' + '
Báo cáo tài chính Q3
' + '
Chạy bằng
' + '
Agent · qwen2.5-coder
' + '
Ưu tiên
' + '
medium
' + '
' + '
Huỷ
' + '
Lưu
'), + }, +} + +# Screens with no bespoke analysis get this generic treatment. +GENERIC = { + "problems": ["Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng."], + "changes": ["Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên."], + "wf": "", +} + +DEAD = [ + ("AccountsTab", "ui/accounts_tab.py:153", "Quản lý tài khoản/nhóm đầy đủ, nhưng không được gắn vào MonitoringTab."), + ("LoginDialog", "ui/login_dialog.py:57", "Màn đăng nhập hoàn chỉnh; app.py:860 bỏ qua, hard-code user “local”."), + ("FlowBuilderDialog", "ui/flow_dialog.py:34", "Bị Co4E thay thế; không nơi nào gọi."), + ("AgentManagerTab", "ui/agent_manager_tab.py:28", "Chỉ dùng bởi FlowBuilderDialog → cũng không tới được."), + ("SkillManagerTab", "ui/skill_manager_tab.py", "Chỉ dùng bởi FlowBuilderDialog → cũng không tới được."), + ("McpServerEditDialog", "ui/mcp_servers_dialog.py:15", "Bị ExtConnectorEditDialog thay thế."), +] + +FLOWS = [ + ("Khởi động → màn đầu", + "python -m cowork_local → MainWindow → Workspace ▸ Project
Không có bước đăng nhập (LoginDialog bị bỏ qua)", + "Giữ nguyên đích đến. Sidebar phẳng nên Project là mục đầu, không còn nằm dưới nhánh Workspace."), + ("Tạo project → chat", + "Nav ▸ Workspace (mở nhánh) → Project → “+” → điền form → Lưu → chọn project → nav ▸ Cowork (mục vừa mới xuất hiện) → gõ", + "Sidebar ▸ Project → “+” → Lưu → sidebar ▸ Cowork (luôn nhìn thấy) → gõ.
Bớt 1 bước mở nhánh, và menu không đổi hình giữa chừng."), + ("Co4E: tạo → chạy → xem run", + "Nav ▸ Workspace ▸ Co4E → “+” trên dải tab → kéo agent từ sidebar → chọn node → sửa ở panel phải → Lưu → Chạy → bấm tab Flow Status để xem", + "Sidebar ▸ Co4E → “+ Workflow” trong danh sách trái → kéo → sửa phải → Lưu → Chạy.
Trạng thái run là một mục trong danh sách trái, không phải tab riêng."), + ("Tạo & chạy scheduled task", + "Nav ▸ Schedule → “+ Task” → form 5 group → Lưu → kéo thẻ vào lane Running (chạy ngay, không hỏi)", + "Sidebar ▸ Schedule Task → “+ Task” → form 3 tab → Lưu → kéo vào Running (lane có viền cảnh báo)."), + ("Duyệt tệp → AI sửa", + "Nav ▸ Workspace ▸ Folder → chọn tệp → bấm ✨ mở panel AI → gõ lệnh → xem plan → xem diff → Apply", + "Sidebar ▸ Folder → chọn tệp → ✨ mở lớp phủ AI → gõ → plan → diff → Apply.
Luồng giữ nguyên; panel không còn chiếm chỗ cố định."), +] + +# Which source files back each screen. A screen made of several widgets lists +# them all, so no control falls between two files. +SCREEN_FILES = { + "dashboard": ["ui\\dashboard_tab.py", "ui\\widgets.py"], + "schedule-kanban": ["ui\\schedule_task_tab.py"], + "schedule-calendar": ["ui\\calendar_view.py"], + "workspace-project": ["ui\\workspace_tab.py"], + "workspace-cowork": ["ui\\cowork_tab.py", "ui\\chat_panel.py", "ui\\composer.py", + "ui\\sidebar.py", "ui\\chat_view.py", "ui\\routing_toggle.py"], + "workspace-co4e": ["ui\\co4e_tab.py", "ui\\co4e_config_panel.py", "ui\\co4e_canvas.py"], + "workspace-folder": ["ui\\folder_tab.py", "ui\\terminal_panel.py", + "ui\\libreoffice_view.py"], + "workspace-graphrag": ["ui\\structure_graph_view.py"], + "monitoring-tổng-quan": ["ui\\monitoring_tab.py"], + "monitoring-agents-admin": ["ui\\agents_admin_tab.py"], + "monitoring-công-cụ": ["ui\\tools_admin_tab.py", "ui\\connectors_panel.py"], + "monitoring-icon": ["ui\\icons_admin_tab.py"], + "dialog-settings": ["ui\\settings_dialog.py"], + "dialog-task-editor": ["ui\\task_editor_dialog.py"], + "dialog-skills": ["ui\\skills_dialog.py"], + "dialog-file-edit": ["ui\\file_edit_dialog.py"], + "dialog-co4e-agent": ["ui\\co4e_agent_dialog.py"], + "dialog-ext-connector": ["ui\\ext_connector_dialog.py"], + "dialog-permission": ["ui\\permission_dialog.py"], + "dialog-login": ["ui\\login_dialog.py"], + "overlay-help-panel": ["ui\\help_agent_widget.py"], + # The shell is not a screen, but its controls are live and must be counted. + "__shell__": ["app.py"], +} + +# The ONLY controls that change place. Everything else stays where it is — +# keyed by the variable the AST found, so a rename breaks the link loudly. +MOVES = { + "self._nav_toggle_btn": "Giữ — nút MENU gập sidebar (150↔54px)", + "self.provider_combo": "→ menu tài khoản ở đáy sidebar", + "self.language_combo": "→ menu tài khoản ở đáy sidebar", + "self.theme_btn": "→ menu tài khoản ở đáy sidebar", + "self.settings_btn": "→ menu tài khoản ở đáy sidebar", + "self.project_list": "→ giữ ở màn Quản lý project + thêm thanh chọn đầu trang", + "self.view_combo": "→ đổi thành cặp tab Kanban | Lịch", + "self.flow_bar": "→ bỏ; chọn workflow từ danh sách trái", + "self.flow_add_btn": "→ nút “+ Mới” cạnh tiêu đề WORKFLOWS " + "(chỗ cũ là dải tab, đã bỏ nên phải có chỗ mới)", + "self.ag_new_btn": "→ nút “+ Mới” cạnh tiêu đề AGENTS", + "self.sk_manage_btn": "→ nút “Quản lý…” cạnh tiêu đề SKILLS", + "self._msg_btn": "→ đổi thành cặp tab Đồ thị | Tin nhắn", + # The chevron beside the launcher is 18px wide and sits next to a 64px + # badge; the action survives, it just moves to where the user already is. + "self.collapse_btn": "→ mục “Ẩn trợ lý vào cạnh phải” trong menu ⋯ ở header panel", + "self.edge_tab": "Giữ — tab mép mở lại trợ lý, nới 16px → 28px", + "self.search_edit": "→ lên sidebar cùng RECENTS", + "self.search_btn": "→ lên sidebar cùng RECENTS", + "self.refresh_btn": "→ lên sidebar cùng RECENTS", +} + +# One function traced end to end, because "does the new design match the old +# behaviour?" is only answerable at this level of detail. +NEWCHAT = [ + ("Số lối vào", "1 — nút “Cuộc trò chuyện mới” trên toolbar Cowork " + "(cowork_tab.py:34)", + "2 — nút sidebar + nút toolbar cũ giữ nguyên", + "Tôi vẽ thêm nút sidebar mà chưa nói gì về nút cũ. " + "Giữ cả hai (bỏ nút cũ là xoá chức năng), cùng gọi một hàm."), + ("Thấy được khi nào", "Chỉ khi đang ở tab Cowork — mà tab này " + "tự ẩn khi chưa chọn project", + "Luôn thấy trên sidebar", "Mới dễ tới hơn. Chưa chọn project thì nút mờ đi."), + ("Chat mới thuộc project nào", "Project đang mở, ngầm định — không hiển thị ở đâu", + "Bộ chọn project ngay trên nút, trong sidebar", + "Cùng hành vi — vẫn là project đang mở " + "(ctx.active_project_id), nhưng nay nhìn thấy và đổi được tại chỗ."), + ("Đổi project trước khi tạo", "Phải rời Cowork → về tab Project → chọn dòng trong danh sách " + "→ quay lại Cowork → bấm nút. 4 bước.", + "Bấm droplist ngay trên nút → chọn → bấm nút. 2 bước, không rời màn.", + "Ít bước hơn, không thêm chức năng — vẫn là chọn project rồi tạo chat."), + ("Bấm từ màn khác", "Không xảy ra được — nút chỉ có trên Cowork", + "Chuyển sang Cowork rồi tạo chat mới", + "Hành vi mới, cần thiết vì nút giờ ở mọi màn."), + ("Việc thực sự làm", "new_session() (chat_panel.py:1653): " + "xoá messages · sinh session_id mới · dọn view, composer, plan, tệp vào/ra · " + "turn đang chạy vẫn chạy nền", + "Giữ y nguyên", "Không đổi."), + ("Lưu chat cũ", "Tự lưu; History refresh qua history_changed", + "Giữ y nguyên — RECENTS refresh", "Không đổi."), +] + +# Surfaced by this audit, deliberately NOT done — each would add or change +# behaviour, which the redesign's scope forbids. +LATER = [ + ("Xuất log cho Nhật ký gần đây", + "Hiện chỉ có “Xem tất cả” nhảy sang Action Logs (monitoring_tab.py:586). " + "Xuất ra tệp là chức năng mới.", + "chức năng mới"), + ("Bỏ auto-refresh, thay bằng nút bấm", + "Monitoring làm mới mỗi 3 giây (monitoring_tab.py:43), " + "Schedule 10 giây (schedule_task_tab.py:150), " + "Dashboard 30 giây (dashboard_tab.py:175). " + "Đề xuất: chỉ làm mới khi vào màn + một nút thủ công.", + "đổi hành vi"), + ("Nút tạo skill mới", + "Cả SkillsDialog lẫn SkillManagerTab đều không có. " + "Chỉ tạo được qua AI / template / nhập / nhân bản. " + "SkillEditDialog đã làm được việc này, chỉ thiếu lối vào.", + "chức năng mới"), + ("Nút “chat mới” trong pane Lịch sử", + "sidebar.py:68 khai báo tín hiệu new_chat, " + "workspace_tab.py:241 đã nối — nhưng không nơi nào phát.", + "hoàn thiện thứ đã dựng"), + ("Gọi ensure_starter_project()", + "Hàm có docstring “đảm bảo luôn có ít nhất một project” nhưng không ai gọi, " + "trong khi refresh() lại ghi “no auto-seed”. Hai chỗ mâu thuẫn.", + "đổi hành vi"), + ("Mật khẩu Sandbox hard-code", + "settings_dialog.py:115 để mật khẩu mở khoá ngay trong mã nguồn.", + "bảo mật"), + ("Hai lớp trùng tên CustomAgent", + "core/custom_agents.py:23 và core/co4e.py:117 — " + "khác trường, khác thư mục lưu.", + "dọn mã"), + ("Sáu màn không có đường vào", + "AccountsTab · LoginDialog · FlowBuilderDialog · AgentManagerTab · " + "SkillManagerTab · McpServerEditDialog — tổng 64 control.", + "quyết định giữ hay gỡ"), +] + +# Old location → new location for EVERY screen, so "nothing was removed" is +# something the reader can check rather than take on trust. +MAPPING = [ + ("nhóm", "Màn hình làm việc", "", "", ""), + ("", "Workspace ▸ Project", "Menu ▸ Workspace (mở nhánh) ▸ Project", + "Sidebar ▸ Project — lên cấp 1, bớt 1 lần mở nhánh", "giữ nguyên"), + ("", "Workspace ▸ Cowork", "Menu ▸ Workspace ▸ Cowork — biến mất nếu chưa chọn project", + "Sidebar ▸ Cowork — luôn thấy, mờ khi chưa chọn", "giữ nguyên"), + ("", "Workspace ▸ Co4E", "Menu ▸ Workspace ▸ Co4E", "Sidebar ▸ Co4E", "giữ nguyên"), + ("", "Workspace ▸ Folder (“Thư mục”)", + "Menu ▸ Workspace ▸ Thư mục", "Sidebar ▸ Folder", "giữ nguyên"), + ("", "Workspace ▸ GraphRAG", "Menu ▸ Workspace ▸ GraphRAG — biến mất nếu chưa chọn project", + "Sidebar ▸ GraphRAG — luôn thấy", "giữ nguyên"), + ("", "Schedule Task — Kanban", "Menu ▸ Schedule Task", + "Sidebar ▸ Schedule Task ▸ tab Kanban", "giữ nguyên"), + ("", "Schedule Task — Lịch", "Menu ▸ Schedule Task ▸ combo đổi sang “Lịch”", + "Sidebar ▸ Schedule Task ▸ tab Lịch — combo thành tab, dễ thấy hơn", "giữ nguyên"), + + ("nhóm", "Giám sát & vận hành — phần bạn hỏi", "", "", ""), + ("", "Dashboard", "Menu ▸ Dashboard (cấp 1)", + "Sidebar ▸ vùng đáy — vẫn 1 cú nhấp", "giữ nguyên"), + ("", "Monitoring ▸ Tổng quan", "Menu ▸ Monitoring (mở nhánh) ▸ Tổng quan", + "Sidebar ▸ Monitoring ▸ tab Tổng quan", "giữ nguyên"), + ("", "Monitoring ▸ Sự kiện bảo mật", "Menu ▸ Monitoring ▸ Sự kiện bảo mật", + "Sidebar ▸ Monitoring ▸ tab Sự kiện bảo mật", "giữ nguyên"), + ("", "Monitoring ▸ Lịch sử gọi MCP", "Menu ▸ Monitoring ▸ Lịch sử gọi MCP", + "Sidebar ▸ Monitoring ▸ tab Lịch sử gọi MCP", "giữ nguyên"), + ("", "Monitoring ▸ Nhật ký hành động", "Menu ▸ Monitoring ▸ Nhật ký hành động", + "Sidebar ▸ Monitoring ▸ tab Nhật ký hành động", "giữ nguyên"), + ("", "Monitoring ▸ Trạng thái Agent", "Menu ▸ Monitoring ▸ Trạng thái Agent", + "Sidebar ▸ Monitoring ▸ tab Trạng thái Agent", "giữ nguyên"), + ("", "Monitoring ▸ Agents Admin", "Menu ▸ Monitoring ▸ Agents Admin", + "Sidebar ▸ Monitoring ▸ tab Agents Admin", "giữ nguyên"), + ("", "Monitoring ▸ Công cụ", "Menu ▸ Monitoring ▸ Công cụ ▸ tab con Tool | Connector", + "Sidebar ▸ Monitoring ▸ tab Công cụ ▸ Tool | Connector", "giữ nguyên"), + ("", "Monitoring ▸ Icon", "Menu ▸ Monitoring ▸ Icon", + "Sidebar ▸ Monitoring ▸ tab Icon", "giữ nguyên"), + + ("nhóm", "Thành phần bị dời chỗ", "", "", ""), + ("", "History (lịch sử chat)", + "Pane giữa, chỉ ở tab Cowork. Đã gom theo project.", + "Sidebar ▸ RECENTS — vẫn gom theo project + “Tất cả project…”", + "giữ, dễ tới hơn"), + ("", "Bộ chọn project", + "Pane trái, chỉ ở tab Project", + "Thanh chọn đầu trang, dùng chung mọi màn", "giữ, dễ tới hơn"), + ("", "Provider · Ngôn ngữ · Giao diện", "Thanh trên cùng (topbar)", + "Menu tài khoản ở đáy sidebar — gom cùng chỗ với Cài đặt", "giữ nguyên"), + ("", "Nút Cài đặt", "Thanh trên cùng", "Menu tài khoản ở đáy sidebar", "giữ nguyên"), + + ("nhóm", "Hộp thoại & lớp phủ", "", "", ""), + ("", "11 hộp thoại", "Mở từ nút trên các màn tương ứng", + "Không đổi — vẫn mở từ đúng những nút đó", "giữ nguyên"), + ("", "Robot trợ giúp · Terminal · Composer", "Lớp phủ / panel thu gọn", + "Không đổi", "giữ nguyên"), +] + +# Every set of tabs / lanes / modes in the app, so nothing is hidden by a +# truncated wireframe. "Nhìn thấy" = does the strip appear on screen at all. +TAB_GROUPS = [ + ("Thanh menu trái", "4 mục", "Dashboard · Schedule Task · Workspace · Monitoring", + "có", "app.py:154"), + ("Workspace ▸ mục con", "5 mục", + "Project · Cowork · Co4E · Folder · GraphRAG", + "không — hide_tab_bar(), và Cowork/GraphRAG " + "còn tự ẩn khi chưa chọn project", "workspace_tab.py:43"), + ("Monitoring ▸ mục con", "8 mục", + "Tổng quan · Sự kiện bảo mật · Lịch sử gọi MCP · Nhật ký hành động · " + "Trạng thái Agent · Agents Admin · Công cụ · Icon", + "không — hide_tab_bar()", "monitoring_tab.py:215"), + ("Công cụ ▸ tab con", "2 tab", "Tool · Connector", + "có — màn duy nhất còn hiện dải tab", "tools_admin_tab.py:91"), + ("Schedule ▸ chế độ xem", "2 chế độ", "Kanban · Lịch", + "là combo, không phải tab", "schedule_task_tab.py:171"), + ("Kanban ▸ lane trạng thái", "7 lane", + "backlog · scheduled · running · waiting_input · done · failed · paused", + "cắt ở mép phải, phải cuộn ngang", "tasks.py:24"), + ("Co4E ▸ sidebar", "3 tab icon", "Workflows · Agents · Skills", + "chỉ có icon, tên nằm trong tooltip", "co4e_tab.py:426"), + ("Co4E ▸ dải tab flow", "1 + N", "Flow Status (ghim) + mỗi workflow đang mở một tab", + "có, kiểu trình duyệt", "co4e_tab.py:556"), + ("Folder ▸ trình xem", "5 trang", "trống · mã nguồn · HTML · tài liệu · ảnh (+ bảng tính)", + "tự đổi theo đuôi tệp, không có tab", "folder_tab.py:322"), + ("GraphRAG ▸ khung trái", "2 trang", "Đồ thị · Tin nhắn", + "là nút bấm, không phải tab", "structure_graph_view.py:217"), + ("Dialog Tạo task bằng AI", "2 tab", "Sinh bằng AI · Nhập từ Excel", + "có", "schedule_task_tab.py:530"), + ("Dialog Kết nối ngoài", "2 chế độ", "MCP (stdio) · REST API", + "là combo đổi trang", "ext_connector_dialog.py:23"), + ("Dialog Đăng nhập (màn chết)", "3 trang", + "Khởi tạo lần đầu · Đăng nhập · Dự phòng offline", + "không tới được", "login_dialog.py:74"), + ("Dialog Flow Builder (màn chết)", "3 tab", + "Flow · Agents · Skills", "không tới được", + "flow_dialog.py:247"), +] + +# Every collapse / expand affordance the app ships. The redesign must keep all +# of them — folding a panel away is a feature users rely on, and dropping one +# would be removing functionality, not simplifying. +COLLAPSIBLES = [ + ("Thanh menu chính", "MENU ‹ ở đầu thanh — gập còn dải icon (150px → 54px)", + "app.py:409", "giữ"), + ("Pane Project", "chevron ‹ trên đầu danh sách project", + "workspace_tab.py:368", "giữ"), + ("Pane Lịch sử", "chevron trên đầu History, gập thành dải mỏng", + "sidebar.py:167 · workspace_tab.py:247", "giữ"), + ("Pane Tệp trong chat", "chevron › — gập panel Files bên phải khung chat", + "chat_panel.py:709", "giữ"), + ("Panel Hỏi đáp GraphRAG", "chevron › — gập panel agent bên phải đồ thị", + "structure_graph_view.py:615", "giữ"), + ("Panel cấu hình bước Co4E", "nút gập panel phải của canvas", + "co4e_tab.py:767", "giữ"), + ("Panel Tin nhắn Co4E", "gập khung log dưới canvas — mặc định đang gập", + "co4e_tab.py:894", "giữ"), + ("Terminal trong Thư mục", "bấm thanh tiêu đề để mở/gập — mặc định đang gập", + "terminal_panel.py:156", "giữ"), + ("Panel AI sửa tệp", "nút ✨ bật/tắt panel — mặc định đang ẩn", + "folder_tab.py:777", "giữ"), + ("Đồ thị ⇄ Tin nhắn (GraphRAG)", "nút đổi nội dung pane trái", + "structure_graph_view.py:416", "giữ — đổi thành cặp tab"), + ("Khối kết quả công cụ trong chat", "bấm tiêu đề để mở/gập output dài", + "chat_view.py:253", "giữ"), +] + +NAV_BEFORE = """Dashboard +Schedule Task +Workspace ▼ ← nhánh accordion, tab strip bên trong BỊ ẨN + Project + Cowork ← TỰ ẨN khi chưa chọn project + Co4E + Folder + GraphRAG ← TỰ ẨN khi chưa chọn project +Monitoring ▼ ← nhánh accordion, tab strip BỊ ẨN + Tổng quan + Sự kiện bảo mật + Lịch sử gọi MCP + Nhật ký hành động + Trạng thái Agent + Agents Admin + Công cụ + Icon""" + +NAV_AFTER = """[ + Đoạn chat mới ] ← hành động chính, trên cùng +────────────── +Project ← màn đầu, giữ nguyên +Cowork ← luôn hiện (mờ đi nếu chưa chọn project) +Co4E +Folder +GraphRAG ← luôn hiện (mờ đi nếu chưa chọn project) +Schedule Task +────────────── +RECENTS ← History dời từ pane giữa lên đây + · thread gần nhất… +────────────── (ghim đáy — nhóm phụ trợ) +Dashboard ← vẫn 1 cú nhấp như cũ +Monitoring ← BỎ ẨN dải tab, đủ 8 mục nằm ngang trong trang: + [Tổng quan] [Sự kiện bảo mật] [Lịch sử gọi MCP] + [Nhật ký hành động] [Trạng thái Agent] + [Agents Admin] [Công cụ] [Icon] +👤 local · Provider ▾ ← gom Provider/Language/Theme/Settings""" + +NAV_PROBLEMS = [ + ("Accordion 2 cấp, không phẳng", + "app.py:170 ghi là \“Claude-style\” nhưng là QTreeWidget " + "accordion. Claude dùng danh sách phẳng."), + ("Mục tự biến mất", + "workspace_tab.py:485 ẩn Cowork và GraphRAG khi chưa chọn project."), + ("Tab strip bị ẩn", + "hide_tab_bar() ẩn dải tab có sẵn → menu là đường duy nhất."), + ("History chỉ có ở tab Cowork", + "workspace_tab.py:169. Điểm làm tốt phải giữ: lịch sử " + "đã gom theo project — lưu trong <project>/.cowork_history " + "(config.py:590), hiển thị gom nhóm ở sidebar.py:193."), + ("Monitoring gom 5 việc rời rạc", + "Chi phí · log bảo mật · quản trị agent · cấu hình tool · thư viện icon."), + ("Dashboard trùng Monitoring ▸ Tổng quan", "Cùng bộ StatCard + BudgetCard."), + ("Top bar giữ thiết lập", "Provider/Ngôn ngữ/Giao diện ở topbar, tách khỏi Cài đặt."), + ("Header không đổi theo màn", "Mọi sub-tab đều hiện \“Workspace — Projects\”."), + ("LỖI: bấm mục con Workspace, menu nhảy về mục cha", + "_goto gọi refresh() (app.py:726) → " + "subtabs_changed vô điều kiện (workspace_tab.py:497) → " + "takeChildren() (app.py:691) huỷ mục vừa bấm.
" + "Đo được: 5/5 mục Workspace mất highlight, 0/8 mục Monitoring bị."), +] + +CSS = """ +:root{--bg:#FFFFFF;--sf:#F8F8F8;--rz:#FFFFFF;--bd:#E5E5E5;--bds:#CECECE;--tx:#3B3B3B;--mut:#616161;--fnt:#6E6E6E;--ac:#005FB8;--nav:#F8F8F8;--navb:#E5E5E5;--navs:#E4E6F1;--ok:#317A2D;--warn:#8F6500;--bad:#CD3131;--r:4px} +@media(prefers-color-scheme:dark){:root{--bg:#1F1F1F;--sf:#252526;--rz:#313131;--bd:#2B2B2B;--bds:#3C3C3C;--tx:#CCCCCC;--mut:#9D9D9D;--fnt:#9A9A9A;--ac:#4DAAFC;--nav:#181818;--navb:#2B2B2B;--navs:#04395E;--ok:#89D185;--warn:#CCA700;--bad:#F76464;--r:4px}} +:root[data-theme=dark]{--bg:#1F1F1F;--sf:#252526;--rz:#313131;--bd:#2B2B2B;--bds:#3C3C3C;--tx:#CCCCCC;--mut:#9D9D9D;--fnt:#9A9A9A;--ac:#4DAAFC;--nav:#181818;--navb:#2B2B2B;--navs:#04395E;--ok:#89D185;--warn:#CCA700;--bad:#F76464;--r:4px} +:root[data-theme=light]{--bg:#FFFFFF;--sf:#F8F8F8;--rz:#FFFFFF;--bd:#E5E5E5;--bds:#CECECE;--tx:#3B3B3B;--mut:#616161;--fnt:#6E6E6E;--ac:#005FB8;--nav:#F8F8F8;--navb:#E5E5E5;--navs:#E4E6F1;--ok:#317A2D;--warn:#8F6500;--bad:#CD3131;--r:4px} +*{box-sizing:border-box} +body{margin:0;background:var(--bg);color:var(--tx); +font:14px/1.55 "Segoe UI Variable Text","Segoe UI",system-ui,sans-serif} +.wrap{max-width:1180px;margin:0 auto;padding:0 24px 64px} +header{padding:34px 0 16px;border-bottom:1px solid var(--bd);margin-bottom:20px} +h1{font-size:25px;margin:0 0 8px;letter-spacing:-.02em} +h2{font-size:19px;margin:38px 0 6px;padding-top:16px;border-top:1px solid var(--bd)} +h3{font-size:15px;margin:20px 0 6px} +p{margin:8px 0}.mut{color:var(--mut)}.fnt{color:var(--fnt)} +code{font:13px "Cascadia Code",Consolas,monospace;background:var(--sf); +border:1px solid var(--bd);border-radius:4px;padding:1px 5px} +.note{background:var(--sf);border:1px solid var(--bd);border-left:3px solid var(--ac); +border-radius:var(--r);padding:9px 13px;margin:10px 0;font-size:13px} +.note.warn{border-left-color:var(--warn)}.note.bad{border-left-color:var(--bad)} +.toc{background:var(--sf);border:1px solid var(--bd);border-radius:var(--r);padding:18px 22px} +.toc ol{margin:6px 0;padding-left:22px;columns:2;column-gap:36px} +.toc a{color:var(--tx);text-decoration:none}.toc a:hover{color:var(--ac);text-decoration:underline} +.sec{border:1px solid var(--bd);border-radius:var(--r);margin:16px 0;overflow:hidden;background:var(--sf)} +.sec>.hd{padding:10px 16px;border-bottom:1px solid var(--bd);display:flex; +align-items:baseline;gap:10px;flex-wrap:wrap} +.sec>.hd b{font-size:15px}.sec>.bd{padding:14px 16px} +.tag{font-size:12px;padding:2px 8px;border-radius:4px;background:var(--bg); +border:1px solid var(--bd);color:var(--mut)} +.cap{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; +color:var(--fnt);margin:12px 0 5px} +.shot{border:1px solid var(--bd);border-radius:var(--r);overflow:hidden;background:var(--bg)} +.shot img{display:block;width:100%;height:auto} +.miss{padding:26px;text-align:center;color:var(--bad);background:var(--bg); +border:1px dashed var(--bad);border-radius:var(--r);font-size:14px} +ul.pr{margin:4px 0;padding-left:18px;font-size:13px}ul.pr li{margin:3px 0} +.lead{font-size:14px;color:var(--tx);margin:0 0 6px;max-width:92ch} +p.rg{font-size:12.5px;color:var(--mut);margin:6px 0 0;line-height:1.55} +p.rg b{color:var(--tx)} +.cols{display:grid;grid-template-columns:1fr 1fr;gap:20px} +.cols.pc{gap:16px;margin-top:10px}.cols.pc .cap{margin:0 0 4px} +details.ctl{margin-top:12px;border:1px solid var(--bd);border-radius:var(--r); +background:var(--bg)} +details.ctl summary{padding:7px 12px;cursor:pointer;font-size:12.5px;color:var(--tx)} +details.ctl[open] summary{border-bottom:1px solid var(--bd)} +details.ctl table{margin:0;font-size:12px} +details.ctl td,details.ctl th{padding:4px 10px} +td.mv{color:var(--ac)} +@media(max-width:900px){.cols{grid-template-columns:1fr}.toc ol{columns:1}} +pre.tree{background:var(--bg);border:1px solid var(--bd);border-radius:var(--r); +padding:16px;font:12.5px/1.65 "Cascadia Code",Consolas,monospace;overflow-x:auto;margin:0} +table{width:100%;border-collapse:collapse;margin:10px 0;font-size:13px} +th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--bd);vertical-align:top} +th{color:var(--mut);font-size:12px;text-transform:uppercase;letter-spacing:.05em} +/* ---- wireframe vocabulary ---- */ +.wf{display:flex;height:570px;border:1px solid var(--bds);border-radius:var(--r); +overflow:hidden;background:var(--bg);font-size:11px;position:relative} +/* The rail must never crop: its bottom group is real navigation. */ +.wf .rail{overflow:visible} +.wf .rail{width:150px;flex:none;background:var(--nav);border-right:1px solid var(--navb); +padding:8px;display:flex;flex-direction:column;gap:3px} +.wf .newbtn{background:var(--ac);color:#fff;border-radius:4px;padding:6px;text-align:center; +font-weight:600} +/* flex:none — inside the column rail this otherwise collapses to a sliver. */ +.wf .rpick{flex:none;background:var(--rz);border:1px solid var(--bds);border-radius:4px; +padding:5px 7px;margin-bottom:5px;font-weight:600;display:flex;align-items:center; +justify-content:space-between;gap:4px;line-height:1.3} +.wf .rpick>span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.wf .rpick .cv{color:var(--mut);font-weight:400;flex:none} +.wf .rpick.empty{color:var(--fnt);font-weight:400;font-style:italic} +.wf .i.off,.wf .newbtn.off{opacity:.45} +.wf .newbtn.off{background:var(--bds);color:var(--tx)} +.wf .hint{font-size:9px;color:var(--fnt);text-align:center;padding:2px 0 6px;font-style:italic} +.wf .newbtn{flex:none} +.wf .i{padding:5px 7px;border-radius:4px;color:var(--tx)} +.wf .i.on{background:var(--navs);border-left:2px solid var(--ac);font-weight:600} +.wf .i.sm{color:var(--mut);font-size:10px;padding:3px 7px} +.wf .hd{font-size:9px;letter-spacing:.1em;color:var(--fnt);padding:4px 7px;font-weight:700} +.wf .scope{font-size:10px;font-weight:600;color:var(--tx);padding:2px 7px 4px} +.wf .i.allp{color:var(--ac);font-style:italic} +.wf .sep{height:1px;background:var(--navb);margin:5px 0} +.wf .acct{margin-top:6px;padding:6px 7px;border-top:1px solid var(--navb); +color:var(--mut);font-size:10px;display:flex;align-items:center;gap:5px} +.wf .acct .lang{margin-left:auto;background:var(--rz);border:1px solid var(--bds); +border-radius:3px;padding:1px 5px;color:var(--tx);font-weight:600} +.wf .acct .thm{font-size:11px} +.wf .main,.wf .c{display:flex;flex-direction:column;gap:6px;padding:10px;flex:1;min-width:0} +.wf .main.dlg{border:none} +.wf .ttl{font-weight:700;font-size:13px;white-space:nowrap} +/* Rows STRETCH by default so nested columns fill the height — `.tb` opts a + toolbar row back into vertical centring. Getting this backwards is what + collapsed every wireframe into a thin strip floating mid-panel. */ +.wf .r{display:flex;gap:6px;align-items:stretch;min-height:0} +.wf .r.tb{align-items:center;flex:none} +.wf .r.end{justify-content:flex-end} +.wf .grow{flex:1;min-height:0} +.wf .b{background:var(--sf);border:1px solid var(--bd);border-radius:4px;padding:6px 8px;min-height:22px} +.wf .b.tall{min-height:70px} +/* nowrap: a wrapped node graph reads as a broken diagram, not a flow */ +.wf .b.canvas{display:flex;align-items:center;justify-content:center;gap:5px; +background:var(--rz);flex-wrap:nowrap;padding:12px;overflow:hidden} +.wf .b.code{background:var(--rz);font-family:"Cascadia Code",Consolas,monospace; +font-size:10px;line-height:1.7;overflow:hidden} +.wf .b.chart{background:var(--rz);padding:8px;color:var(--ac);min-height:70px} +.wf .b.chart svg{width:100%;height:100%;display:block} +.wf .b.hero,.wf .b.kpi{display:flex;flex-direction:column;justify-content:center; +font-weight:700} +.wf .b.hero{flex:none;width:130px;font-size:20px;align-items:center;text-align:center} +.wf .b.kpi{flex:1;font-size:14px} +.wf .s{display:block;font-size:9.5px;font-weight:400;color:var(--fnt);margin-top:2px} +.wf .lbl{color:var(--mut);font-size:10px} +.wf .hd2{font-size:9px;letter-spacing:.09em;color:var(--fnt);font-weight:700; +margin-top:2px} +.wf .hd2.row{display:flex;align-items:center;justify-content:space-between} +.wf .pchev{color:var(--mut);font-size:12px;font-weight:400} +.wf .ghd{display:flex;align-items:center;gap:6px} +.wf .gact{color:var(--ac);font-weight:600;font-size:9.5px;letter-spacing:0} +.wf .b.tbl{padding:0;overflow:hidden} +.wf .tr{display:flex;padding:3px 8px;border-bottom:1px solid var(--bd);gap:6px} +.wf .tr:last-child{border-bottom:none} +.wf .tr span{flex:1}.wf .tr span:first-child{flex:2.4} +.wf .tr.th{color:var(--fnt);font-size:9px;letter-spacing:.05em;font-weight:700} +.wf .ctr2{display:flex;align-items:center;justify-content:center} +.wf .edge{background:var(--sf);border:1px solid var(--bds);border-right:none; +border-radius:5px 0 0 5px;padding:14px 5px;color:var(--mut)} +.wf .edge.wide{padding:14px 10px;font-weight:700;color:var(--tx)} +.wf .mnu{align-self:flex-end;background:var(--rz);border:1px solid var(--bds); +border-radius:5px;padding:3px;min-width:52%;box-shadow:0 3px 10px rgba(16,32,64,.14)} +.wf .mi{padding:4px 8px;border-radius:3px} +.wf .mi:nth-child(2){background:var(--navs);font-weight:600} +/* Sparkle badge — the teal-tinted "AI" chip, same convention as the app's + existing ✨ AI buttons in Folder and Schedule. */ +/* The dock is anchored to the window corner — its position is unchanged. */ +.wf .main.anchor{position:relative} +.wf .dock{position:absolute;right:10px;bottom:10px} +.wf .dock.badgewrap{display:flex;align-items:center;gap:3px} +.wf .dock .badge,.wf .dock.badge{background:#E6F6F4;border:1px solid #7FD0C4; +border-radius:6px;padding:7px 11px;font-weight:700;color:#0F6E62;white-space:nowrap} +/* "Ẩn vào cạnh phải" — a real button in the app, next to the launcher. */ +.wf .dock .chv{background:var(--sf);border:1px solid var(--bds);border-radius:4px; +padding:6px 3px;color:var(--mut);font-size:11px;line-height:1} +/* The proposed dot: 26px, no label, no neighbouring chevron. Drawn at the same + scale as the wireframe around it so the size claim is visible, not asserted. */ +.wf .dock.fab,.wf .fab{width:26px;height:26px;border-radius:50%;background:#E6F6F4; +border:1px solid #7FD0C4;display:flex;align-items:center;justify-content:center; +font-size:12px;box-shadow:0 2px 6px rgba(16,32,64,.14)} +/* Hover / keyboard focus only — the label is never on screen at rest. */ +.wf .fabpill{display:inline-flex;align-items:center;gap:5px;background:#E6F6F4; +border:1px solid #7FD0C4;border-radius:13px;padding:4px 11px 4px 5px; +font-weight:700;color:#0F6E62;white-space:nowrap;box-shadow:0 2px 6px rgba(16,32,64,.14)} +.wf .fabpill .fab{box-shadow:none;width:18px;height:18px;font-size:10px} +/* Old vs new footprint, drawn to scale beside each other. */ +.wf .oldbox{width:42px;height:32px;border:1px dashed var(--bds);border-radius:4px; +display:flex;align-items:center;justify-content:center;color:var(--fnt);font-size:9px} +.wf .dock.pnl{width:74%;height:76%;background:var(--sf);border:1px solid var(--bds); +border-radius:6px;box-shadow:0 3px 10px rgba(16,32,64,.13)} +.wf .phdr{border-bottom:1px solid var(--bd);padding-bottom:5px;gap:5px} +.wf .spark{color:#0F9B8A} +.wf .pnl{display:flex;flex-direction:column;gap:5px;padding:8px} +/* The rail's own MENU collapse control (150px <-> 54px in the app). */ +.wf .menutog{display:flex;align-items:center;justify-content:space-between; +color:var(--fnt);font-size:9px;letter-spacing:.1em;font-weight:700;padding:2px 6px 6px} +.wf .menutog .chev{font-size:13px;font-weight:400} +.wf .rail.narrow{width:46px;align-items:center} +.wf .rail.narrow .menutog{justify-content:center;padding:2px 0 6px} +.wf .newbtn.ic,.wf .i.ic,.wf .acct.ic{text-align:center;padding:5px 0;width:100%} +.wf .acct.ic{border-top:1px solid var(--navb)} +.wf .w22{flex:none;width:22%} +.wf .inp{background:var(--rz);border:1px solid var(--bds);border-radius:4px; +padding:6px 8px;color:var(--tx);min-height:26px;overflow:hidden} +.wf .inp.tall{min-height:52px} +.wf .btn{background:var(--rz);border:1px solid var(--bds);border-radius:4px; +padding:4px 9px;white-space:nowrap;align-self:center} +.wf .btn.pri{background:var(--ac);color:#fff;border-color:transparent;font-weight:600} +.wf .tab{padding:4px 10px;border-bottom:2px solid transparent;color:var(--mut);white-space:nowrap} +.wf .tab.on{border-bottom-color:var(--ac);color:var(--tx);font-weight:600} +.wf .stat{border-top:1px solid var(--bd);padding-top:5px;color:var(--fnt); +font-size:10px;flex:none} +/* Shared project picker — same spot on every project-scoped screen. */ +.wf .r.pb{border-bottom:1px solid var(--bd);padding-bottom:7px;margin-bottom:2px} +.wf .pick{background:var(--navs);border:1px solid var(--navb);border-radius:4px; +padding:4px 10px;font-weight:600;color:var(--tx)} +.wf .pane{background:var(--sf);border:1px solid var(--bd);border-radius:4px;gap:3px} +.wf .lane{background:var(--sf);border:1px solid var(--bd);border-radius:4px;padding:6px;gap:5px} +.wf .lane.warn{border-color:var(--warn)} +/* 7 lanes must fit without horizontal scrolling — that is the whole point. */ +.wf .k7{gap:4px} +.wf .k7 .lane{padding:5px;gap:4px;min-width:0} +.wf .k7 .card{font-size:10px;padding:4px 6px} +.wf .k7 .hd2{font-size:8.5px} +.tag.ok{color:var(--ok);border-color:var(--ok)} +b.ok,span.ok{color:var(--ok)}b.bad,span.bad{color:var(--bad)} +td.ok{color:var(--ok);white-space:nowrap} +tr.grp td{background:var(--sf);font-weight:700;font-size:13px; +border-bottom:1px solid var(--bds);padding-top:14px} +.wf .li{padding:4px 7px;border-radius:4px;color:var(--tx);line-height:1.35} +.wf .li.on{background:var(--navs);font-weight:600} +.wf .card{background:var(--rz);border:1px solid var(--bd);border-radius:4px; +padding:5px 7px;line-height:1.35} +.wf .chat{gap:6px;padding:0} +.wf .msg{border-radius:5px;padding:6px 9px;line-height:1.45;max-width:88%} +.wf .msg.u{background:var(--navs);align-self:flex-end} +.wf .msg.a{background:var(--sf);border:1px solid var(--bd)} +.wf .node{background:var(--sf);border:1px solid var(--bds);border-radius:4px; +padding:6px 8px;white-space:nowrap;font-size:10px;flex:none} +.wf .node.ok{border-color:var(--ok);color:var(--ok)} +.wf .node.run{border-color:var(--ac);color:var(--ac);font-weight:600} +.wf .arw{color:var(--fnt)} +.wf .diff{font-family:"Cascadia Code",Consolas,monospace;font-size:9.5px; +line-height:1.7;background:var(--rz);border-radius:4px;padding:5px 7px} +.wf .add{color:var(--ok)}.wf .del{color:var(--bad)} +.wf .ok{color:var(--ok)}.wf .bad{color:var(--bad)} +.wf .cm{color:var(--fnt)}.wf .kw{color:var(--ac)}.wf .fn{color:var(--warn)} +.wf .w18{flex:none;width:18%}.wf .w24{flex:none;width:24%} +.wf .w26{flex:none;width:26%}.wf .w28{flex:none;width:28%}.wf .w32{flex:none;width:32%} +@media(max-width:760px){.wf{height:auto;flex-direction:column}.wf .rail{width:auto}} +.tgl{position:fixed;top:16px;right:16px;z-index:9;background:var(--sf);color:var(--tx); +border:1px solid var(--bds);border-radius:var(--r);padding:7px 13px;cursor:pointer;font-size:13px} +.sw{display:inline-flex;border:1px solid var(--bds);border-radius:4px;overflow:hidden;margin-left:auto} +.sw button{background:var(--bg);color:var(--mut);border:0;padding:3px 11px;cursor:pointer;font-size:12px} +.sw button.on{background:var(--ac);color:#fff;font-weight:600} +""" + +JS = """ +const root=document.documentElement; +function setTheme(t){root.setAttribute('data-theme',t); + document.getElementById('tgl').textContent=t==='dark'?'☀ Sáng':'🌙 Tối'; + document.querySelectorAll('.sec').forEach(s=>swap(s,t));} +function swap(sec,t){const i=sec.querySelector('img.shotimg');if(!i)return; + const src=t==='dark'?i.dataset.dark:i.dataset.light;if(src)i.src=src; + sec.querySelectorAll('.sw button').forEach(b=>b.classList.toggle('on',b.dataset.t===t));} +document.getElementById('tgl').onclick=()=>setTheme( + root.getAttribute('data-theme')==='dark'?'light':'dark'); +document.querySelectorAll('.sw button').forEach(b=>b.onclick=()=>{ + swap(b.closest('.sec'),b.dataset.t);}); +setTheme(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'); +""" + + +def esc(s: str) -> str: + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + +def load_controls() -> dict: + """file path -> {controls, menu_actions}, from tools/extract_controls.py.""" + src = DOCS / "screens" / "controls.json" + if not src.exists(): + return {} + return {f["file"]: f for f in json.loads(src.read_text(encoding="utf-8"))} + + +def controls_table(slug: str, index: dict) -> str: + """A collapsed, exhaustive control list for one screen.""" + files = SCREEN_FILES.get(slug) + if not files: + return "" + rows, n = [], 0 + for fname in files: + rec = index.get(fname) + if not rec: + continue + short = fname.split("\\\\")[-1] + for c in rec["controls"]: + label = c.get("label_vi") or c.get("label") or "—" + label = label.replace("tr(", "").strip("'\")")[:60] + act = "; ".join(h.split(" → ")[-1] for h in c["signals"])[:70] or "—" + dest = MOVES.get(c["var"], "giữ nguyên tại chỗ") + cls = "ok" if dest.startswith("giữ") else "mv" + rows.append(f'{esc(label)}{c["kind"]}' + f'{esc(act)}' + f'{short}:{c["line"]}' + f'{dest}') + n += 1 + for a in rec["menu_actions"]: + label = a.get("label_vi") or a.get("label") or "—" + label = label.replace("tr(", "").strip("'\")")[:60] + rows.append(f'{esc(label)}menu chuột phải' + f'—' + f'{short}:{a["line"]}' + f'giữ nguyên tại chỗ') + n += 1 + if not rows: + return "" + return (f'
Kiểm kê control — {n} mục ' + f'(trích bằng AST, không đọc tay)' + f'' + f'{"".join(rows)}
NhãnLoạiHàm xử lýNguồnSau khi sửa
') + + +def embed(rel: str) -> str: + """PNG at `rel` as a data: URI (standalone builds only).""" + raw = (DOCS / rel.split("?")[0]).read_bytes() + return "data:image/png;base64," + base64.b64encode(raw).decode("ascii") + + +def bust(rel: str) -> str: + """Append a content stamp to an image URL. + + Re-capturing overwrites the PNGs but keeps their names, so a browser (or the + editor's HTML preview) happily serves the previous render from cache and the + page looks stale even though it was just rebuilt. Stamping the URL with the + file's mtime forces a fetch whenever the picture actually changed. + """ + path = DOCS / rel + try: + return f"{rel}?v={int(path.stat().st_mtime)}" + except OSError: + return rel + + +def main() -> int: + stamp = datetime.now().strftime("%H:%M %d/%m/%Y") + cidx = load_controls() + recs = json.loads(MANIFEST.read_text(encoding="utf-8")) + by_slug: dict[str, dict] = {} + for r in recs: + by_slug.setdefault(r["slug"], {"title": r["title"], "note": r["note"], "shots": {}}) + by_slug[r["slug"]]["shots"][r["theme"]] = r + order = list(by_slug) + + toc = "".join( + f'
  • {esc(by_slug[s]["title"])}
  • ' for s in order) + + secs = [] + for n, slug in enumerate(order, 1): + info = by_slug[slug] + a = ANALYSIS.get(slug, GENERIC) + dark, light = info["shots"].get("dark"), info["shots"].get("light") + + if dark and dark["file"]: + src = embed if STANDALONE else bust + d_url, l_url = src(dark["file"]), src((light or dark)["file"]) + shot = (f'
    ') + sw = ('' + '') + else: + err = esc((dark or {}).get("error", "không rõ")) + shot = f'
    Không chụp được màn này
    {err}
    ' + sw = "" + + # The dock floats over the MAIN WINDOW. Modal dialogs cover it, so they + # get none; section 27 draws its own (badge + open panel). + dock = "" if (slug.startswith("dialog-") + or slug == "overlay-help-panel") else DOCK_BADGE + wf = (f'
    Đề xuất — bố cục mới
    ' + f'
    {a["wf"]}{dock}
    ' + if a["wf"] else + '
    Đề xuất — bố cục mới
    ' + '

    Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

    ') + + # What the screen is, plus a legend for the regions visible in the shot. + de = DESCRIPTIONS.get(slug) + intro = f'

    {de["d"]}

    ' if de else "" + legend = "" + if de and de.get("r"): + bits = " · ".join( + f'{lab}{": " + txt if txt else ""}' for lab, txt in de["r"]) + legend = f'

    {bits}

    ' + + # Eight sections were written by hand (tools/audit_handwritten.py). Use + # that markup verbatim, but keep the screenshot and the AST control + # inventory generated so neither goes stale. + hand = HAND_SECTIONS.get(slug) + if hand: + body = (hand.replace("{{SHOT}}", shot) + .replace("{{CONTROLS}}", controls_table(slug, cidx))) + else: + body = f"""
    +{intro} +
    Hiện tại
    {shot} +{legend} +{wf} +{controls_table(slug, cidx)} +
    +
    Vấn đề
      {''.join(f'
    • {p}
    • ' for p in a['problems'])}
    +
    Thay đổi
      {''.join(f'
    • {c}
    • ' for c in a['changes'])}
    +
    """ + + secs.append(f"""
    +
    {n}. {esc(info['title'])}{esc(info['note'])}{sw}
    +{body}
    """) + + flows = "".join( + f'{t}{b}{af}' + for t, b, af in FLOWS) + navp = "".join(f'{t}{d}' for t, d in NAV_PROBLEMS) + tabs = "".join( + f'{g}{n}{items}' + f'{seen}{src}' + for g, n, items, seen, src in TAB_GROUPS) + newchat = "".join( + f'{a}{o}{n}{v}' + for a, o, n, v in NEWCHAT) + later = "".join(f'{n}{d}' + f'{k}' for n, d, k in LATER) + rail_has = rail("Cowork") + ('
    Cowork
    ' + '
    ') + rail_none = rail("Project", empty=True) + ( + '
    Quản lý project
    ' + '
    Chưa có project — bấm “+ Project mới”' + '
    ') + shell_ctl = controls_table("__shell__", cidx) + colls = "".join( + f'{n}{how}{src}' + f'{keep}' for n, how, src, keep in COLLAPSIBLES) + rail_open = rail("Cowork") + ('
    Nội dung
    ' + '
    ') + rail_shut = rail_collapsed() + ('
    Nội dung
    ' + '
    ') + mapping = "".join( + (f'{name}' if kind == "nhóm" + else f'{name}{old}{new}' + f'{keep}') + for kind, name, old, new, keep in MAPPING) + dead = "".join(f'{n}{f}{d}' + for n, f, d in DEAD) + + html = f""" + +CoworkLocal — Audit UI/UX + +
    +
    +

    CoworkLocal — Audit UI/UX

    +

    Hiện trạng {len(order)} màn hình · đề xuất sắp xếp lại theo UI/UX kiểu Claude · +bảng màu Visual Studio Code · dựng lúc {stamp}

    +
    Ràng buộc: chỉ sắp xếp lại, không xoá/thêm chức năng. +Hai chỗ lệch được đánh dấu ở Phần 1.
    +
    Ảnh chụp: render offscreen trên bản sao dữ liệu +(scheduler tắt, hash dữ liệu thật trước/sau giống hệt). Nạp dữ liệu mẫu: 3 project · +6 chat · 10 task · 3 workflow · 45 ngày token. Ảnh đã chỉnh menu sáng đúng mục — +xem lỗi #9.
    +
    + +
    Mục lục +

    Phần 1 — Điều hướng · Phần 2 — Luồng người dùng · +Phần 3 — {len(order)} màn hình · Phần 4 — Màn chết

    +
      {toc}
    + +

    Phần 1 — Điều hướng

    +
    +
    Hiện tại
    {esc(NAV_BEFORE)}
    +
    Đề xuất
    {esc(NAV_AFTER)}
    +
    +

    Chín điểm đã xác minh trong code

    +{navp}
    Vấn đềChi tiết
    + +

    Khung ứng dụng — thanh trên cùng & thanh menu

    +

    Không thuộc màn nào nên liệt kê riêng.

    +{shell_ctl} + +

    Các nút thu gọn / mở rộng — giữ nguyên toàn bộ

    +

    App có 11 chỗ gập được. Thiết kế mới giữ đủ cả 11.

    +
    +
    Menu mở rộng
    {rail_open}
    +
    Menu đã gập (54px, chỉ còn icon)
    +
    {rail_shut}
    +
    + +{colls}
    Chỗ gập đượcCách dùngNguồnThiết kế mới
    + +

    Từng màn hình đi đâu sau khi sửa

    +

    Không màn nào bị bỏ. Giám sát (Dashboard + 8 màn Monitoring) ở nhóm +phụ trợ đáy sidebar, mở ra thấy đủ 8 tab.

    + +{mapping}
    Màn hìnhHiện tại ở đâuSau khi sửa ở đâuChức năng
    +
    Dashboard/Monitoring xuống đáy nhưng vẫn là mục cấp 1, vẫn +một cú nhấp. Sidebar chia theo tần suất: việc hằng ngày ở trên, quản trị ở dưới.
    + +

    Toàn bộ nhóm tab / lane / chế độ trong app

    +

    Đầy đủ, kể cả nhóm không hiện ra màn hình.

    + +{tabs}
    NhómSLCác mụcNhìn thấy?Nguồn
    +
    14 nhóm tab/lane, chỉ 4 nhóm hiện ra màn hình. +Số còn lại bị ẩn, thành combo, thành nút, hoặc không tới được.
    +
    Hai chỗ lệch — cần bạn quyết: +(1) Cowork/GraphRAG: biến mất → hiện nhưng mờ. +(2) Bỏ ẩn tab strip Monitoring (khôi phục thứ đã có).
    + +

    Phần 2 — Luồng người dùng

    +{flows}
    LuồngHiện tạiSau khi sửa
    + +

    Truy vết chi tiết: “Đoạn chat mới”

    + +{newchat}
    Khía cạnhGiao diện cũGiao diện mớiCó đồng bộ không
    +
    +
    Có project
    {rail_has}
    +
    Chưa có project nào
    {rail_none}
    +
    +
    Khi chưa có project (đã chạy thử app với 0 project): +hiện nay Cowork và GraphRAG biến mất khỏi menu nên không chat được, mà không nói vì sao. +Thiết kế mới giữ nguyên cổng chặn đó — vẫn không tạo chat được — nhưng hai mục vẫn nằm +đúng chỗ, chỉ mờ đi; droplist ghi “Chưa có project”; nút chat mới bị khoá kèm lý do +“Tạo project trước”.
    +Ghi nhận thêm: lúc đó ctx.active_project_id vẫn giữ +'default' — trỏ vào một project không tồn tại. Và +projects.ensure_starter_project() (“đảm bảo luôn có ít nhất một project”) +không nơi nào gọi.
    + +
    Phát hiện: sidebar.py:68 khai báo tín hiệu +new_chat và workspace_tab.py:241 đã nối nó vào +_on_sidebar_new — nhưng không nơi nào phát tín hiệu này +(grep new_chat.emit → rỗng). Tức pane Lịch sử vốn được thiết kế để có nút +“chat mới” nhưng nút đó chưa bao giờ được thêm. Đề xuất đưa nút lên sidebar chính là +hoàn thiện ý định sẵn có trong code, không phải thêm mới.
    + +

    Phần 3 — Từng màn hình

    +{''.join(secs)} + +

    Phần 4 — Phát triển lần sau

    +

    Những việc audit này phát hiện nhưng cố ý không làm, vì đều thêm +hoặc đổi chức năng — ngoài phạm vi “chỉ sắp xếp lại”.

    + +{later}
    ViệcChi tiếtLoại
    + +

    Phần 5 — Màn chết (chỉ ghi nhận)

    +

    Sáu màn có trong code nhưng không tới được — tổng 64 control +(accounts 18 · flow_dialog 28 · agent_manager 7 · skill_manager 7 · mcp_servers 4). +Không nằm trong kiểm kê phía trên vì không có đường nào tới; chỉ ghi nhận, không đề xuất gỡ.

    +{dead}
    Thành phầnVị tríTình trạng
    +
    Ngoài phạm vi: settings_dialog.py:115 hard-code mật khẩu +Sandbox; hai lớp cùng tên CustomAgent +(custom_agents.py:23 · co4e.py:117).
    +
    +{"".join(f"" for j in HAND_JS)} +""" + + dest = OUT + dest.write_text(html, encoding="utf-8") + missing = [r["slug"] for r in recs if not r["file"]] + size = dest.stat().st_size + unit = f"{size / 1024 / 1024:.1f} MB" if size > 1_000_000 else f"{size // 1024} KB" + kind = ("MOT FILE DUY NHAT — anh da nhung san" if STANDALONE + else "CAN kem thu muc screens/ moi co hinh") + print(f"wrote {dest} ({len(order)} screens, {unit}) — {kind}") + if missing: + print(f"placeholders for: {sorted(set(missing))}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/capture_screens.py b/tools/capture_screens.py new file mode 100644 index 0000000..b7c2a53 --- /dev/null +++ b/tools/capture_screens.py @@ -0,0 +1,366 @@ +"""Capture every CoworkLocal screen to PNG, offscreen, for the UI audit page. + +Run: python tools/capture_screens.py + +Two safety measures, both mandatory — this script drives the REAL application: + +1. **Data isolation.** ``config.CONFIG_DIR`` is ``Path.home() / ".cowork_local"``, a + module-level constant resolved at import time. We copy that folder to a temp + directory and repoint ``USERPROFILE``/``HOME`` at it *before* importing + ``cowork_local``, so every write the app makes lands in the copy. The user's + real data is never opened for writing. + +2. **Schedulers disabled.** ``MainWindow.__init__`` starts ``TaskScheduler`` and + ``RoutingScheduler``, which would *execute the user's scheduled tasks* — real + agent turns writing real files. Both ``start`` methods are patched to no-ops + before the window is built. + +We also construct ``MainWindow`` directly rather than calling ``app.run()``: +``run()`` seeds built-in skills/flows and calls ``ctx.config.save()``. + +Screens that fail to render (QtWebEngine generally cannot initialise offscreen) +are recorded in the manifest with their error. They are never silently skipped — +the audit page renders an explicit "could not capture" placeholder for them. +""" +from __future__ import annotations + +import json +import os +import shutil +import sys +import tempfile +import traceback +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent # …/cowork_local +OUT_DIR = REPO / "docs" / "screens" +THEMES = ("dark", "light") + + +def _isolate_home() -> Path: + """Copy the real config dir into a temp HOME and repoint the env at it.""" + real = Path.home() / ".cowork_local" + sandbox = Path(tempfile.mkdtemp(prefix="cowork-capture-")) + if real.exists(): + shutil.copytree(real, sandbox / ".cowork_local", dirs_exist_ok=True) + else: + (sandbox / ".cowork_local").mkdir(parents=True, exist_ok=True) + for var in ("USERPROFILE", "HOME"): + os.environ[var] = str(sandbox) + os.environ.pop("HOMEDRIVE", None) + os.environ.pop("HOMEPATH", None) + return sandbox + + +def _load_fonts() -> int: + """Register system fonts with the offscreen platform. + + The offscreen plugin ships with NO font database (``QFontDatabase.families()`` + returns an empty list), so every glyph renders as a tofu box — unusable when + the screenshots are the deliverable. Loading the real Windows faces fixes + both Latin and Vietnamese diacritics, and Consolas covers the code views. + """ + from PySide6.QtGui import QFontDatabase + + wanted = [ + "SegUIVar.ttf", "segoeui.ttf", "segoeuib.ttf", "segoeuii.ttf", + "seguisb.ttf", "consola.ttf", "consolab.ttf", "arial.ttf", + ] + root = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "Fonts" + loaded = 0 + for name in wanted: + path = root / name + if path.exists() and QFontDatabase.addApplicationFont(str(path)) != -1: + loaded += 1 + return loaded + + +def _apply_theme(app, name: str | None = None) -> str: + """Load the app's real stylesheet onto `app`. + + `MainWindow` does not style itself — `run()` calls `app.setStyleSheet` — so a + checker that builds the window directly measures a window with no padding, + no margins and no borders. Every QSS-driven layout bug is invisible there. + """ + from cowork_local import theme + from cowork_local.config import AppConfig + + name = name or AppConfig.load().theme + theme.set_active_theme(name) + app.setStyleSheet(theme.stylesheet(name)) + return name + + +def _freeze_schedulers() -> None: + """No-op the background engines so nothing is executed while we capture.""" + from cowork_local.core.task_scheduler import TaskScheduler + TaskScheduler.start = lambda self: None # type: ignore[assignment] + try: + from cowork_local.core.routing.scheduler import RoutingScheduler + RoutingScheduler.start = lambda self: None # type: ignore[assignment] + except Exception: + pass + + +def main() -> int: + os.environ["QT_QPA_PLATFORM"] = "offscreen" + sandbox = _isolate_home() + sys.path.insert(0, str(REPO.parent)) # so `import cowork_local` works + sys.path.insert(0, str(Path(__file__).resolve().parent)) # sibling: seed_demo_data + OUT_DIR.mkdir(parents=True, exist_ok=True) + + from PySide6.QtCore import Qt + from PySide6.QtWidgets import QApplication + app = QApplication([]) + + n_fonts = _load_fonts() + print(f"[fonts] registered {n_fonts} face(s) with the offscreen platform") + if not n_fonts: + print(" WARNING: no fonts loaded — every screenshot will render as tofu boxes") + + _freeze_schedulers() + + import cowork_local.theme as theme + from cowork_local.config import AppConfig, CONFIG_DIR + from cowork_local.i18n import set_language, tr + from cowork_local.state import AppContext + + assert str(sandbox) in str(CONFIG_DIR), ( + f"isolation failed: CONFIG_DIR={CONFIG_DIR} is not inside {sandbox}") + print(f"[isolated] CONFIG_DIR -> {CONFIG_DIR}") + + # Fill the sandbox with demo data so the screenshots show a working app. + # Safe by construction: seed() re-asserts it is inside a capture sandbox. + from seed_demo_data import seed + counts = seed() + print("[seeded] " + " · ".join(f"{k}={v}" for k, v in counts.items())) + + from cowork_local.app import MainWindow + + cfg = AppConfig.load() + set_language("vi") + ctx = AppContext(cfg) + + manifest: list[dict] = [] + # Label of the nav row selected right now, recorded into every shot so the + # "is the rail pointing at the right thing?" question is machine-checked + # instead of eyeballed across 54 images. + nav_state = {"label": "", "expected": ""} + + def nav_to(win, page: int, sub=None, expect: str = "") -> None: + """Navigate the way a user does, and record where the rail ends up. + + Since the rail became a flat list, ``_goto`` moves the highlight itself + (``_select_nav_row``), so this no longer needs the two-step workaround + that existed while selecting a Workspace child destroyed the row being + selected. + """ + win._ensure_page(page) + win._goto(page, sub) + app.processEvents() + app.processEvents() + cur = next((t.currentItem() for t in (win.nav, win.nav_bottom) + if t.currentItem() is not None and t.currentItem().isSelected()), + None) + nav_state["label"] = cur.text(0) if cur is not None else "" + nav_state["expected"] = expect or nav_state["label"] + + def shot(widget, slug: str, title: str, note: str = "") -> None: + """Grab `widget` for the active theme; record success or the error.""" + rec = {"slug": slug, "title": title, "theme": theme.current_theme(), + "note": note, "file": "", "error": "", + "nav": nav_state["label"], "nav_expected": nav_state["expected"]} + try: + app.processEvents() + app.processEvents() + pm = widget.grab() + if pm.isNull() or pm.width() < 2: + raise RuntimeError("grab() returned an empty pixmap") + name = f"{slug}-{theme.current_theme()}.png" + pm.save(str(OUT_DIR / name)) + rec["file"] = f"screens/{name}" + print(f" ok {name} ({pm.width()}x{pm.height()})") + except Exception as exc: # noqa: BLE001 + rec["error"] = f"{type(exc).__name__}: {exc}" + print(f" FAIL {slug}: {rec['error']}") + manifest.append(rec) + + for th in THEMES: + print(f"\n=== theme: {th} ===") + ctx.config.theme = th + theme.set_active_theme(th) + app.setStyleSheet(theme.stylesheet(th)) + + win = MainWindow(ctx, user_name="local") + win.resize(1600, 1000) + win.show() + app.processEvents() + + # ---- main screens, driven through the app's own navigation API ------ + ROW_DASH, ROW_SCHED, ROW_WS, ROW_MON = 0, 1, 2, 3 + + nav_to(win, ROW_DASH, None, expect=tr("app.tab.dashboard")) + shot(win, "dashboard", "Dashboard", "ui/dashboard_tab.py:35") + + nav_to(win, ROW_SCHED, None, expect=tr("app.tab.schedule")) + sched = win._page_widgets[ROW_SCHED] + shot(win, "schedule-kanban", "Schedule Task — Kanban", "ui/schedule_task_tab.py:70") + try: # combo index 1 == Calendar view + sched.view_combo.setCurrentIndex(1) + app.processEvents() + shot(win, "schedule-calendar", "Schedule Task — Calendar", "ui/calendar_view.py:88") + sched.view_combo.setCurrentIndex(0) + except Exception as exc: # noqa: BLE001 + manifest.append({"slug": "schedule-calendar", "title": "Schedule Task — Calendar", + "theme": th, "note": "ui/calendar_view.py:88", "file": "", + "error": f"{type(exc).__name__}: {exc}"}) + print(f" FAIL schedule-calendar: {exc}") + + # Workspace: capture with no project selected, then with one selected so + # the project-gated sub-tabs (Cowork, GraphRAG) actually exist. + nav_to(win, ROW_WS, None, expect=tr("app.tab.workspace")) + ws = win.workspace + shot(win, "workspace-project", "Workspace ▸ Project", "ui/workspace_tab.py:188") + try: + if ws.project_list.count(): + ws.project_list.setCurrentRow(0) + app.processEvents() + except Exception: # noqa: BLE001 + pass + + for attr, slug, title, note in ( + ("_cowork_tab_idx", "workspace-cowork", "Workspace ▸ Cowork", "ui/cowork_tab.py:21"), + ("_co4e_tab_idx", "workspace-co4e", "Workspace ▸ Co4E", "ui/co4e_tab.py:228"), + ("_folder_tab_idx", "workspace-folder", "Workspace ▸ Folder", "ui/folder_tab.py:238"), + ("_graphrag_tab_idx", "workspace-graphrag", "Workspace ▸ GraphRAG", "ui/structure_graph_view.py:188"), + ): + idx = getattr(ws, attr, None) + if idx is None: + manifest.append({"slug": slug, "title": title, "theme": th, "note": note, + "file": "", "error": "sub-tab index not present"}) + continue + nav_to(win, ROW_WS, idx, expect=ws.tabs.tabText(idx)) + shot(win, slug, title, note) + + # Monitoring: enumerate its sub-tabs from the app itself. + nav_to(win, ROW_MON, None, expect=tr("app.tab.monitoring")) + mon = win._page_widgets[ROW_MON] + try: + subs = mon.nav_subtabs() + except Exception as exc: # noqa: BLE001 + subs = [] + print(f" FAIL monitoring subtabs: {exc}") + for label, sub, _icon in subs: + nav_to(win, ROW_MON, sub, expect=label) + slug = "monitoring-" + "".join( + c.lower() if c.isalnum() else "-" for c in label).strip("-") + shot(win, slug, f"Monitoring ▸ {label}", "ui/monitoring_tab.py:132") + + # Dialogs/overlays below are not nav destinations. + nav_state["label"] = nav_state["expected"] = "" + + # ---- dialogs: built directly and shown (never exec(), it blocks) ----- + for slug, title, note, build in _dialog_specs(ctx, win): + try: + dlg = build() + dlg.show() + app.processEvents() + shot(dlg, slug, title, note) + dlg.close() + except Exception as exc: # noqa: BLE001 + manifest.append({"slug": slug, "title": title, "theme": th, "note": note, + "file": "", "error": f"{type(exc).__name__}: {exc}"}) + print(f" FAIL {slug}: {type(exc).__name__}: {exc}") + + # ---- overlays -------------------------------------------------------- + try: + help_dock = win.help_agent + help_dock._expand() + app.processEvents() + shot(help_dock, "overlay-help-panel", "Help dock — expanded panel", + "ui/help_agent_widget.py:79") + except Exception as exc: # noqa: BLE001 + print(f" FAIL overlay-help-panel: {exc}") + + win.close() + + (OUT_DIR / "manifest.json").write_text( + json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8") + + bad_nav = [r for r in manifest + if r["nav_expected"] and r["nav"] != r["nav_expected"]] + checked = sum(1 for r in manifest if r["nav_expected"]) + print(f"\n[nav] rail selection matches the screen: {checked - len(bad_nav)}/{checked}") + for r in bad_nav: + print(f" MISMATCH {r['slug']} [{r['theme']}]: " + f"rail says '{r['nav']}', screen is '{r['nav_expected']}'") + + ok = sum(1 for r in manifest if r["file"]) + bad = [r for r in manifest if not r["file"]] + print(f"\ncaptured {ok}/{len(manifest)}") + if bad: + print("could NOT capture (recorded in manifest, shown as placeholders):") + for r in bad: + print(f" - {r['slug']} [{r['theme']}]: {r['error']}") + print(f"sandbox (safe to delete): {sandbox}") + return 0 + + +def _dialog_specs(ctx, win): + """(slug, title, note, factory) for each dialog we can build headlessly. + + Signatures differ per dialog (some take ctx first, some take parent first, + some require a real model object) — each factory below matches the actual + ``__init__`` it calls, not a guessed one. + """ + # NOTE: two different classes share the name `CustomAgent` — + # core/custom_agents.py:23 and core/co4e.py:117. Co4EAgentDialog uses the + # co4e one (it has `.role`); importing the other raises AttributeError. + from cowork_local.core.co4e import CustomAgent + from cowork_local.ui.settings_dialog import SettingsDialog + from cowork_local.ui.task_editor_dialog import TaskEditorDialog + from cowork_local.ui.skills_dialog import SkillsDialog, SkillEditDialog + from cowork_local.ui.file_edit_dialog import FileEditDialog + from cowork_local.ui.co4e_agent_dialog import Co4EAgentDialog + from cowork_local.ui.ext_connector_dialog import ExtConnectorEditDialog + from cowork_local.ui.permission_dialog import PermissionDialog + from cowork_local.ui.agents_admin_tab import AgentEditDialog + from cowork_local.ui.login_dialog import LoginDialog + + return [ + # SettingsDialog(ctx, parent) + ("dialog-settings", "Settings", "ui/settings_dialog.py:26", + lambda: SettingsDialog(ctx, win)), + # TaskEditorDialog(task, all_tasks, parent, ctx) + ("dialog-task-editor", "Task Editor", "ui/task_editor_dialog.py:55", + lambda: TaskEditorDialog(None, [], win, ctx)), + # SkillsDialog(parent, ctx) + ("dialog-skills", "Skills manager", "ui/skills_dialog.py:108", + lambda: SkillsDialog(win, ctx)), + # SkillEditDialog(parent, skill, ctx) + ("dialog-skill-edit", "Skill editor", "ui/skills_dialog.py:23", + lambda: SkillEditDialog(win, None, ctx)), + ("dialog-file-edit", "File view & AI edit", "ui/file_edit_dialog.py:50", + lambda: FileEditDialog(ctx, "", win)), + # Co4EAgentDialog(ctx, agent, skill_names, parent) — agent must be real + ("dialog-co4e-agent", "Co4E agent editor", "ui/co4e_agent_dialog.py:23", + lambda: Co4EAgentDialog(ctx, CustomAgent(id="preview"), [], win)), + # ExtConnectorEditDialog(parent, category, connector) + ("dialog-ext-connector", "External connector", "ui/ext_connector_dialog.py:23", + lambda: ExtConnectorEditDialog(win, "other", None)), + # PermissionDialog(action, parent) — `preview` is a dict, not a string + ("dialog-permission", "Permission request", "ui/permission_dialog.py:13", + lambda: PermissionDialog( + {"name": "run_command", + "preview": {"title": "Run command", "kind": "command", + "text": "npm install --save-dev vitest"}}, win)), + # AgentEditDialog(parent, ctx, agent, default_model_hint) + ("dialog-agent-edit", "Admin agent editor", "ui/agents_admin_tab.py:35", + lambda: AgentEditDialog(win, ctx, None, "")), + ("dialog-login", "Login (dead screen — not wired)", "ui/login_dialog.py:57", + lambda: LoginDialog(ctx, win)), + ] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_co4e.py b/tools/check_co4e.py new file mode 100644 index 0000000..ac3a87b --- /dev/null +++ b/tools/check_co4e.py @@ -0,0 +1,204 @@ +"""Check the Co4E sidebar rearrangement, on the real widget, offscreen. + +Phase D only moved things and added a second door to "new flow". So the test +that matters is a subtraction test: every control that existed before must still +exist, the flow tab strip (which carries the pinned Runs tab and lets several +flows stay open) must be untouched, and the section headings must actually name +the list you are looking at — in all three languages. + +Run: python tools/check_co4e.py +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E402 + +# Every control the sidebar and the flow area had before these changes. +EXPECTED = [ + "wf_list", "wf_edit_btn", "wf_dup_btn", "wf_del_btn", "wf_runbg_btn", + "agent_list", "ag_new_btn", "ag_edit_btn", "ag_del_btn", + "skill_list", "sk_manage_btn", + # Runs moved off the strip onto a toggle + a back button. + "runs_btn", "runs_back_btn", "runs_table", "runs_side_list", "runs_more_btn", + "name_edit", "add_step_btn", "save_btn", "save_tpl_btn", "mode_combo", "run_btn", + "run_stop_btn", "run_rename_btn", "run_del_btn", "run_clear_btn", "ws_folder_btn", +] + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + + _apply_theme(app) # measure the styled widget, not a bare one + from cowork_local.config import AppConfig, CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.i18n import set_language, tr + from cowork_local.state import AppContext + from cowork_local.ui.co4e_tab import Co4ETab + + set_language("vi") + tab = Co4ETab(AppContext(AppConfig.load())) + app.processEvents() + + fails: list[str] = [] + + missing = [n for n in EXPECTED if getattr(tab, n, None) is None] + print(f"control cu con nguyen : {len(EXPECTED) - len(missing)}/{len(EXPECTED)}") + if missing: + fails.append(f"mat control: {missing}") + + # The strip is gone from the screen, as the drawing asks. + strip_shown = tab.flow_scroll.isVisible() or tab.flow_add_btn.isVisible() + print(f"dai tab flow tren man : {strip_shown} (phai la False)") + if strip_shown: + fails.append("dai tab flow van con hien") + + # What the strip carried must still work. 1) Flow Status, both directions. + tab.runs_btn.setChecked(True) + app.processEvents() + on_runs = tab.center_stack.currentIndex() == 0 + tab.runs_back_btn.click() + app.processEvents() + back = tab.center_stack.currentIndex() == 1 + print(f"Flow Status: mo = {on_runs} · quay ve flow = {back} " + f"· nut gat dang bat = {tab.runs_btn.isChecked()}") + if not (on_runs and back): + fails.append("khong di/ve duoc trang Flow Status") + if tab.runs_btn.isChecked(): + fails.append("nut gat Flow Status khong tra ve trang thai tat") + + # 2) Opening a flow from the list REPLACES the one on the canvas — one at a + # time now, which is the part of the old strip that genuinely goes away. + from cowork_local.core import co4e as _co4e + tab._open_flow(_co4e.new_workflow("Flow A")) + app.processEvents() + tab._open_flow(_co4e.new_workflow("Flow B")) + app.processEvents() + print(f"mo 2 flow lien tiep : con {len(tab._flows)} flow tren canvas " + f"({tab._wf.name!r})") + if len(tab._flows) != 1: + fails.append(f"cho 1 flow mo cung luc, thay {len(tab._flows)}") + + # One column, four named sections — no icon tabs left. + from PySide6.QtWidgets import QTabWidget + heads = [h.text() for h, _b, _s in tab._sections.values()] + print(f"cot sidebar : {heads}") + if len(heads) != 4: + fails.append(f"cho 4 muc trong cot sidebar, thay {len(heads)}") + if tab.sidebar.findChildren(QTabWidget): + fails.append("van con tab icon trong sidebar") + + # Every list visible at once — that is the point of dropping the tabs. + tab.show() + app.processEvents() + shown = [n for n in ("wf_list", "agent_list", "skill_list", "runs_side_list") + if not getattr(tab, n).isHidden()] + print(f"danh sach hien cung luc: {shown}") + if len(shown) != 4: + fails.append(f"chi {len(shown)}/4 danh sach hien cung luc") + + # Headings fold their section, so a short window can still reach everything. + head, body, _s = tab._sections["co4e.tab_agents"] + head.setChecked(False) + app.processEvents() + folded = body.isHidden() + head.setChecked(True) + app.processEvents() + print(f"gap/mo muc AGENTS : gap = {folded} · mo lai = {not body.isHidden()}") + if not folded: + fails.append("bam tieu de khong gap duoc muc") + + # Both new-flow doors must land on the same slot. + print(f"'Moi' canh WORKFLOWS : {tab.wf_new_btn.text()!r}") + before = tab._wf.name + tab.wf_new_btn.click() + app.processEvents() + print(f"bam 'Moi' -> flow tren canvas {before!r} -> {tab._wf.name!r}") + if tab._wf.name == before: + fails.append("nut 'Moi' canh WORKFLOWS khong tao flow moi") + + # The action buttons that act on a selection stayed with the list. + print(f"nut duoi danh sach : agents = " + f"{[b.toolTip() for b in (tab.ag_edit_btn, tab.ag_del_btn)]}") + + # --- small screens ------------------------------------------------------ + # The complaint that started this: on a laptop the four lists squeezed down + # to one row each. Check real geometry at a few window heights. + print() + for w, h in ((1920, 1080), (1366, 768), (1280, 720)): + tab.resize(w, h) + app.processEvents() + app.processEvents() + heights = {n: getattr(tab, n).height() + for n in ("wf_list", "agent_list", "skill_list", "runs_side_list")} + rows = {n: (getattr(tab, n).height() // max(1, getattr(tab, n).sizeHintForRow(0) or 18)) + for n in heights} + print(f"{w}x{h}: cao = {heights} · so dong thay duoc = {rows}") + thin = [n for n, v in heights.items() if v < 50] + if thin: + fails.append(f"o {w}x{h}, danh sach qua thap: {thin}") + + # Folding must hand its height to the others, not just hide the body. + tab.resize(1280, 720) + app.processEvents() + before = tab.wf_list.height() + for key in ("co4e.tab_skills", "co4e.runs_tab"): + tab._sections[key][0].setChecked(False) + app.processEvents(); app.processEvents() + after = tab.wf_list.height() + print(f"gap SKILLS + FLOW STATUS -> WORKFLOWS cao {before} -> {after}px") + if after <= before: + fails.append("gap muc khac ma WORKFLOWS khong duoc them cho") + for key in ("co4e.tab_skills", "co4e.runs_tab"): + tab._sections[key][0].setChecked(True) + app.processEvents() + + print() + for lang in ("vi", "en", "ja"): + set_language(lang) + tab._retranslate() + app.processEvents() + texts = [h.text() for h, _b, _s in tab._sections.values()] + print(f" {lang}: {texts}") + print(f" nut moi = {tab.wf_new_btn.text()!r}" + f" · runs = {tab.runs_btn.text()!r} / {tab.runs_back_btn.text()!r}") + if any(not t or "CO4E." in t for t in texts): + fails.append(f"thieu ban dich tieu de muc cho {lang}") + set_language("vi") + + print() + if fails: + print("*** LOI ***") + for f in fails: + print(" " + f) + return 1 + print("KET QUA: Co4E sap xep lai, khong mat control nao") + return 0 + + +if __name__ == "__main__": + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_combo_popup.py b/tools/check_combo_popup.py new file mode 100644 index 0000000..abd423a --- /dev/null +++ b/tools/check_combo_popup.py @@ -0,0 +1,94 @@ +"""A drop-list must have room for its text and for the tick beside it. + +macOS marks the current row with a checkmark; Windows does not. The language +combo is only as wide as "VN", and the popup inherits that width, so on macOS +the tick landed on top of the two letters. Reported from a Mac, so this checks +the property that made it possible rather than the platform. +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts) + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication, QStyle + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + _apply_theme(app) + + from cowork_local.config import CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.config import AppConfig + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1400, 900) + win.show() + app.processEvents() + + fails = [] + from PySide6.QtWidgets import QStyledItemDelegate + for name in ("language_combo", "nav_project", "provider_combo"): + combo = getattr(win, name, None) + if combo is None or not combo.count(): + continue + view = combo.view() + fm = view.fontMetrics() + longest = max(fm.horizontalAdvance(combo.itemText(i)) + for i in range(combo.count())) + tick = combo.style().pixelMetric(QStyle.PM_IndicatorWidth, None, combo) + need = longest + tick + have = max(view.minimumWidth(), combo.width()) + print(f"{name:15}: chu dai nhat {longest:>4}px + dau tick {tick:>3}px " + f"= can {need:>4}px | popup rong {have:>4}px") + if have < need: + fails.append(f"{name}: popup {have}px, khong du {need}px cho chu + dau tick") + + # No tick: the row is already tinted, and the menu-style delegate that + # draws one on macOS covered the two letters it was marking. + deleg = combo.itemDelegate() + plain = type(deleg) is QStyledItemDelegate + print(f"{'':15} delegate={type(deleg).__name__} (khong ve dau tick={plain})") + if not plain: + fails.append(f"{name}: dung delegate kieu menu — macOS se ve dau tick") + + # ...and the current row must still be obvious without one. + view = combo.view() + sheet = app.styleSheet() + if "selection-background-color" not in sheet: + fails.append("popup khong to mau muc dang chon") + + print() + for f in fails: + print("FAIL " + f) + print("PASS moi drop-list du cho chu va dau tick" if not fails + else f"{len(fails)} problem(s)") + sys.stdout.flush() + os._exit(1 if fails else 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_controls_alive.py b/tools/check_controls_alive.py new file mode 100644 index 0000000..9979d26 --- /dev/null +++ b/tools/check_controls_alive.py @@ -0,0 +1,219 @@ +"""Round 3: is every control in the inventory still in the built app? + +docs/screens/controls.json was extracted from the source by AST before the +redesign started. Rounds 1 and 2 ask whether the new shape is right; this one +asks the opposite question — whether rearranging dropped anything. + +A control counts as alive if the attribute still exists on its screen's widget +AND is a real QWidget. Ones that were deliberately moved or replaced are listed +in MOVED with where they went, so an intentional change reads differently from +an accidental loss. + +Run: python tools/check_controls_alive.py +""" +from __future__ import annotations + +import json +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 + +# controls.json lists every control in a FILE, and several files hold more than +# one class (schedule_task_tab.py alone has the tab plus three dialogs). Only +# the screen's own class lives on the widget we can inspect, so each control is +# attributed to its class first, using the file as it was when the inventory +# was taken — that is the baseline commit, not today's line numbers. +BASELINE = "291a611" + + +def class_ranges(path: str) -> list[tuple[str, int, int]]: + """(class name, first line, last line) from the file at BASELINE.""" + import ast + import subprocess + try: + src = subprocess.run(["git", "show", f"{BASELINE}:{path}"], + cwd=REPO, capture_output=True, text=True, + encoding="utf-8", check=True).stdout + except Exception: # noqa: BLE001 + return [] + try: + tree = ast.parse(src) + except SyntaxError: + return [] + return [(n.name, n.lineno, max(getattr(x, "lineno", n.lineno) + for x in ast.walk(n))) + for n in tree.body if isinstance(n, ast.ClassDef)] + + +def owning_class(ranges, line: int) -> str: + for name, start, end in ranges: + if start <= line <= end: + return name + return "" + + +# Controls that are gone ON PURPOSE, with what replaced them. Anything missing +# and NOT listed here is a regression. +MOVED = { + "ui\\help_agent_widget.py": { + "self.collapse_btn": "→ mục 'Ẩn trợ lý' trong menu ⋯ của panel", + }, + "ui\\monitoring_tab.py": { + # e6adfd9 turned one Refresh in the Monitoring header into one per + # table (page.title_refresh_btn on Bảo mật / MCP / Hành động / Agent). + # Tổng quan gets none because it auto-refreshes every 3s (_REFRESH_MS); + # Icon has nothing to refresh. + "self.refresh_btn": "→ nút 'Làm mới' riêng trên từng bảng (title_refresh_btn)", + }, + "ui\\schedule_task_tab.py": { + "self.view_combo": "→ cặp tab Kanban | Lịch (view_tabs)", + }, + "ui\\folder_tab.py": { + "self.path_edit": "→ tiêu đề màn (path_lbl)", + }, + "ui\\structure_graph_view.py": { + "self._msgs_toggle_btn": "→ cặp tab Đồ thị | Tin nhắn (view_tabs)", + }, + "app.py": { + "self.settings_btn": "→ nút Cài đặt ở đáy rail (_nav_settings_btn)", + }, +} + +# The class whose controls each owner widget actually holds. +MAIN_CLASS = { + "app.py": "MainWindow", + "ui\\workspace_tab.py": "WorkspaceTab", + "ui\\cowork_tab.py": "CoworkTab", + "ui\\chat_panel.py": "ChatPanel", + "ui\\composer.py": "Composer", + "ui\\sidebar.py": "HistorySidebar", + "ui\\co4e_tab.py": "Co4ETab", + "ui\\folder_tab.py": "FolderTab", + "ui\\structure_graph_view.py": "StructureGraphView", + "ui\\schedule_task_tab.py": "ScheduleTaskTab", + "ui\\dashboard_tab.py": "DashboardTab", + "ui\\monitoring_tab.py": "MonitoringTab", + "ui\\help_agent_widget.py": "HelpAgentWidget", +} + +# Which built widget owns each source file's controls. +def owners(win): + ws = win.workspace + import cowork_local.ui.co4e_tab as co4e_mod + return { + "app.py": win, + "ui\\workspace_tab.py": ws, + "ui\\cowork_tab.py": ws._cowork, + "ui\\chat_panel.py": ws._cowork, + "ui\\composer.py": ws._cowork.composer, + "ui\\sidebar.py": win.sidebar, + "ui\\co4e_tab.py": win.findChildren(co4e_mod.Co4ETab)[0], + "ui\\folder_tab.py": ws.tabs.widget(ws._folder_tab_idx), + "ui\\structure_graph_view.py": ws.tabs.widget(ws._graphrag_tab_idx), + "ui\\schedule_task_tab.py": win._page_widgets[win._ROW_SCHEDULE], + "ui\\dashboard_tab.py": win._page_widgets[win._ROW_DASHBOARD], + "ui\\monitoring_tab.py": win._page_widgets[win._ROW_MONITORING], + "ui\\help_agent_widget.py": win.help_agent, + } + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + + _apply_theme(app) # measure the styled window, not a bare one + from cowork_local.config import AppConfig, CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1600, 950) + win.show() + # Build every lazy page before looking for its controls. + for row in (win._ROW_DASHBOARD, win._ROW_SCHEDULE, win._ROW_MONITORING): + win._goto(row, None) + for _ in range(6): + app.processEvents() + for sub in range(win.workspace.tabs.count()): + win._goto(win._ROW_WORKSPACE, sub) + for _ in range(6): + app.processEvents() + + index = json.loads((REPO / "docs" / "screens" / "controls.json") + .read_text(encoding="utf-8")) + own = owners(win) + + alive = dead = moved = skipped = other_class = 0 + losses: list[tuple[str, str, str]] = [] + for rec in index: + holder = own.get(rec["file"]) + if holder is None: + skipped += len(rec["controls"]) + continue + ranges = class_ranges(rec["file"].replace("\\", "/")) + want = MAIN_CLASS.get(rec["file"], "") + for c in rec["controls"]: + var = c["var"] + if not var.startswith("self."): + skipped += 1 + continue + # Belongs to a dialog defined in the same file → not on this widget. + if ranges and want and owning_class(ranges, c["line"]) != want: + other_class += 1 + continue + name = var.split(".", 1)[1] + if getattr(holder, name, None) is not None: + alive += 1 + elif var in MOVED.get(rec["file"], {}): + moved += 1 + else: + dead += 1 + losses.append((rec["file"], var, + c.get("label_vi") or c.get("label") or "?")) + + print(f"control con song : {alive}") + print(f"co y doi cho : {moved}") + for f, v, w in [(f, v, MOVED[f][v]) for f in MOVED for v in MOVED[f]]: + print(f" {v:26} {w}") + print(f"thuoc class khac : {other_class} (hop thoai dinh nghia cung file)") + print(f"khong kiem duoc : {skipped} (dialog dung rieng, bien cuc bo)") + print(f"MAT : {dead}") + for f, var, label in losses: + print(f" ! {f}: {var} ({label})") + print() + if dead: + print("*** VONG 3 THAT BAI: co control bien mat ***") + return 1 + print("KET QUA VONG 3: khong control nao bien mat ngoai y muon") + return 0 + + +if __name__ == "__main__": + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_cowork_screen.py b/tools/check_cowork_screen.py new file mode 100644 index 0000000..bbc1ecf --- /dev/null +++ b/tools/check_cowork_screen.py @@ -0,0 +1,177 @@ +"""Workspace ▸ Cowork against its wireframe (section 5). + +The drawing heads the screen with the THREAD's title — "Gom số liệu doanh thu", +not the word "Cowork" — with the model beside it, Skills and Cuộc trò chuyện mới +on the right, and a caps TỆP ĐẦU RA (n) panel down the side. +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts) + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + _apply_theme(app) + + from cowork_local.config import CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.config import AppConfig + from cowork_local.core.history import list_conversations, load_conversation + from cowork_local.i18n import set_language, tr + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1920, 1000) + win.show() + app.processEvents() + win._goto(win._ROW_WORKSPACE, win.workspace._cowork_tab_idx) + app.processEvents() + c = win.cowork + fails = [] + + # a new thread has no title yet, so the screen name stands in + print(f"chua mo thread: tieu de={c._title_lbl.text()!r}") + if not c._title_lbl.text().strip(): + fails.append("tieu de trong khi chua mo thread") + + convs = list_conversations() + if not convs: + fails.append("khong co hoi thoai de kiem") + else: + conv = load_conversation(Path(convs[0]["path"])) + c.load_conversation(conv) + app.processEvents() + want = conv.get("title", "") + print(f"sau khi mo thread: tieu de={c._title_lbl.text()!r} (thread={want!r})") + if want and c._title_lbl.text() != want: + fails.append(f"tieu de khong theo thread: {c._title_lbl.text()!r} != {want!r}") + if c._title_lbl.text() == tr("cowork.title") and want: + fails.append("tieu de van la ten man hinh") + + # the files panel is a caps section carrying its own count + head = c.output_section.header.text() + print(f"pane tep dau ra: {head!r}") + body = head.lstrip("▾▸ ").split(" (")[0] + if body != body.upper(): + fails.append(f"tieu de pane chua viet hoa: {head!r}") + if "(" not in head: + fails.append("tieu de pane khong kem so luong") + + # toolbar keeps both actions the drawing shows + for name, btn in (("Skills", c.skills_btn), ("chat moi", c._new_btn)): + if not btn.isVisible(): + fails.append(f"thieu nut {name} tren thanh cong cu") + print(f"nut tren thanh cong cu: {c.skills_btn.text()!r}, {c._new_btn.text()!r}") + + # the status strip, read left to right, is + # Agent: … · Định tuyến: … · ↓in ↑out · $cost · 📁 folder + from PySide6.QtCore import QPoint as _P + + bar = c._usage_total_lbl.parentWidget() + def _x(widget): + return widget.mapTo(bar, _P(0, 0)).x() + + usage_text = c._usage_total_lbl.text() + print(f"usage tren dai: {usage_text!r}") + if not usage_text.strip(): + fails.append("dai duoi khong hien token/chi phi cua thread da mo") + order = [("Agent", _x(c._agent_lbl)), ("Dinh tuyen", _x(c.routing_toggle)), + ("usage", _x(c._usage_total_lbl))] + folder = getattr(c, "folder_lbl", None) + if folder is not None: + order.append(("thu muc", _x(folder))) + print("thu tu: " + " < ".join(f"{n}({x})" for n, x in order)) + for (n1, x1), (n2, x2) in zip(order, order[1:]): + if x1 >= x2: + fails.append(f"dai duoi sai thu tu: {n1} khong dung truoc {n2}") + + # the drawing gives Cowork two columns; History lives in the rail's RECENTS + # and its pane arrives folded to the strip the drawing keeps + w = win.workspace + sb = w._sidebar + print(f"vao Cowork: History hien={sb.isVisible()}") + if sb.isVisible(): + fails.append("pane Lich su van o tren man Cowork — ban ve chi co 2 cot") + w.show_history_pane() + app.processEvents() + print(f"sau 'Tat ca project…': History hien={sb.isVisible()} rong={sb.width()}px") + if not sb.isVisible(): + fails.append("'Tat ca project…' khong mo duoc pane Lich su") + w._on_history_fold(True) + app.processEvents() + + # one heading on the files panel, with the chevron at its right — the + # drawing has "TỆP ĐẦU RA (3) ›" and nothing above it + from PySide6.QtCore import QPoint as _QP + + hdr_w = c.output_section.header + chev = c._io_collapse_btn + same_row = abs(hdr_w.mapTo(c, _QP(0, 0)).y() - chev.mapTo(c, _QP(0, 0)).y()) <= 8 + chev_right = chev.mapTo(c, _QP(0, 0)).x() > hdr_w.mapTo(c, _QP(0, 0)).x() + # Count what the panel actually shows as headings. Asking whether the old + # label is visible proved nothing: unparented, it reports invisible whether + # or not the code hides it. + from PySide6.QtWidgets import QLabel + + heads = [l.text() for l in c._io_widget.findChildren(QLabel) + if l.isVisible() and l.text().strip()] + print(f"tieu de hien trong pane: {heads} | " + f"chevron cung hang={same_row} ben phai={chev_right}") + if heads: + fails.append(f"pane co tieu de thua ngoai '{hdr_w.text()}': {heads}") + if not (same_row and chev_right): + fails.append("chevron thu gon khong nam cuoi hang tieu de pane") + + # the composer spans the screen, under BOTH columns — inside the chat + # column it stopped at the files panel's edge and shrank when files arrived + from PySide6.QtCore import QPoint + + c._set_io_collapsed(False) + app.processEvents() + split = c.center_split + files_pane = split.widget(1) + comp_right = c.composer.mapTo(c, QPoint(c.composer.width(), 0)).x() + files_left = files_pane.mapTo(c, QPoint(0, 0)).x() + below = c.composer.mapTo(c, QPoint(0, 0)).y() > split.mapTo(c, QPoint(0, 0)).y() + spans = comp_right > files_left + print(f"o nhap: rong {c.composer.width()} / man {c.width()} | " + f"duoi splitter={below} | trai qua duoi pane tep={spans}") + if not below: + fails.append("o nhap khong nam duoi hang than") + if not spans: + fails.append("o nhap dung lai o mep pane tep, khong trai het man") + + print() + for f in fails: + print("FAIL " + f) + print("PASS man Cowork khop ban ve" if not fails else f"{len(fails)} problem(s)") + sys.stdout.flush() + os._exit(1 if fails else 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_dashboard.py b/tools/check_dashboard.py new file mode 100644 index 0000000..0c856a2 --- /dev/null +++ b/tools/check_dashboard.py @@ -0,0 +1,95 @@ +"""Check the Dashboard header regroup, on the real widget, offscreen. + +Nine controls were on one row. They are now on two, grouped by what they do — +so this asserts that all nine are still present, still wired, and that the +header really is two rows now (row 1 = title + Refresh, row 2 = the selectors). + +Run: python tools/check_dashboard.py +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E402 + +HEADER = ["_title", "chart_prev_btn", "_chart_period_lbl", "chart_next_btn", + "gran_combo", "metric_combo", "currency_lbl", "currency_combo", + "refresh_btn"] + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + + _apply_theme(app) # measure the styled widget, not a bare one + from cowork_local.config import AppConfig, CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + from cowork_local.ui.dashboard_tab import DashboardTab + + set_language("vi") + tab = DashboardTab(AppContext(AppConfig.load())) + tab.resize(1100, 800) + tab.show() + app.processEvents() + tab.refresh() + app.processEvents() + + fails: list[str] = [] + missing = [n for n in HEADER if getattr(tab, n, None) is None] + print(f"control header con nguyen: {len(HEADER) - len(missing)}/{len(HEADER)}") + if missing: + fails.append(f"mat control: {missing}") + + # Two rows: everything in the header must sit at one of exactly two y bands. + tops = {} + for n in HEADER: + w = getattr(tab, n) + tops.setdefault(round(w.mapTo(tab, w.rect().topLeft()).y() / 10), []).append(n) + print(f"so hang cua header : {len(tops)}") + for band, names in sorted(tops.items()): + print(f" y~{band * 10:>4}px : {names}") + if len(tops) != 2: + fails.append(f"header co {len(tops)} hang, cho 2") + + # Still wired: changing the metric must not throw and must stick. + before = tab.metric_combo.currentData() + tab.metric_combo.setCurrentIndex(1 - tab.metric_combo.currentIndex()) + app.processEvents() + after = tab.metric_combo.currentData() + print(f"doi chi so bieu do : {before} -> {after}") + if after == before: + fails.append("combo chi so khong doi duoc") + tab.refresh_btn.click() + app.processEvents() + print("bam Lam moi : khong loi") + + print() + if fails: + print("*** LOI ***") + for f in fails: + print(" " + f) + return 1 + print("KET QUA: header Dashboard chia 2 hang, du 9 control") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_design_parity.py b/tools/check_design_parity.py new file mode 100644 index 0000000..30148bd --- /dev/null +++ b/tools/check_design_parity.py @@ -0,0 +1,392 @@ +"""Compare the running app against every proposal on the audit page. + +The checklist is not written here — it is read from build_audit_page.ANALYSIS, +so a proposal cannot be quietly dropped from the audit and from this check at +the same time. Each item has a probe against a real MainWindow built offscreen. + +Verdicts: + OK the probe passes + CHUA not implemented + KHAC implemented differently on purpose (reason printed) + TAY cannot be probed mechanically — inspect by eye + +Run: python tools/check_design_parity.py +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 + + +def page_proposals(): + """The 'Thay đổi' bullets as they appear ON THE PAGE, per section. + + Read from docs/ui-audit.html rather than build_audit_page.ANALYSIS: eight + sections are hand-written, and for those two the generator's text is NOT + what the page shows. Checking against ANALYSIS reported Settings and the + Task editor as matching the design when the page asked for something else + (and, for the Task editor, the opposite). + """ + import re + html = (REPO / "docs" / "ui-audit.html").read_text(encoding="utf-8") + html = re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "", html) + out: dict[str, list[str]] = {} + for m in re.finditer(r'
    (.*?)
    ', html, re.S): + block = re.search(r'Thay đổi
      (.*?)
    ', m.group(2), re.S) + if not block: + continue + out[m.group(1)] = [ + re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", li)).strip() + for li in re.findall(r"
  • (.*?)
  • ", block.group(1), re.S)] + return out + + +def build(): + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() or QApplication([]) + _load_fonts() + _freeze_schedulers() + + _apply_theme(app) # measure the styled window, not a bare one + from cowork_local.config import AppConfig, CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1600, 900) + win.show() + for _ in range(8): + app.processEvents() + return app, win + + +def main() -> int: + app, win = build() + ws = win.workspace + + def goto(sub): + win._goto(win._ROW_WORKSPACE, sub) + for _ in range(6): + app.processEvents() + + def page(row): + win._goto(row, None) + for _ in range(6): + app.processEvents() + return win._page_widgets[row] + + import cowork_local.ui.co4e_tab as co4e_mod + co4e = win.findChildren(co4e_mod.Co4ETab)[0] + dash = page(win._ROW_DASHBOARD) + mon = page(win._ROW_MONITORING) + sched = page(win._ROW_SCHEDULE) + goto(ws._cowork_tab_idx) + chat = ws._cowork + dock = win.help_agent + + def rows_of(widget, names): + """How many distinct y-bands the named widgets occupy.""" + bands = set() + for n in names: + w = getattr(widget, n, None) + if w is not None: + bands.add(round(w.mapTo(widget, w.rect().topLeft()).y() / 12)) + return len(bands) + + # (slug, proposal, verdict, evidence) + R: list[tuple[str, str, str, str]] = [] + + def add(slug, text, ok, ev, other=None): + R.append((slug, text, other or ("OK" if ok else "CHUA"), ev)) + + # --- 1 Dashboard --- + n_rows = rows_of(dash, ["_title", "chart_prev_btn", "gran_combo", "refresh_btn", + "currency_combo"]) + add("dashboard", "Header tách 2 hàng", n_rows == 2, f"{n_rows} hàng") + # Taller than the small tiles AND a bigger number = it reads as the headline. + taller = dash.card_cost.height() > dash.card_total.height() * 1.5 + bigger = "34px" in dash.card_cost.value_lbl.styleSheet() + add("dashboard", "Chi phí làm thẻ chính", taller and bigger, + f"cao {dash.card_cost.height()}px vs thẻ phụ {dash.card_total.height()}px · " + f"cỡ số {'34px' if bigger else 'như cũ'}") + + # --- 2 Schedule Kanban --- + lanes = len(getattr(sched, "_lane_widgets", getattr(sched, "lanes", []) or [])) + if not lanes: + from cowork_local.core.tasks import STATUSES + lanes = len(STATUSES) + add("schedule-kanban", "Giữ đủ 7 lane", lanes == 7, f"{lanes} lane") + has_combo = getattr(sched, "view_combo", None) is not None + add("schedule-kanban", "Combo → cặp tab Kanban | Lịch", not has_combo, + "vẫn là combo" if has_combo else "đã thành tab") + # The lane is only outlined while it actually holds something — seed data + # may leave it empty, so drop a card in and read the style back. + run_col = sched.columns.get("running") + styled = "" + if run_col is not None: + from PySide6.QtWidgets import QListWidgetItem + run_col.addItem(QListWidgetItem("probe")) + sched.column_headers["running"].setStyleSheet("") + sched.refresh() + app.processEvents() + styled = run_col.styleSheet() + add("schedule-kanban", "Lane Running có viền cảnh báo", "border" in styled, + styled or "không có viền") + + # --- 4/5 Workspace --- + add("workspace-project", "History lên sidebar thành RECENTS", + win.nav_recents.topLevelItemCount() > 0, + f"{win.nav_recents.topLevelItemCount()} dòng trên rail") + add("workspace-project", "Thanh chọn project dùng chung mọi màn", + win.nav_project.count() > 0, f"{win.nav_project.count()} mục, ở rail") + goto(ws._co4e_tab_idx) + hdr_off = ws._header.isHidden() + goto(ws._project_tab_idx) + hdr_on = not ws._header.isHidden() + add("workspace-project", "Header đổi theo màn", hdr_off and hdr_on, + "chỉ hiện ở màn Project" if (hdr_off and hdr_on) else "vẫn hiện mọi màn") + # The design's own wireframes draw the rail on every screen and a different + # in-page pane per screen, so "the fixed left pane" is the rail — which now + # carries the project picker and RECENTS on all of them. + fixed = (win.nav_project.isVisible() or not win._nav_collapsed) and \ + win.nav_recents.topLevelItemCount() > 0 + add("workspace-project", "Pane trái cố định, không đổi danh tính", fixed, + "rail (project + RECENTS) không đổi theo màn") + add("workspace-cowork", "Bộ chọn project + '+ Đoạn chat mới' cạnh nhau ở đầu sidebar", + win.nav_new_chat is not None and win.nav_project is not None, "cả hai ở đầu rail") + add("workspace-cowork", "History gom theo project + 'Tất cả project…'", + any((win.nav_recents.topLevelItem(i).data(0, 0x0100) or {}).get("all") + for i in range(win.nav_recents.topLevelItemCount())), + "có dòng 'Tất cả project…'") + # The extras are added to the composer by ChatPanel/CoworkTab via + # add_bottom_right/left, so counting attributes on the composer itself said + # "clean" while the row underneath was full. Count the row instead. + # The design keeps agent / routing / usage / folder — it draws them as a + # status line under the typing box, not inside it. So the test is that the + # TYPING row holds only input + attach/send/stop, and the rest sits in its + # own strip below. Demanding an empty strip would mean deleting features. + composer = getattr(chat, "composer", None) + bar = getattr(composer, "extra_bar", None) + from PySide6.QtWidgets import QPlainTextEdit, QTextEdit + typing = composer.input + in_typing_row = typing.parentWidget() is composer + below = bar is not None and bar.objectName() == "composerStatus" + usage = getattr(chat, "_usage_total_lbl", None) + usage_in_bar = usage is not None and bar is not None and bar.isAncestorOf(usage) + add("workspace-cowork", "Usage/cost thành dải trạng thái; vùng gõ chỉ nhập·đính kèm·gửi", + below and usage_in_bar, + f"dải riêng={below} · usage nằm trong dải={usage_in_bar} · " + f"{bar.layout().count() if bar else 0} mục") + + # --- 6 Co4E --- + add("workspace-co4e", "Bỏ dải tab flow", + not (co4e.flow_scroll.isVisible() or co4e.flow_add_btn.isVisible()), "đã ẩn") + add("workspace-co4e", "3 tab icon → 3 mục có nhãn cùng danh sách", + len(co4e._sections) >= 3, f"{len(co4e._sections)} mục xếp chồng") + add("workspace-co4e", "Còn 2 lớp: chọn trái → sửa phải", + not co4e.flow_scroll.isVisible(), "nav → cột trái → panel phải") + + # --- 7 Folder / 8 GraphRAG --- + folder = ws.tabs.widget(ws._folder_tab_idx) + title_lbl = getattr(folder, "path_lbl", None) + add("workspace-folder", "Path bar gộp vào tiêu đề", + title_lbl is not None and getattr(folder, "path_edit", None) is None, + f"tiêu đề = {title_lbl.text()[:40]!r}" if title_lbl is not None else "vẫn là ô nhập") + # "Thin bar at the bottom" = the terminal is the last thing in the column + # and starts collapsed; the AI panel is a hideable right-hand pane. + # Geometry is meaningless for a page that has never been shown, so ask the + # widgets what state they are in instead of how tall they currently are. + term = getattr(folder, "terminal", None) + lay = folder.layout() + last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None + collapsed = term is not None and term._body.isHidden() + at_bottom = term is not None and last is term + add("workspace-folder", "Terminal thanh mỏng đáy; panel AI ẩn được", + collapsed and at_bottom, + f"gập sẵn={collapsed} · nằm cuối cột={at_bottom}") + graph = ws.tabs.widget(ws._graphrag_tab_idx) + # One row = the path box and Export share a y-band. + def band(w): + return round(w.mapTo(graph, w.rect().topLeft()).y() / 10) + one_row = band(graph.path_edit) == band(graph._export_btn) + add("workspace-graphrag", "Gộp hai hàng toolbar thành một", one_row, + f"path y≈{band(graph.path_edit) * 10} · Export y≈{band(graph._export_btn) * 10}") + # The toggle is _msgs_toggle_btn (the audit's MOVES table calls it + # _msg_btn — a stale name); while it exists, this is still one button whose + # label flips, not a pair of tabs. + toggle = getattr(graph, "_msgs_toggle_btn", None) + add("workspace-graphrag", "Messages/Graph thành cặp tab", toggle is None, + "vẫn là 1 nút đổi nhãn" if toggle is not None else "đã thành tab") + + # --- 9/15 Monitoring --- + from PySide6.QtWidgets import QHBoxLayout, QScrollArea + from PySide6.QtWidgets import QSpinBox as QSpinBoxT + # The Overview column is not simply the first scroll area any more — the + # event tables' detail panels are scroll areas too, and are built first + # (e6adfd9). Pick the one that actually holds Overview's own sections. + ov = None + for area in mon.findChildren(QScrollArea): + body = area.widget() + if body is None or body.layout() is None: + continue + if body.layout().indexOf(mon.ov_usage_group) >= 0: + ov = body + break + assert ov is not None, "khong tim thay cot Tong quan" + one_col = not isinstance(ov.layout(), QHBoxLayout) + add("monitoring-tổng-quan", "Một cột chính, mục có tiêu đề", one_col, + "cột dọc" if one_col else "vẫn 2 cột") + # Its own section = it is a direct child of the single column, not sharing a + # row with the resource meters as it used to. + own = ov.layout().indexOf(mon.ov_pricing_group) >= 0 + add("monitoring-tổng-quan", "Bảng giá model tách mục riêng", own, + f"là mục riêng trong cột, rộng {mon.ov_pricing_group.width()}px") + strip = not mon.tabs.tabBar().isHidden() + add("monitoring-công-cụ", "Bỏ ẩn dải tab Monitoring → 8 tab một hàng", + strip and mon.tabs.count() == 8, f"dải tab hiện={strip}, {mon.tabs.count()} tab") + + # --- 17/18 dialogs --- + from cowork_local.ui.settings_dialog import SettingsDialog + from cowork_local.ui.task_editor_dialog import TaskEditorDialog + s = SettingsDialog(win.ctx) + add("dialog-settings", "Thêm cột mục lục bên trái", + s.section_list.count() == 5, f"{s.section_list.count()} mục") + # The page asks for Ngôn ngữ/Giao diện in the Chung group, with AI Provider + # left as its own group — not everything merged together. + from cowork_local.ui.widgets import SegmentedControl, ToggleSwitch + in_general = s._general_box.isAncestorOf(s.language_combo) and \ + s._general_box.isAncestorOf(s.theme_combo) + prov_apart = not s._general_box.isAncestorOf(s.provider_combo) + add("dialog-settings", "Gom Ngôn ngữ/Giao diện vào nhóm Chung; AI Provider vẫn riêng", + in_general and prov_apart, + f"ngôn ngữ+giao diện trong Chung={in_general} · provider tách riêng={prov_apart}") + n_switch = len(s.findChildren(ToggleSwitch)) + n_seg = len(s.findChildren(SegmentedControl)) + steppers = [w for w in s.findChildren(QSpinBoxT) if w.buttonSymbols() != 2] + add("dialog-settings", + "Field theo đúng loại: checkbox→toggle, dropdown 2–4 giá trị→segmented, số→stepper", + n_switch >= 6 and n_seg >= 2 and len(steppers) >= 1, + f"{n_switch} toggle · {n_seg} segmented · {len(steppers)} stepper") + s.close() + t = TaskEditorDialog(ctx=win.ctx) + rows = [t.section_list.item(i).text() for i in range(t.section_list.count())] + like_settings = (t.section_list.count() == 5 + and t.section_stack.count() == 5 + and not hasattr(t, "step_tabs")) + add("dialog-task-editor", + "Danh sách trái + panel phải giống Cài đặt (không dùng tab), 5 mục = 5 group", + like_settings, " · ".join(rows)) + t.close() + + # --- 27 help dock --- + # The page says 26px. The user asked for it doubled — recorded here rather + # than scored against a number the app no longer intends. + from cowork_local.ui.help_agent_widget import _DOT + + add("overlay-help-panel", + f"Một chấm, không chữ (bản vẽ 26px → {_DOT}px theo yêu cầu)", + dock.width() == _DOT and not dock.launcher.text().strip(), + f"{dock.width()}px") + from cowork_local.i18n import tr + dock.launcher._set_open(True) + app.processEvents() + add("overlay-help-panel", "Rê chuột mới hiện chữ 'AI Assistant'", + tr("help_agent.badge") in dock.launcher.text(), dock.launcher.text().strip()) + dock.launcher._set_open(False) + # The page asks for "'Ẩn trợ lý' dời vào menu ⋯". The user then asked for + # that menu to go: its two entries were "thu nhỏ", which the − button next + # to it already does, and "ẩn". Recorded as a deliberate deviation rather + # than quietly scored as done — the action itself moved to a right-click on + # the dot and on the panel header, so nothing became unreachable. + from PySide6.QtCore import Qt + + has_menu = hasattr(dock, "more_btn") + by_right_click = dock.launcher.contextMenuPolicy() == Qt.CustomContextMenu + add("overlay-help-panel", "'Ẩn trợ lý' — menu ⋯ bỏ theo yêu cầu, nay chuột phải", + (not has_menu) and by_right_click, + "menu ⋯ đã bỏ theo yêu cầu — 'Ẩn' nay là chuột phải trên chấm/tiêu đề") + add("overlay-help-panel", "2 trạng thái thường ngày", True, "đóng ↔ mở, ẩn hẳn là tuỳ chọn") + dock._hide_to_edge() + app.processEvents() + add("overlay-help-panel", "Tab mép 16px → 28px", dock.width() >= 28, f"{dock.width()}px") + dock._show_launcher() + app.processEvents() + goto(ws._cowork_tab_idx) + comp = chat.composer + dock_top = dock.mapTo(win, dock.rect().topLeft()).y() + comp_top = comp.mapTo(win, comp.rect().topLeft()).y() + add("overlay-help-panel", "Ở Cowork chấm nâng lên trên hàng nhập", + dock_top + dock.height() <= comp_top, + f"chấm đáy y={dock_top + dock.height()} · ô nhập đỉnh y={comp_top}") + + # --- coverage: is every bullet ON THE PAGE actually probed? ------------- + proposals = page_proposals() + probed = {} + for slug, text, _v, _e in R: + probed.setdefault(slug, 0) + probed[slug] += 1 + gaps = [] + for slug, bullets in proposals.items(): + n_probe = probed.get(slug, 0) + if len(bullets) > n_probe: + for extra in bullets[n_probe:]: + gaps.append((slug, extra)) + + # --- report --- + order = ["OK", "KHAC", "CHUA", "TAY"] + counts = {k: 0 for k in order} + cur = None + for slug, text, verdict, ev in R: + counts[verdict] = counts.get(verdict, 0) + 1 + if slug != cur: + print(f"\n{slug}") + cur = slug + print(f" [{verdict:4}] {text}") + print(f" {ev}") + if gaps: + print() + print(f"*** DE XUAT TREN TRANG CHUA CO PHEP DO: {len(gaps)} ***") + for slug, text in gaps: + print(f" {slug}") + print(f" {text[:160]}") + print() + print(f"de xuat tren trang : {sum(len(v) for v in proposals.values())}" + f" · da co phep do : {len(R)}") + print("TONG KET: " + " · ".join(f"{k}={counts.get(k, 0)}" for k in order)) + print(f" OK = dung thiet ke moi ({counts['OK']}/{len(R)})") + print(f" KHAC = co y lam khac, da ghi ly do") + print(f" CHUA = chua lam") + # This used to return 0 unconditionally — a report, not a check. Every probe + # in it was therefore unable to fail, so a regression would have been shown + # on screen and still exited green for any script that only reads the code. + return 1 if counts.get("CHUA") else 0 + + +if __name__ == "__main__": + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_dialogs.py b/tools/check_dialogs.py new file mode 100644 index 0000000..47ad800 --- /dev/null +++ b/tools/check_dialogs.py @@ -0,0 +1,140 @@ +"""Check the left-list + right-panel navigation in Settings and Task editor. + +Both dialogs are navigated the same way, as the audit page asks: a list of the +real group boxes on the left, one panel shown at a time on the right. So the +test is that picking a row swaps the panel, that the rows match the groups, and +— since this is a rearrangement — that no input control went missing. + +Run: python tools/check_dialogs.py +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E402 + +# Every field each dialog must still offer after the move. +SETTINGS_FIELDS = [ + "language_combo", "theme_combo", "tray_chk", "notify_chk", + "provider_combo", "prov_base", "prov_key", "prov_model", + "sandbox_pw_edit", "sandbox_unlock_btn", "sandbox_confirm", + "sandbox_block_network", "sec_enabled", "ai_check", +] +TASK_FIELDS = [ + "title_edit", "desc_edit", "gen_desc_btn", "priority_combo", "status_combo", + "workspace_combo", "provider_combo", "model_combo", "skill_combo", + "sched_enabled", "run_at_edit", "files_list", "files_add_btn", "links_list", + "links_add_btn", "next_combo", "run_next_combo", "pass_output_chk", + "depends_list", "retry_spin", "timeout_spin", "approval_chk", +] + + +def check(name, dlg, app, expect_rows, fields): + fails = [] + print(f"--- {name} ---") + idx, stack = dlg.section_list, dlg.section_stack + rows = [idx.item(i).text() for i in range(idx.count())] + print(f"muc : {rows}") + if len(rows) != expect_rows: + fails.append(f"{name}: cho {expect_rows} muc, thay {len(rows)}") + if idx.count() != stack.count(): + fails.append(f"{name}: {idx.count()} muc nhung {stack.count()} panel") + + # Picking a row must swap the panel — and each panel must hold something. + swapped, empty = 0, [] + for i in range(idx.count()): + idx.setCurrentRow(i) + for _ in range(3): + app.processEvents() + if stack.currentIndex() == i: + swapped += 1 + page = stack.widget(i).widget() + if not page.findChildren(type(page)): + empty.append(rows[i]) + print(f"chon muc -> doi panel : {swapped}/{idx.count()}") + if swapped != idx.count(): + fails.append(f"{name}: chon muc khong doi panel") + if empty: + fails.append(f"{name}: panel rong {empty}") + + missing = [f for f in fields if getattr(dlg, f, None) is None] + print(f"field con nguyen : {len(fields) - len(missing)}/{len(fields)}") + if missing: + fails.append(f"{name}: mat field {missing}") + return fails + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + + _apply_theme(app) # measure the styled widget, not a bare one + from cowork_local.config import AppConfig, CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from cowork_local.i18n import set_language, tr + from cowork_local.state import AppContext + from cowork_local.ui.settings_dialog import SettingsDialog + from cowork_local.ui.task_editor_dialog import TaskEditorDialog + + set_language("vi") + ctx = AppContext(AppConfig.load()) + fails = [] + + s = SettingsDialog(ctx) + s.resize(900, 640) + s.show() + app.processEvents() + fails += check("Cai dat", s, app, 5, SETTINGS_FIELDS) + + t = TaskEditorDialog(ctx=ctx) + t.resize(900, 640) + t.show() + app.processEvents() + fails += check("Task editor", t, app, 5, TASK_FIELDS) + + # Both dialogs must be navigated the SAME way — that is the stated point. + same = (type(s.section_list) is type(t.section_list) + and type(s.section_stack) is type(t.section_stack)) + print() + print(f"hai hop thoai cung kieu dieu huong: {same}") + if not same: + fails.append("hai hop thoai dieu huong khac kieu") + + for lang in ("vi", "en", "ja"): + set_language(lang) + print(f" {lang}: general={tr('settings.group.general')!r} " + f"basic={tr('schedtask.g_basic')!r}") + set_language("vi") + + print() + if fails: + print("*** LOI ***") + for x in fails: + print(" " + x) + return 1 + print("KET QUA: hai hop thoai dung danh sach trai + panel phai, khong mat field") + return 0 + + +if __name__ == "__main__": + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_dock_corner.py b/tools/check_dock_corner.py new file mode 100644 index 0000000..3b01e15 --- /dev/null +++ b/tools/check_dock_corner.py @@ -0,0 +1,94 @@ +"""The assistant dot sits in the bottom-right corner — unless the composer is +genuinely underneath it. + +It used to lift on Cowork whenever a composer existed, measured only on the +vertical axis. On a wide window the composer stops at the chat column's right +edge, far short of the dot, so the dot rose 156px for nothing and Cowork was +the one screen where it was not in the corner. +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts) + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + _apply_theme(app) + + from cowork_local.config import CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.config import AppConfig + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.show() + dock = win.help_agent + fails = [] + + def gap_and_overlap(width, height): + win.resize(width, height) + app.processEvents() + win._goto(win._ROW_WORKSPACE, win.workspace._cowork_tab_idx) + app.processEvents() + comp = win.cowork.composer + origin = comp.mapTo(win, comp.rect().topLeft()) + dleft = dock.x() - win.mapToGlobal(win.rect().topLeft()).x() + overlaps = (dleft + dock.width() > origin.x() + and dleft < origin.x() + comp.width()) + gap = win.height() - (dock.y() + dock.height()) + return gap, overlaps, origin.x() + comp.width(), dleft + + # baseline: every other screen + win.resize(1936, 1048) + app.processEvents() + win._goto(win._ROW_WORKSPACE, win.workspace._project_tab_idx) + app.processEvents() + corner = win.height() - (dock.y() + dock.height()) + print(f"man thuong : cach day {corner}px") + + for w, h in ((1936, 1048), (1200, 800), (900, 700)): + gap, over, comp_right, dleft = gap_and_overlap(w, h) + print(f"Cowork {w}x{h:<5}: cach day {gap:>4}px | composer het o x={comp_right} " + f"| cham o x={dleft} | chong nhau={over}") + if over and gap <= corner: + fails.append(f"{w}x{h}: composer nam duoi cham ma cham khong duoc nang") + if not over and gap != corner: + fails.append(f"{w}x{h}: khong chong nhau ma cham van lech " + f"({gap}px thay vi {corner}px)") + + print() + for f in fails: + print("FAIL " + f) + print("PASS cham o goc, chi nang khi that su bi che" if not fails + else f"{len(fails)} problem(s)") + sys.stdout.flush() + os._exit(1 if fails else 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_graphrag_rescan.py b/tools/check_graphrag_rescan.py new file mode 100644 index 0000000..860a50c --- /dev/null +++ b/tools/check_graphrag_rescan.py @@ -0,0 +1,127 @@ +"""GraphRAG rebuilds when it is opened, not every time a project is picked. + +Switching project used to rescan the folder, redo the force layout and setHtml +the whole D3 page immediately — for a tab usually not on screen. With the rail's +picker one click away from anywhere, that fired constantly, and the rebuild you +did see on opening GraphRAG read as the page reloading itself. +""" +from __future__ import annotations + +import os +import sys +import time + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts) + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + _apply_theme(app) + + from cowork_local.config import CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.config import AppConfig + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1600, 950) + win.show() + app.processEvents() + st, w = win.structure, win.workspace + fails: list[str] = [] + + # startup itself must not build any of it + if st.web is not None or st._graph is not None: + fails.append("dung san do thi ngay luc khoi dong — startup phai nhe") + + # the idle warm-up builds the browser view and the first graph off the + # click path (MainWindow fires this on a 3s timer; call it directly here) + win._prewarm_graph() + deadline = time.monotonic() + 30 + while st._graph is None and time.monotonic() < deadline: + app.processEvents() + time.sleep(0.02) + if st._graph is None: + print("FAIL lam nong khong dung duoc do thi") + sys.stdout.flush() + os._exit(1) + print(f"sau khi lam nong: web={'co' if st.web else 'chua'} do thi={'co' if st._graph else 'chua'}") + + calls = {"scan": 0, "html": 0} + real_scan, real_html = st._scan, st._render_d3 + st._scan = lambda: (calls.__setitem__("scan", calls["scan"] + 1), real_scan())[1] + st._render_d3 = lambda: (calls.__setitem__("html", calls["html"] + 1), real_html())[1] + + # ...so the click itself does nothing but show it — this is the flash + t0 = time.monotonic() + win._goto(win._ROW_WORKSPACE, w._graphrag_tab_idx) + app.processEvents() + print(f"bam vao GraphRAG: {(time.monotonic() - t0) * 1000:.0f}ms " + f"_scan={calls['scan']} setHtml={calls['html']}") + if calls["scan"] or calls["html"]: + fails.append("bam vao GraphRAG van phai quet/nap lai trang — con chop trang") + + # re-entering an unchanged graph must not rebuild anything + for _ in range(3): + win._goto(win._ROW_WORKSPACE, w._project_tab_idx) + app.processEvents() + win._goto(win._ROW_WORKSPACE, w._graphrag_tab_idx) + app.processEvents() + print(f"vao lai 3 lan (khong doi gi): _scan={calls['scan']} setHtml={calls['html']}") + if calls["scan"] or calls["html"]: + fails.append("vao lai GraphRAG van quet/reload du khong co gi doi") + + # switching project off-screen defers the work + win._goto(win._ROW_WORKSPACE, w._cowork_tab_idx) + app.processEvents() + ids = [pid for _n, pid in w.project_choices()] + for pid in ids[1:3]: + w.choose_project(pid) + app.processEvents() + print(f"doi {len(ids[1:3])} project khi dang o Cowork: _scan={calls['scan']} " + f"setHtml={calls['html']} | can quet lan toi={st._needs_scan}") + if calls["scan"]: + fails.append("doi project van quet ngay du GraphRAG khong tren man") + if len(ids) > 1 and not st._needs_scan: + fails.append("doi project ma khong danh dau can quet lai") + + # ...and opening it does the work once + win._goto(win._ROW_WORKSPACE, w._graphrag_tab_idx) + app.processEvents() + print(f"mo GraphRAG: _scan={calls['scan']}") + if len(ids) > 1 and calls["scan"] != 1: + fails.append(f"mo GraphRAG phai quet dung 1 lan, dang {calls['scan']}") + + print() + for f in fails: + print("FAIL " + f) + print("PASS GraphRAG chi dung lai do thi khi can" if not fails + else f"{len(fails)} problem(s)") + sys.stdout.flush() + os._exit(1 if fails else 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_help_dock.py b/tools/check_help_dock.py new file mode 100644 index 0000000..341feef --- /dev/null +++ b/tools/check_help_dock.py @@ -0,0 +1,157 @@ +"""Check the redesigned help dock on the real widget, offscreen. + +The claim being made is a size claim ("84×64 → 26×26"), so this measures the +widget instead of trusting the constants, and confirms that nothing the old +three-button layout could do has gone missing — hiding to the edge just moved +into the panel's ⋯ menu. + +Run: python tools/check_help_dock.py +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E402 + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication, QWidget + + app = QApplication([]) + _load_fonts() + + _apply_theme(app) # measure the styled widget, not a bare one + from cowork_local.config import AppConfig, CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from cowork_local.i18n import set_language, tr + from cowork_local.state import AppContext + from cowork_local.ui.help_agent_widget import HelpAgentWidget + + set_language("vi") + host = QWidget() + host.resize(1200, 800) + dock = HelpAgentWidget(AppContext(AppConfig.load()), host, user_name="local") + app.processEvents() + + fails: list[str] = [] + OLD_W, OLD_H = 84, 64 # 64px badge + 2px gap + 18px chevron + + closed = dock.size() + print(f"dong : {closed.width()}x{closed.height()}px " + f"(cu {OLD_W}x{OLD_H})") + area_new, area_old = closed.width() * closed.height(), OLD_W * OLD_H + print(f"dien tich : {area_new} vs {area_old}px2 " + f"({100 - round(area_new / area_old * 100)}% nho hon)") + # The page draws 26px; the user asked for double. Read the size the module + # declares rather than a number written here, so the two cannot drift, and + # keep the two things that actually matter: it is a square chip, and it is + # still far smaller than the button it replaced. + if closed.width() != closed.height(): + fails.append(f"nut dong khong vuong: {closed.width()}x{closed.height()}") + if area_new >= area_old: + fails.append(f"khong con nho hon nut cu: {area_new} vs {area_old}px2") + # clears the ~24px comfortable-tap floor the old 18px chevron missed + if min(closed.width(), closed.height()) < 24: + fails.append("vung bam nho hon 24px") + + # Hover: the name appears, and only then. + print(f"chu luc dong : {dock.launcher.text()!r} (phai rong)") + if dock.launcher.text().strip(): + fails.append("nut dong ma van hien chu") + dock.launcher._set_open(True) + app.processEvents() + hovered = dock.size() + print(f"re chuot : {hovered.width()}x{hovered.height()}px · " + f"chu = {dock.launcher.text().strip()!r}") + if tr("help_agent.badge") not in dock.launcher.text(): + fails.append("re chuot khong hien 'AI Assistant'") + if hovered.width() <= closed.width(): + fails.append("re chuot ma nut khong no ra") + dock.launcher._set_open(False) + app.processEvents() + if dock.size().width() != closed.width(): + fails.append("roi chuot ma nut khong thu lai") + + # Every state still reachable, and the corner anchor still holds. + for state, call in (("panel", dock._expand), ("launcher", dock._collapse), + ("hidden", dock._hide_to_edge), ("launcher", dock._show_launcher)): + call() + app.processEvents() + got = dock._state + inside = (dock.x() + dock.width() <= host.width() + and dock.y() + dock.height() <= host.height()) + print(f"trang thai {state:9}: {got:9} {dock.width():3}x{dock.height():3} " + f"goc phai duoi = {inside}") + if got != state: + fails.append(f"khong vao duoc trang thai {state}") + if not inside: + fails.append(f"trang thai {state} tran ra ngoai cua so") + + # The edge tab was 16px — below anything comfortable to hit. + dock._hide_to_edge() + app.processEvents() + print(f"tab mep : {dock.width()}px (cu 16px)") + if dock.width() < 24: + fails.append(f"tab mep {dock.width()}px, van duoi 24px") + dock._show_launcher() + + # The ⋯ menu is gone (user's call — its two entries were "collapse", which + # the − button beside it already did, and "hide"). Nothing was lost with it: + # collapse is the − button and the dot, hide is a right-click on either the + # dot or the open panel's header. Check the routes, not the menu. + from PySide6.QtCore import Qt as _Qt + + if hasattr(dock, "more_btn"): + fails.append("menu ⋯ van con tren panel") + dock._collapse() + app.processEvents() + if dock._state != "launcher": + fails.append("nut − khong thu nho duoc ve cham") + if dock.launcher.contextMenuPolicy() != _Qt.CustomContextMenu: + fails.append("cham khong co menu chuot phai de an") + dock._hide_to_edge() + app.processEvents() + if dock._state != "hidden": + fails.append("khong an duoc vao canh phai") + print(f"thu nho + an : ca hai duong deu chay (trang thai cuoi={dock._state!r})") + dock._show_launcher() + app.processEvents() + print(f"nut thu nho : {dock.min_btn.toolTip()!r}") + print(f"nut gui / o nhap: {dock.send_btn is not None} / {dock.input is not None}") + + # All three languages must have the new strings. + for lang in ("vi", "en", "ja"): + set_language(lang) + dock.retranslate() + # more_tooltip went with the ⋯ menu; dot_hint replaces it as the + # string that tells you the right-click is there. + vals = [dock.launcher.toolTip(), tr("help_agent.badge"), + tr("help_agent.dot_hint")] + print(f" {lang}: badge={vals[1]!r} goi y chuot phai={vals[2]!r}") + if any(not v or v.startswith("help_agent.") for v in vals): + fails.append(f"thieu ban dich cho {lang}") + set_language("vi") + + print() + if fails: + print("*** LOI ***") + for f in fails: + print(" " + f) + return 1 + print("KET QUA: nut tro ly gon lai, khong mat chuc nang nao") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_help_i18n.py b/tools/check_help_i18n.py new file mode 100644 index 0000000..adc9f4e --- /dev/null +++ b/tools/check_help_i18n.py @@ -0,0 +1,80 @@ +"""The help panel must follow a language switch — transcript included. + +retranslate() re-labelled the chrome but not the rendered HTML transcript, so +the greeting and the "AI Assistant" speaker label stayed in the language the +panel happened to be built in. +""" +from __future__ import annotations + +import os +import re +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts) + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + sys.stdout.reconfigure(encoding="utf-8") # ja/vi text on a cp932 console + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + _apply_theme(app) + + from cowork_local.config import CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.config import AppConfig + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1400, 900) + win.show() + app.processEvents() + + panel = win.help_agent + fails = [] + for lang in ("en", "ja", "vi"): + win._set_language(lang) if hasattr(win, "_set_language") else set_language(lang) + if not hasattr(win, "_set_language"): + panel.retranslate() + app.processEvents() + + # The panel greets by the signed-in name, not the placeholder. + want = panel._greeting() + shown = re.sub("<[^>]+>", " ", panel.log.toHtml()) + shown = " ".join(shown.split()) + # compare on the stable half of the sentence, the name is substituted + probe = " ".join(want.split())[:28] + state = "ok" if probe and probe in shown else "MISSING" + print(f"{lang}: title={panel.title.text()!r} greeting={state}") + if state != "ok": + print(f" muon: {probe!r}") + print(f" thay: {shown[:160]!r}") + if state != "ok": + fails.append(f"{lang}: transcript still shows another language") + + print() + print("PASS panel follows the language" if not fails + else "\n".join(f"FAIL {f}" for f in fails)) + sys.stdout.flush() + os._exit(1 if fails else 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_icons_screen.py b/tools/check_icons_screen.py new file mode 100644 index 0000000..4dcf57f --- /dev/null +++ b/tools/check_icons_screen.py @@ -0,0 +1,108 @@ +"""The Icon library screen against its wireframe. + +The drawing (section 16) puts the three actions on the title row, a magnifier +in the search box, caps section headings, and — its stated complaint — a +visible edge on an icon cell when you hover or select it, so you can tell what +you are about to pick. +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts) + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtCore import QPoint + from PySide6.QtWidgets import QApplication, QLineEdit + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + _apply_theme(app) + + from cowork_local.config import CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from cowork_local.config import AppConfig + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + from cowork_local.ui.icons_admin_tab import IconsAdminTab + + set_language("vi") + tab = IconsAdminTab(AppContext(AppConfig.load())) + tab.resize(1100, 700) + tab.show() + app.processEvents() + + fails = [] + + # 1. actions on the title row, to its right — not in a strip below the + # grids. Comparing y alone was not enough: a button left out of the + # layout sits at (0,0), which is "the same row" by accident. + title = tab._title + t_pos = title.mapTo(tab, QPoint(0, 0)) + t_mid = t_pos.y() + title.height() // 2 + t_right = t_pos.x() + title.width() + for name, btn in (("Thêm", tab.add_btn), ("Dán", tab.paste_btn), + ("Xóa", tab.del_btn)): + pos = btn.mapTo(tab, QPoint(0, 0)) + mid = pos.y() + btn.height() // 2 + aligned = abs(mid - t_mid) <= 6 + after = pos.x() >= t_right + print(f"nut {name:5}: tam y={mid} (tieu de {t_mid}) thang hang={aligned} " + f"| x={pos.x()} (sau tieu de {t_right})={after}") + if not (aligned and after): + fails.append(f"nut {name} khong nam cung hang, ben phai tieu de") + + # 2. magnifier in the search box + lead = tab.search.actions() + print(f"o tim co icon kinh lup: {bool(lead)}") + if not lead: + fails.append("o tim thieu icon kinh lup") + + # 3. caps headings + for name, lbl in (("tich hop", tab._builtin_lbl), ("tuy chinh", tab._custom_lbl)): + text = lbl.text() + print(f"tieu de {name}: {text!r}") + if text != text.upper(): + fails.append(f"tieu de {name} chua viet hoa: {text!r}") + + # 4. the cell has an edge to see — compare the painted cell hovered vs not + grid = tab.builtin_grid + if grid.count(): + rect = grid.visualItemRect(grid.item(0)) + plain = grid.grab(rect).toImage() + grid.setCurrentRow(0) + app.processEvents() + picked = grid.grab(rect).toImage() + diff = sum(1 for x in range(plain.width()) for y in range(plain.height()) + if plain.pixelColor(x, y) != picked.pixelColor(x, y)) + print(f"o icon doi {diff} diem anh khi duoc chon") + if diff == 0: + fails.append("o icon khong doi gi khi duoc chon — khong thay minh dang chon cai nao") + else: + fails.append("luoi icon rong") + + print() + for f in fails: + print("FAIL " + f) + print("PASS man Icon khop ban ve" if not fails else f"{len(fails)} problem(s)") + sys.stdout.flush() + os._exit(1 if fails else 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_kanban_scroll.py b/tools/check_kanban_scroll.py new file mode 100644 index 0000000..a050548 --- /dev/null +++ b/tools/check_kanban_scroll.py @@ -0,0 +1,94 @@ +"""Schedule Task must not scroll sideways on a screen the app supports. + +Two separate causes, both reported as "some screens scroll, some don't": + · each lane is a QListWidget whose column hint runs a few px past its own + viewport, so individual lanes grew a scrollbar at most window widths; + · the seven lanes together wanted 1242px where a 1280 window leaves 1091. +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts) + +# Smallest screen the app is expected to run on, and the rail at both extremes. +SIZES = [(1280, 720), (1366, 768), (1600, 900), (1920, 1080), (1936, 1048)] + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QAbstractScrollArea, QApplication, QScrollArea + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + _apply_theme(app) + + from cowork_local.config import CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.config import AppConfig + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.show() + app.processEvents() + + fails = [] + for w, h in SIZES: + # 150 is the default and 240 a realistic widening. 360 (the maximum) + # on a 1280 screen leaves 920px for seven lanes that need 1067 — that + # scroll is the user's own trade, so it is reported, not failed. + for rail in (150, 240, 360): + win.resize(w, h) + app.processEvents() + win.split.setSizes([rail, max(1, w - rail)]) + win._goto(win._ROW_SCHEDULE, None) + app.processEvents() + + page = [p for p in win._page_widgets + if p is not None and hasattr(p, "counts_lbl")][0] + outer = page.findChild(QScrollArea) + lanes = [s for s in win.findChildren(QAbstractScrollArea) + if s.isVisible() and s.__class__.__name__ == "_KanbanColumn"] + spill = outer.horizontalScrollBar().maximum() + lane_spill = [s.horizontalScrollBar().maximum() for s in lanes] + worst = max(lane_spill) if lane_spill else 0 + print(f"{w}x{h} rail={rail:<4}: {len(lanes)} lan rong " + f"{lanes[0].width() if lanes else 0:>4} | vung ngoai thua {spill:>4}px " + f"| lan thua toi da {worst}px") + if spill and rail < 360: + fails.append(f"{w}x{h} rail={rail}: 7 lan tran {spill}px") + elif spill: + print(f" (rail keo het co: nguoi dung tu chon, thua {spill}px)") + if worst: + fails.append(f"{w}x{h} rail={rail}: mot lan tu tran {worst}px") + + print() + for f in fails: + print("FAIL " + f) + print("PASS khong cuon ngang o moi co man hinh da thu" if not fails + else f"{len(fails)} problem(s)") + sys.stdout.flush() + os._exit(1 if fails else 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_layout_geometry.py b/tools/check_layout_geometry.py new file mode 100644 index 0000000..e806a5e --- /dev/null +++ b/tools/check_layout_geometry.py @@ -0,0 +1,213 @@ +"""Round 2: does the built layout have the SHAPE the wireframes draw? + +Round 1 asks "does the feature exist". A screen can pass that and still be laid +out wrongly — right widgets, wrong order, wrong side, wrong proportions. This +round measures real geometry against what the audit page's wireframes depict: +reading order of the rail, section order down Monitoring, which side each pane +is on, and the size relationships the design calls out (hero card, the dot). + +Run: python tools/check_layout_geometry.py +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 + +# The rail, top to bottom, as the audit page's rail() helper draws it. +RAIL_ORDER = ["Project", "Cowork", "Co4E", "Thư mục", "GraphRAG", "Schedule Task"] +RAIL_BOTTOM = ["Dashboard", "Giám sát"] +# Monitoring ▸ Tổng quan, in the order the wireframe stacks it: what it cost → +# what the machine is doing → what the agent may touch → per-model prices → +# what actually happened. +MON_ORDER = ["ov_usage_group", "ov_resource_group", "ov_sandbox_details_group", + "ov_pricing_group", "ov_activity_group", "ov_audit_group"] + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + + _apply_theme(app) # measure the styled window, not a bare one + from cowork_local.config import AppConfig, CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1600, 950) + win.show() + for _ in range(8): + app.processEvents() + ws = win.workspace + fails: list[str] = [] + + def top_of(w, ref): + return w.mapTo(ref, w.rect().topLeft()).y() + + def left_of(w, ref): + return w.mapTo(ref, w.rect().topLeft()).x() + + # --- 1. rail: reading order, and the rail is on the LEFT --------------- + rows = [win.nav.topLevelItem(i).text(0) for i in range(win.nav.topLevelItemCount())] + bottom = [win.nav_bottom.topLevelItem(i).text(0) + for i in range(win.nav_bottom.topLevelItemCount())] + print(f"thanh menu : {rows}") + print(f"nhom day : {bottom}") + if rows != RAIL_ORDER: + fails.append(f"thu tu thanh menu lech: {rows} != {RAIL_ORDER}") + if bottom != RAIL_BOTTOM: + fails.append(f"thu tu nhom day lech: {bottom} != {RAIL_BOTTOM}") + rail_x = left_of(win._nav_wrap, win) + content_x = left_of(win.pages, win) + print(f"rail x={rail_x} · noi dung x={content_x}") + if rail_x >= content_x: + fails.append("rail khong nam ben trai noi dung") + + # --- 2. rail header order: picker ABOVE the new-chat button ------------ + py, by = top_of(win.nav_project, win), top_of(win.nav_new_chat, win) + ry = top_of(win.nav_recents, win) + ay = top_of(win._account_row, win) + print(f"bo chon y={py} · nut chat moi y={by} · GAN DAY y={ry} · tai khoan y={ay}") + if not (py < by < ry < ay): + fails.append("thu tu doc cua rail sai (bo chon → chat moi → GAN DAY → tai khoan)") + + # --- 3. Monitoring: one column, sections in the drawn order ------------ + win._goto(win._ROW_MONITORING, None) + for _ in range(8): + app.processEvents() + mon = win._page_widgets[win._ROW_MONITORING] + tops = [(n, top_of(getattr(mon, n), mon)) for n in MON_ORDER if getattr(mon, n, None)] + lefts = {n: left_of(getattr(mon, n), mon) for n, _y in tops} + print("Monitoring, tu tren xuong:") + for n, y in tops: + print(f" {n:28} y={y:5} x={lefts[n]}") + if [n for n, _ in sorted(tops, key=lambda t: t[1])] != [n for n, _ in tops]: + fails.append("thu tu muc trong Monitoring khong khop ban ve") + # Sandbox and Permissions share a row; everything else is full width. + perm_y = top_of(mon.ov_permissions_group, mon) + sbx_y = top_of(mon.ov_sandbox_details_group, mon) + same_row = abs(perm_y - sbx_y) < 20 + print(f"Sandbox | Quyen cung hang: {same_row}") + if not same_row: + fails.append("Sandbox va Quyen khong cung mot hang") + price_w = mon.ov_pricing_group.width() + res_w = mon.ov_resource_group.width() + print(f"bang gia rong {price_w}px · tai nguyen {res_w}px (deu tron be ngang)") + if price_w < res_w * 0.95: + fails.append("bang gia model khong chiem tron be ngang") + + # --- 3b. Schedule: all seven lanes on screen, no horizontal scroll ----- + win._goto(win._ROW_SCHEDULE, None) + for _ in range(8): + app.processEvents() + sched = win._page_widgets[win._ROW_SCHEDULE] + from PySide6.QtWidgets import QScrollArea + lanes = list(sched.columns.values()) + # The page holds more than one scroll area — take the one the lanes live in. + board = next(sa for sa in sched.findChildren(QScrollArea) + if sa.isAncestorOf(lanes[0])) + rightmost = max(left_of(c, board.widget()) + c.width() for c in lanes) + fits = rightmost <= board.viewport().width() + 2 + print(f"Schedule: {len(lanes)} lane · mep phai x={rightmost} · " + f"khung rong {board.viewport().width()} · vua mot man = {fits}") + if len(lanes) != 7: + fails.append(f"chi co {len(lanes)} lane, thiet ke la 7") + if not fits: + fails.append(f"lane thu 7 nam ngoai man ({rightmost} > " + f"{board.viewport().width()}) — phai cuon ngang") + + # --- 4. Dashboard: hero left, taller; supporting tiles in a 2x2 -------- + win._goto(win._ROW_DASHBOARD, None) + for _ in range(8): + app.processEvents() + dash = win._page_widgets[win._ROW_DASHBOARD] + hero, small = dash.card_cost, dash.card_total + print(f"the Chi phi : x={left_of(hero, dash)} cao={hero.height()} · " + f"the phu x={left_of(small, dash)} cao={small.height()}") + if left_of(hero, dash) >= left_of(small, dash): + fails.append("the Chi phi khong nam ben trai cac the phu") + if hero.height() < small.height() * 1.5: + fails.append("the Chi phi khong cao gap ruoi the phu") + row1 = top_of(dash.card_total, dash) + row2 = top_of(dash.card_out, dash) + print(f"the phu hang 1 y={row1} · hang 2 y={row2} (phai la 2 hang)") + if row2 <= row1: + fails.append("4 the phu khong xep 2x2") + + # --- 5. Cowork: the dot clears the composer, and is the declared size -- + win._goto(win._ROW_WORKSPACE, ws._cowork_tab_idx) + for _ in range(8): + app.processEvents() + dock = win.help_agent + comp = ws._cowork.composer + dock_bottom = top_of(dock, win) + dock.height() + comp_top = top_of(comp, win) + print(f"cham {dock.width()}x{dock.height()} · day y={dock_bottom} · o nhap dinh y={comp_top}") + # Reading _DOT and comparing against it makes this unfailable — change the + # constant and the expectation moves with it (check_probes_bite caught + # exactly that). Bound what the design actually claims instead: a square + # chip, big enough to hit, far smaller than the 84x64 button it replaced. + # 26px was drawn, 52px is what the user asked for; 64 is the ceiling past + # which "gọn" stops being true. + if not 24 <= dock.width() <= 64 or dock.width() != dock.height(): + fails.append(f"cham tro ly {dock.width()}x{dock.height()}px, " + f"cho o khoang 24..64 va phai vuong") + if dock_bottom > comp_top: + fails.append("cham tro ly de len o nhap") + if left_of(dock, win) + dock.width() > win.width(): + fails.append("cham tro ly tran ra ngoai cua so") + + # --- 6. Co4E: sidebar left, canvas middle, config right --------------- + win._goto(win._ROW_WORKSPACE, ws._co4e_tab_idx) + for _ in range(8): + app.processEvents() + import cowork_local.ui.co4e_tab as co4e_mod + c4 = win.findChildren(co4e_mod.Co4ETab)[0] + xs = [c4._split.widget(i).x() for i in range(c4._split.count())] + print(f"Co4E 3 pane x = {xs}") + if xs != sorted(xs): + fails.append("thu tu 3 pane cua Co4E sai (trai → giua → phai)") + heads = [h.text() for h, _b, _s in c4._sections.values()] + print(f"cot sidebar: {heads}") + if len(heads) != 4: + fails.append(f"cot sidebar co {len(heads)} muc, ban ve ve 4") + + print() + if fails: + print("*** LECH BO CUC ***") + for f in fails: + print(" " + f) + return 1 + print("KET QUA VONG 2: hinh hoc khop ban ve") + return 0 + + +if __name__ == "__main__": + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_multi_screen.py b/tools/check_multi_screen.py new file mode 100644 index 0000000..b684efb --- /dev/null +++ b/tools/check_multi_screen.py @@ -0,0 +1,133 @@ +"""Does the layout adapt across screen sizes AND display scalings? + +Two things change between machines, and only one of them is width: + + * the screen is bigger or smaller — more or fewer pixels to lay out in; + * the display scale is 100/125/150% — the SAME number of logical pixels + holds less, because every label and margin is taller. + +A breakpoint written as a raw pixel number only holds on the machine it was +tuned on. This walks a grid of (window size × font scale) and, for each cell, +checks that no screen is clipped and that the panes folded when they had to. + +Run: python tools/check_multi_screen.py +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 + +# Real-world panels, from a small laptop up to 4K-at-150%-effective. +SIZES = [(1280, 720), (1366, 768), (1600, 900), (1920, 1080), (2560, 1440)] +# 9pt ≈ 100%, 11pt ≈ 125%, 14pt ≈ 150% of the design baseline. +POINTS = [9, 11, 14] + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtGui import QFont + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + + _apply_theme(app) # measure the styled window, not a bare one + from cowork_local.config import AppConfig, CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + from cowork_local.ui.widgets import ui_scale + + set_language("vi") + fails: list[str] = [] + print(f"{'co chu':>7} {'cua so':>11} {'thang do':>9} {'man bi bo':>10} panel da gap") + print("-" * 86) + + for pt in POINTS: + f = QFont(app.font()) + f.setPointSize(pt) + app.setFont(f) + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.show() + for _ in range(6): + app.processEvents() + ws = win.workspace + dests = [("Project", win._ROW_WORKSPACE, ws._project_tab_idx), + ("Cowork", win._ROW_WORKSPACE, ws._cowork_tab_idx), + ("Co4E", win._ROW_WORKSPACE, ws._co4e_tab_idx), + ("Folder", win._ROW_WORKSPACE, ws._folder_tab_idx), + ("GraphRAG", win._ROW_WORKSPACE, ws._graphrag_tab_idx), + ("Schedule", win._ROW_SCHEDULE, None), + ("Dashboard", win._ROW_DASHBOARD, None), + ("Monitoring", win._ROW_MONITORING, None)] + + for w, h in SIZES: + win.resize(w, h) + for _ in range(6): + app.processEvents() + clipped = [] + for name, page, sub in dests: + win._goto(page, sub) + for _ in range(4): + app.processEvents() + widget = win._page_widgets[page] + if widget.minimumSizeHint().width() > widget.width() + 1: + clipped.append(name) + folded = [] + if getattr(ws, "_is_narrow", False): + folded.append("pane Project/History") + import cowork_local.ui.co4e_tab as co4e_mod + c4 = win.findChildren(co4e_mod.Co4ETab)[0] + if c4._config_collapsed: + folded.append("panel cau hinh Co4E") + scale = ui_scale(win) + print(f"{pt:>5}pt {w:>5}x{h:<5} {scale:>8.2f} " + f"{(', '.join(clipped) or 'khong'):>10} {', '.join(folded) or '-'}") + if clipped: + fails.append(f"{pt}pt {w}x{h}: bi bo — {clipped}") + # The window must never demand more than the smallest panel we support. + need = win.minimumSizeHint().width() + if need > SIZES[0][0]: + fails.append(f"{pt}pt: cua so doi toi thieu {need}px, " + f"rong hon man nho nhat ({SIZES[0][0]}px)") + print(f"{'':>7} {'':>11} {'':>9} cua so doi toi thieu: {need}px") + win.close() + del win + for _ in range(3): + app.processEvents() + + print() + if fails: + print("*** KHONG THICH UNG DUOC ***") + for x in fails: + print(" " + x) + return 1 + print("KET QUA: bo cuc thich ung o moi co man hinh va muc phong chu da thu") + return 0 + + +if __name__ == "__main__": + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_nav.py b/tools/check_nav.py new file mode 100644 index 0000000..8fe0a41 --- /dev/null +++ b/tools/check_nav.py @@ -0,0 +1,342 @@ +"""Smoke-test the flat nav rail against a real MainWindow. + +Builds the window offscreen on a COPY of ~/.cowork_local (schedulers no-oped, so +nothing scheduled can fire) and answers the questions the redesign has to get +right: + + * is every destination that used to be reachable still reachable? + * does the rail highlight follow the content, from clicks AND from _goto? + * do the project-gated rows stay listed (greyed) instead of disappearing? + * does Monitoring still expose all eight sub-views, now via its own tab strip? + +Run: python tools/check_nav.py +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) # `import cowork_local` +sys.path.insert(0, str(Path(__file__).resolve().parent)) # sibling tools +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 + + +def rows(tree): + from PySide6.QtCore import Qt + out = [] + for i in range(tree.topLevelItemCount()): + it = tree.topLevelItem(i) + data = it.data(0, Qt.UserRole) or {} + out.append((it.text(0), data.get("page"), data.get("sub"), + not it.isDisabled())) + return out + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + + _apply_theme(app) # measure the styled window, not a bare one + from cowork_local.config import CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.config import AppConfig + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + app.processEvents() + + fails: list[str] = [] + print("THANH MENU CHINH") + for label, page, sub, on in rows(win.nav): + print(f" {label:22} page={page} sub={sub} {'' if on else '(mo — chua chon project)'}") + print("NHOM GHIM DAY") + for label, page, sub, on in rows(win.nav_bottom): + print(f" {label:22} page={page} sub={sub}") + print(f"NUT: {win._nav_settings_btn.text()}") + print() + + main_rows, bottom_rows = rows(win.nav), rows(win.nav_bottom) + n_total = len(main_rows) + len(bottom_rows) + # Five Workspace sub-views + Schedule, then Dashboard + Monitoring. + if len(main_rows) != 6: + fails.append(f"thanh chinh co {len(main_rows)} dong, cho 6") + if len(bottom_rows) != 2: + fails.append(f"nhom day co {len(bottom_rows)} dong, cho 2") + if any(sub is not None for _l, _p, sub, _o in bottom_rows): + fails.append("nhom day khong duoc mang sub-tab") + + # The two gated rows must be PRESENT (that is the point) — greyed is fine. + labels = [r[0] for r in main_rows] + ws_labels = [lab for lab, _i, _ic, _on in win.workspace.nav_entries()] + for lab in ws_labels: + if lab not in labels: + fails.append(f"mat dong Workspace: {lab}") + print(f"du 5 man Workspace tren thanh menu: {all(l in labels for l in ws_labels)}" + f" ({', '.join(ws_labels)})") + + # Highlight must follow the content for every row, both ways round. + # Re-fetch items by index every time: navigating can rebuild the rail, which + # deletes the C++ objects a held reference points at. + ok_click = ok_goto = 0 + for which, name in ((win.nav, "chinh"), (win.nav_bottom, "day")): + for i in range(which.topLevelItemCount()): + label, page, sub, on = rows(which)[i] + if not on: + continue + which.setCurrentItem(which.topLevelItem(i)) # as if clicked + app.processEvents() + if win.pages.currentIndex() == page: + ok_click += 1 + else: + fails.append(f"bam '{label}' ({name}) khong mo dung trang") + win._goto(page, sub) # programmatic + app.processEvents() + cur = next((t.currentItem() for t in (win.nav, win.nav_bottom) + if t.currentItem() is not None and t.currentItem().isSelected()), None) + if cur is not None and cur.text(0) == label: + ok_goto += 1 + else: + fails.append(f"_goto toi '{label}' nhung vet sang o " + f"'{cur.text(0) if cur else 'khong dau'}'") + n_live = sum(1 for r in main_rows + bottom_rows if r[3]) + print(f"bam mo dung trang : {ok_click}/{n_live}") + print(f"vet sang theo _goto : {ok_goto}/{n_live}") + + # Only one row may look active across the two lists. + lit = sum(1 for t in (win.nav, win.nav_bottom) for i in range(t.topLevelItemCount()) + if t.topLevelItem(i).isSelected()) + print(f"so dong dang sang : {lit} (phai la 1)") + if lit != 1: + fails.append(f"{lit} dong cung sang") + + # Monitoring's eight sub-views moved to its own tab strip — check it is shown. + win._ensure_page(win._ROW_MONITORING) + mon = win._page_widgets[win._ROW_MONITORING] + # isVisible() is False for everything while the window has never been shown; + # isHidden() asks the question that actually matters here. + strip_visible = not mon.tabs.tabBar().isHidden() if hasattr(mon, "tabs") else False + n_sub = len(mon.nav_subtabs()) + print(f"Monitoring: {n_sub} man, dai tab hien = {strip_visible}") + if n_sub != 8: + fails.append(f"Monitoring chi con {n_sub} man") + if not strip_visible: + fails.append("dai tab Monitoring van bi an — 8 man khong toi duoc") + + # Workspace's own strip stays hidden: the rail lists those five instead. + ws_strip = not win.workspace.tabs.tabBar().isHidden() + print(f"Workspace: dai tab hien = {ws_strip} (phai la False — thanh menu lo roi)") + if ws_strip: + fails.append("dai tab Workspace hien lai — trung voi thanh menu") + + # The whole point of the change: with no project selected the two gated rows + # must stay in place, greyed — not vanish and resize the menu. + win.workspace._update_tab_visibility(False) + app.processEvents() + gated = rows(win.nav) + off = [lab for lab, _p, _s, on in gated if not on] + print() + print(f"chua chon project : van du {len(gated)} dong, mo: {off or 'khong'}") + if len(gated) != len(main_rows): + fails.append(f"chua chon project thi thanh menu con {len(gated)} dong " + f"(truoc {len(main_rows)}) — item van bien mat") + if len(off) != 2: + fails.append(f"cho 2 dong bi mo (Cowork, GraphRAG), thay {len(off)}") + + # --- rail header: project picker + new chat (Phase A) ------------------ + print() + n_proj = win.nav_project.count() + print(f"bo chon project : {n_proj} muc · dang chon " + f"{win.nav_project.currentText()!r}") + print(f"nut chat moi : {win.nav_new_chat.text()!r} " + f"(bat = {win.nav_new_chat.isEnabled()})") + if win.nav_project.currentData() != win.workspace.selected_project_id(): + fails.append("bo chon project khong khop voi project dang chon") + + # Picking in the rail must move the real selection, not just the combo. + if n_proj > 1: + other = next(i for i in range(n_proj) + if win.nav_project.itemData(i) != win.workspace.selected_project_id()) + want = win.nav_project.itemData(other) + win.nav_project.setCurrentIndex(other) + app.processEvents() + got = win.workspace.selected_project_id() + print(f"doi project tu rail: chon {want} -> workspace dang o {got}") + if got != want: + fails.append("doi project tren rail khong doi project that") + if win.nav_project.currentData() != got: + fails.append("bo chon khong dong bo nguoc lai") + + # New chat from any screen: lands on Cowork with an empty thread, and the + # old toolbar button must still be there. + win._goto(win._ROW_DASHBOARD, None) + app.processEvents() + before = win.cowork.current_session_id() if hasattr(win.cowork, "current_session_id") else None + win._on_rail_new_chat() + app.processEvents() + on_cowork = (win.pages.currentIndex() == win._ROW_WORKSPACE + and win.workspace.current_subtab() == win.workspace._cowork_tab_idx) + print(f"bam '+ chat moi' tu Dashboard -> dung o Cowork: {on_cowork}") + if not on_cowork: + fails.append("nut chat moi khong dua toi Cowork") + old_btn = getattr(win.cowork, "_new_btn", None) + print(f"nut cu tren thanh Cowork con nguyen: {old_btn is not None} " + f"({old_btn.text()!r})" if old_btn is not None else "MAT NUT CU") + if old_btn is None: + fails.append("nut 'Cuoc tro chuyen moi' cu tren Cowork bi mat") + + # --- rail RECENTS (Phase B) -------------------------------------------- + from PySide6.QtCore import Qt as _Qt + win._refresh_rail_recents() + app.processEvents() + rec = win.nav_recents + items = [(rec.topLevelItem(i).text(0), rec.topLevelItem(i).data(0, _Qt.UserRole) or {}) + for i in range(rec.topLevelItemCount())] + threads = [t for t, d in items if d.get("path")] + print() + print(f"GAN DAY ({win.nav_recents_hdr.text()}): {len(threads)} thread" + f" + dong '{items[-1][0]}'") + for t in threads: + print(f" {t}") + if not items[-1][1].get("all"): + fails.append("thieu dong 'Tat ca project…'") + if len(threads) > win._RAIL_RECENTS: + fails.append(f"GAN DAY liet ke {len(threads)} thread, toi da {win._RAIL_RECENTS}") + + # Scoped to the active project — a flat cross-project list would lose that. + pid = win.workspace.selected_project_id() + all_titles = {t["title"] for t in win.workspace.recent_threads(99)} + other_pid = next((p for _n, p in win.workspace.project_choices() if p != pid), "") + if other_pid: + win.workspace.choose_project(other_pid) + app.processEvents() + win._refresh_rail_recents() + other_titles = {t["title"] for t in win.workspace.recent_threads(99)} + print(f"doi sang project khac: danh sach doi = {other_titles != all_titles}") + if other_titles & all_titles and other_titles == all_titles: + fails.append("GAN DAY khong gom theo project — hai project cung mot danh sach") + win.workspace.choose_project(pid) + app.processEvents() + win._refresh_rail_recents() + + # Clicking a thread must open it through the normal route. + if threads: + win._goto(win._ROW_DASHBOARD, None) + app.processEvents() + # Re-fetch: the project switch above rebuilt this list, deleting the + # items a held reference would point at. + win._on_rail_recent(win.nav_recents.topLevelItem(0)) + app.processEvents() + on_cowork = (win.pages.currentIndex() == win._ROW_WORKSPACE + and win.workspace.current_subtab() == win.workspace._cowork_tab_idx) + print(f"bam thread gan day -> mo o Cowork: {on_cowork}") + if not on_cowork: + fails.append("bam thread trong GAN DAY khong mo duoc") + + # The full History panel must still exist, with all its controls. + sb = win.sidebar + kept = [n for n in ("search_box", "search_btn", "tree", "_collapse_btn") + if getattr(sb, n, None) is not None] + print(f"khung History day du van con: {len(kept)}/4 control goc {kept}") + if len(kept) != 4: + fails.append("khung History bi mat control") + + # --- account row moved off the top bar (Phase C) ----------------------- + print() + from PySide6.QtWidgets import QWidget as _QWidget + top_kids = {w.objectName() or type(w).__name__ + for w in win.findChildren(_QWidget) + if w.parent() is not None and w.parent().objectName() == "topbar"} + print(f"top bar con lai : {sorted(top_kids)}") + for name in ("provider_combo", "language_combo", "theme_btn"): + w = getattr(win, name, None) + if w is None: + fails.append(f"mat control {name}") + continue + in_rail = win._nav_wrap.isAncestorOf(w) + print(f" {name:16} nam trong rail = {in_rail}") + if not in_rail: + fails.append(f"{name} chua chuyen xuong rail") + # They must still work, not just exist: flipping the language must retranslate. + from cowork_local.i18n import get_language + before_lang = get_language() + other = next(i for i in range(win.language_combo.count()) + if win.language_combo.itemData(i) != before_lang) + win.language_combo.setCurrentIndex(other) + app.processEvents() + after_lang = get_language() + print(f"doi ngon ngu tu rail: {before_lang} -> {after_lang}") + if after_lang == before_lang: + fails.append("combo ngon ngu o rail khong doi duoc ngon ngu") + win.language_combo.setCurrentIndex(win.language_combo.findData(before_lang)) + app.processEvents() + print(f"provider dang chon : {win.provider_combo.currentText()!r}") + print(f"tai khoan : {win.account_lbl.text()!r}") + + # Collapsing the rail must not take the project picker away with it: at + # 54px the combo cannot show a name, so a folder button stands in for it. + if not win._nav_collapsed: + win._toggle_nav() + app.processEvents() + mini = getattr(win, "nav_project_btn", None) + if mini is None or mini.isHidden(): + fails.append("thu gon rail xong khong con cach nao doi project") + else: + mini.menu().aboutToShow.emit() + app.processEvents() + menu_items = [a.text() for a in mini.menu().actions()] + combo_items = [win.nav_project.itemText(i) + for i in range(win.nav_project.count())] + if menu_items != combo_items: + fails.append(f"menu project khi thu gon lech voi combo: " + f"{menu_items} vs {combo_items}") + elif len(menu_items) > 1: + before = win.workspace.selected_project_id() + # Any row but the one already selected, or nothing would change. + row = (win.nav_project.currentIndex() + 1) % len(menu_items) + mini.menu().actions()[row].trigger() + app.processEvents() + after = win.workspace.selected_project_id() + if after == before: + fails.append("chon project tu menu thu gon khong doi project") + else: + print(f"doi project khi thu gon: {before} -> {after}") + win._toggle_nav() + app.processEvents() + + print() + print(f"tong dong dieu huong: {n_total} + nut Cai dat") + if fails: + print("*** LOI ***") + for f in fails: + print(" " + f) + return 1 + print("KET QUA: thanh menu phang chay dung") + return 0 + + +if __name__ == "__main__": + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_no_hscroll.py b/tools/check_no_hscroll.py new file mode 100644 index 0000000..70bf2f9 --- /dev/null +++ b/tools/check_no_hscroll.py @@ -0,0 +1,156 @@ +"""Prove the long dialogs never scroll sideways — including at large fonts. + +The report that started this came from a display at 125–150% scaling, where +every label is wider than on a 100% screen. Rather than trusting one font size, +this runs each dialog at several point sizes and several widths and fails if any +horizontal scrollbar turns up, in the scroll area or in the section index. + +Run: python tools/check_no_hscroll.py +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E402 + +WIDTHS = (1100, 964, 820, 700) +POINTS = (9, 11, 14) # ~100%, ~125%, ~150% display scaling + + +def hscroll(dlg, app): + """(scroll-area overflow, index overflow) — each True means content is + wider than the space it is given. + + A dialog built from step tabs has one scroll area per page, and a page that + is not current has stale geometry — so each tab is brought to the front + before its page is measured. + """ + from PySide6.QtWidgets import QListWidget, QScrollArea + over_area = False + stack = getattr(dlg, "section_stack", None) + if stack is not None: + # One scroll area per section; a page that is not current has stale + # geometry, so bring each to the front before measuring it. + idx = dlg.section_list + keep = idx.currentRow() + for i in range(stack.count()): + idx.setCurrentRow(i) + for _ in range(3): + app.processEvents() + sa = stack.widget(i) + if sa.widget().sizeHint().width() > sa.viewport().width(): + over_area = True + idx.setCurrentRow(keep) + else: + sa = dlg.findChildren(QScrollArea)[0] + over_area = sa.widget().sizeHint().width() > sa.viewport().width() + idx = dlg.findChild(QListWidget, "sectionIndex") + over_idx = False + if idx is not None: + over_idx = _index_elides(idx) + return over_area, over_idx + + +def _index_elides(idx) -> bool: + """True when a section name does not fit the visible width of the list. + + Two earlier attempts got this wrong: + · `sizeHintForColumn(0) > viewport().width()` returns 182px at 9pt, 11pt + and 14pt alike — it does not track the font, so it called the 9pt + dialog broken while nothing on screen was clipped. + · Asking the delegate whether it elides. It does not: the view lays each + row out at its natural width and the viewport simply clips what runs + past it, so a list squeezed to 90px still reported "no elision". + + So compare the painted text against the width that is actually on screen. + """ + from PySide6.QtGui import QFontMetrics + from PySide6.QtWidgets import QStyle, QStyleOptionViewItem + + for i in range(idx.count()): + row = idx.indexFromItem(idx.item(i)) + opt = QStyleOptionViewItem() + idx.initViewItemOption(opt) + opt.rect = idx.visualRect(row) + idx.itemDelegate().initStyleOption(opt, row) + box = idx.style().subElementRect(QStyle.SE_ItemViewItemText, opt, idx) + label = idx.item(i).text() + visible = idx.viewport().width() - box.left() + if QFontMetrics(opt.font).horizontalAdvance(label) > visible: + return True + return False + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtGui import QFont + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + + _apply_theme(app) # measure the styled widget, not a bare one + from cowork_local.config import AppConfig, CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + from cowork_local.ui.settings_dialog import SettingsDialog + from cowork_local.ui.task_editor_dialog import TaskEditorDialog + + set_language("vi") + ctx = AppContext(AppConfig.load()) + fails: list[str] = [] + + for pt in POINTS: + f = QFont(app.font()) + f.setPointSize(pt) + app.setFont(f) + for name, make in (("Cai dat", lambda: SettingsDialog(ctx)), + ("Task editor", lambda: TaskEditorDialog(ctx=ctx))): + dlg = make() + dlg.show() + row = [] + for w in WIDTHS: + dlg.resize(w, 900) + for _ in range(4): + app.processEvents() + over_area, over_idx = hscroll(dlg, app) + row.append(f"{w}:{'A' if over_area else '.'}{'I' if over_idx else '.'}") + if over_area: + fails.append(f"{name} @ {pt}pt {w}px — vung cuon tran ngang") + if over_idx: + fails.append(f"{name} @ {pt}pt {w}px — muc luc tran ngang") + print(f" {pt:>2}pt {name:12} {' '.join(row)}") + dlg.close() + print() + + print("A = vung cuon tran · I = muc luc tran · . = khong tran") + print() + if fails: + print("*** LOI ***") + for x in fails: + print(" " + x) + return 1 + print("KET QUA: khong hop thoai nao cuon ngang, o moi co chu va be rong da thu") + return 0 + + +if __name__ == "__main__": + _rc = main() + # Qt (WebEngine especially) crashes during interpreter teardown with + # 0xC0000409 AFTER the work is done, which would mask the real result — + # and check_probes_bite reads these exit codes to decide whether a probe + # caught its mutation. Leave immediately with the verdict instead. + sys.stdout.flush() + sys.stderr.flush() + os._exit(_rc) diff --git a/tools/check_orphans.py b/tools/check_orphans.py new file mode 100644 index 0000000..83fd7a5 --- /dev/null +++ b/tools/check_orphans.py @@ -0,0 +1,114 @@ +"""Catch controls orphaned by a neighbouring container being removed. + +The audit page defaults every control to "giữ nguyên tại chỗ" and lists only the +ones that move. That default is unsafe when the thing a control sits *with* is +removed — then "unchanged" is impossible and the control has quietly lost its +home. This is how the Co4E "+ new workflow" button vanished from the proposal: +it lives in the same layout row as the flow tab strip, and the strip was proposed +for removal. + +Note the relationship is SIBLING, not parent/child: `flow_row` holds both the +scroller (wrapping `flow_bar`) and `flow_add_btn`. An earlier version of this +check looked only for `container.addWidget(child)` and therefore found nothing — +it passed while the bug was live. Verify any change here with --selftest. + +Run: python tools/check_orphans.py [--selftest] +""" +from __future__ import annotations + +import ast +import io +import json +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO / "tools")) + +# A MOVES note containing one of these means the thing is going away, so anything +# that only existed alongside it needs a new home. +REMOVAL_WORDS = ("bỏ;", "bỏ ", "gộp", "thay thế") + + +def layout_map(path: Path) -> tuple[dict[str, list[str]], dict[str, str]]: + """(layout var -> widget vars added to it, wrapper var -> widget it wraps).""" + members: dict[str, list[str]] = {} + alias: dict[str, str] = {} + tree = ast.parse(io.open(path, encoding="utf-8").read(), filename=str(path)) + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + try: + owner = ast.unparse(node.func.value) + args = [ast.unparse(a) for a in node.args] + except Exception: # noqa: BLE001 + continue + if not args: + continue + if node.func.attr in ("addWidget", "addLayout"): + members.setdefault(owner, []).append(args[0]) + elif node.func.attr == "setWidget": + # QScrollArea(inner): the scroller stands in for what it holds. + alias[owner] = args[0] + return members, alias + + +def main(argv: list[str]) -> int: + import build_audit_page as B + + selftest = "--selftest" in argv + moves = dict(B.MOVES) + if selftest: + # Re-create the original bug and prove the check reports it. + moves.pop("self.flow_add_btn", None) + + removed = {k for k, v in moves.items() + if any(w in v.lower() for w in REMOVAL_WORDS)} + ctl = json.loads((REPO / "docs" / "screens" / "controls.json") + .read_text(encoding="utf-8")) + + problems: list[tuple[str, str, str, str]] = [] + n_sib = 0 + for rec in ctl: + path = REPO / rec["file"] + if not path.exists(): + continue + members, alias = layout_map(path) + labels = {c["var"]: (c.get("label_vi") or c.get("label") or "?") + for c in rec["controls"]} + for layout, kids in members.items(): + # Resolve wrappers so a scroller counts as the widget it holds. + resolved = {k: alias.get(k, k) for k in kids} + gone = [k for k, r in resolved.items() if r in removed] + if not gone: + continue + for kid in kids: + if resolved[kid] in removed or kid not in labels: + continue + n_sib += 1 + if kid not in moves: + problems.append((rec["file"], kid, labels[kid], + f"cung hang voi {resolved[gone[0]]}")) + + print(f"control nam canh mot thanh phan bi bo : {n_sib}") + print(f"thanh phan bi bo trong MOVES : {len(removed)}" + f" {sorted(removed) if removed else ''}") + print() + if problems: + print("*** CONTROL MO COI ***") + for f, var, label, why in problems: + print(f" {f}: {var} ({label}) — {why}") + print() + print(f"KET QUA: {len(problems)} control mat cho, can khai bao trong MOVES") + return 0 if selftest else 1 + if selftest: + print("KET QUA SELFTEST: *** THAT BAI — phep kiem KHONG bat duoc loi da biet ***") + return 1 + print("KET QUA: khong co control nao bi mo coi") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/check_probes_bite.py b/tools/check_probes_bite.py new file mode 100644 index 0000000..21f1d82 --- /dev/null +++ b/tools/check_probes_bite.py @@ -0,0 +1,132 @@ +"""Round 5: do the checks actually bite? + +Rounds 1–4 all report green. That is only worth something if the checks would +have turned red had the work not been done. So this round breaks the app on +purpose, one feature at a time, and fails if the corresponding check still +passes — a check that cannot fail is not evidence. + +Each mutation is applied by monkey-patching the module BEFORE the checker +builds its own window, then undone. + +Run: python tools/check_probes_bite.py +""" +from __future__ import annotations + +import io +import os +import runpy +import subprocess +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +# (name, file, find, replace, checker that must FAIL because of it) +MUTATIONS = [ + ("phong to cham tro ly gap doi khai bao", + "ui/help_agent_widget.py", "_DOT = 52", "_DOT = 104", + "check_layout_geometry.py"), + ("tra lane Running ve khong vien", + "ui/schedule_task_tab.py", + 'if status == "running" and counts[status]:', + 'if False:', + "check_design_parity.py"), + ("bo cot muc luc cua Cai dat", + "ui/settings_dialog.py", + "self.section_list, self.section_stack = section_panels(pages)", + "self.section_list, self.section_stack = section_panels(pages[:1])", + "check_dialogs.py"), + ("noi lai dai tab flow Co4E", + "ui/co4e_tab.py", + "self.flow_scroll.setVisible(False)", + "self.flow_scroll.setVisible(True)", + "check_co4e.py"), + ("bo dong 'Tat ca project...' khoi GAN DAY", + "app.py", + 'more.setData(0, Qt.UserRole, {"all": True})', + 'more.setData(0, Qt.UserRole, {})', + "check_design_parity.py"), + ("tra thanh menu ve accordion (bo nhom day)", + "app.py", + 'rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,', + 'rows.append((self.nav, self._ROW_DASHBOARD, None,', + "check_layout_geometry.py"), +] + + +def run_checker(script: str) -> int: + """Run a checker in a fresh process; return its exit code.""" + proc = subprocess.run( + [sys.executable, str(REPO / "tools" / script)], + cwd=REPO, capture_output=True, text=True, encoding="utf-8", + errors="replace", env={**os.environ, "QT_QPA_PLATFORM": "offscreen", + "PYTHONIOENCODING": "utf-8"}) + return proc.returncode + + +def tree_state() -> str: + return subprocess.run(["git", "status", "--short"], cwd=REPO, + capture_output=True, text=True).stdout.strip() + + +def main() -> int: + fails: list[str] = [] + # Compare the tree BEFORE and AFTER, not against a clean tree: work in + # progress is legitimately uncommitted, and demanding a clean tree made this + # round fail for a reason that has nothing to do with the mutations. + before = tree_state() + print(f"{'hong gi':44} {'phep do':26} ket qua") + print("-" * 88) + for name, rel, find, repl, checker in MUTATIONS: + path = REPO / rel + # newline="" both ways: the default translates on read AND write, so a + # LF file came back as CRLF and every mutated file was left "modified" + # even after being restored. + with io.open(path, "r", encoding="utf-8", newline="") as fh: + original = fh.read() + if find not in original: + fails.append(f"{name}: khong tim thay doan can sua trong {rel}") + print(f"{name:44} {checker:26} *** KHONG AP DUNG DUOC ***") + continue + + def write(text: str) -> None: + with io.open(path, "w", encoding="utf-8", newline="") as fh: + fh.write(text) + + write(original.replace(find, repl, 1)) + try: + code = run_checker(checker) + finally: + write(original) # always restore + bit = code != 0 + print(f"{name:44} {checker:26} {'BAT DUOC' if bit else '*** KHONG BAT ***'}") + if not bit: + fails.append(f"{name}: {checker} van bao xanh du da lam hong") + + # Everything must be back exactly as it was before this run. + after = tree_state() + same = after == before + print() + print("cay lam viec sau khi thu giong het truoc:", "co" if same else "*** KHAC ***") + if not same: + print(" truoc:", before.replace("\n", " | ") or "(sach)") + print(" sau :", after.replace("\n", " | ") or "(sach)") + fails.append("file chua duoc khoi phuc sau khi thu") + + print() + if fails: + print("*** VONG 5 THAT BAI ***") + for f in fails: + print(" " + f) + return 1 + print(f"KET QUA VONG 5: ca {len(MUTATIONS)} phep do deu bat duoc loi khi co tinh lam hong") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_project_gate.py b/tools/check_project_gate.py new file mode 100644 index 0000000..07454dc --- /dev/null +++ b/tools/check_project_gate.py @@ -0,0 +1,116 @@ +"""With no project chosen, the gated screens must stay shut on every route. + +The redesign shows Cowork and GraphRAG at all times instead of making them +appear and disappear. That is a presentation change only — the gate is the same +`isTabVisible` state as before — so this asserts the gate still actually holds, +and holds for programmatic jumps too, not just for the greyed rail rows. + +Runs against an EMPTY home, not a copy of the real one: with any project on +disk the gate is open and the test proves nothing. +""" +from __future__ import annotations + +import os +import sys +import tempfile + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +sandbox = Path(tempfile.mkdtemp(prefix="cowork-gate-")) +(sandbox / ".cowork_local").mkdir(parents=True, exist_ok=True) +for _var in ("USERPROFILE", "HOME"): + os.environ[_var] = str(sandbox) +os.environ.pop("HOMEDRIVE", None) +os.environ.pop("HOMEPATH", None) + +from capture_screens import _apply_theme, _freeze_schedulers, _load_fonts # noqa: E402 + + +def main() -> int: + from PySide6.QtCore import Qt + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + _apply_theme(app) + + from cowork_local.config import CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from cowork_local.app import MainWindow + from cowork_local.config import AppConfig + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1400, 900) + win.show() + app.processEvents() + + fails = [] + n_projects = len(win.workspace.project_choices()) + print(f"project tren dia: {n_projects}") + if n_projects: + print("FAIL home khong rong — phep thu vo nghia") + sys.stdout.flush() + os._exit(1) + + tree = win.nav + rows = [(i, tree.topLevelItem(i)) for i in range(tree.topLevelItemCount())] + locked = [(i, it) for i, it in rows if it.isDisabled()] + print("dong bi khoa :", [it.text(0) for _i, it in locked]) + print("dong mo binh thuong:", [it.text(0) for _i, it in rows if not it.isDisabled()]) + if not locked: + fails.append("khong project nao ma khong dong nao bi khoa") + + for _i, it in locked: + if not it.toolTip(0): + fails.append(f"dong khoa '{it.text(0)}' khong noi ly do") + + for i, it in locked: + label = it.text(0) + data = it.data(0, Qt.UserRole) or {} + before = win.workspace.current_subtab() + + tree.setCurrentItem(it) + app.processEvents() + if win.workspace.current_subtab() != before: + fails.append(f"bam duoc vao '{label}' du dang khoa") + + # the route a greyed row does not guard: a jump from code + win._goto(data.get("page", 0), data.get("sub")) + app.processEvents() + landed = win.workspace.current_subtab() + if landed == data.get("sub"): + fails.append(f"_goto mo duoc '{label}' trong khi cong dang dong") + print(f" '{label}': bam -> {before}, _goto -> {landed} " + f"(tab hien = {win.workspace.tabs.isTabVisible(data.get('sub'))})") + + # controls that would act on a project must be off too + for name, widget in (("+ chat moi", win.nav_new_chat), + ("chon project", win.nav_project_btn)): + if widget.isEnabled(): + fails.append(f"'{name}' van bam duoc khi chua co project") + print(f"+ chat moi bat={win.nav_new_chat.isEnabled()} " + f"tooltip={win.nav_new_chat.toolTip()!r}") + + print() + for f in fails: + print("FAIL " + f) + print("PASS cong project van giu, ca khi bam lan khi goi tu code" if not fails + else f"{len(fails)} problem(s)") + sys.stdout.flush() + os._exit(1 if fails else 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_project_screen.py b/tools/check_project_screen.py new file mode 100644 index 0000000..8d441e1 --- /dev/null +++ b/tools/check_project_screen.py @@ -0,0 +1,170 @@ +"""Workspace ▸ Project against its wireframe (section 4). + +The drawing: a "Quản lý project" title with + Project mới on its right, a caps +PROJECT heading over the list, every row carrying "N đoạn chat · M task", and +the form reading Tên / Mô tả / Instructions / Thư mục làm việc. +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts) + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtCore import QPoint + from PySide6.QtWidgets import QApplication, QLabel + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + _apply_theme(app) + + from cowork_local.config import CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.config import AppConfig + from cowork_local.i18n import set_language, tr + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1400, 900) + win.show() + app.processEvents() + win._goto(win._ROW_WORKSPACE, win.workspace._project_tab_idx) + app.processEvents() + + w = win.workspace + fails = [] + + # 1. title, and the create button on its row, to its right + print(f"tieu de: {w._header.text()!r}") + if w._header.text() != tr("workspace.header"): + fails.append("tieu de khong phai workspace.header") + h_pos = w._header.mapTo(w, QPoint(0, 0)) + h_mid = h_pos.y() + w._header.height() // 2 + b_pos = w._new_btn.mapTo(w, QPoint(0, 0)) + b_mid = b_pos.y() + w._new_btn.height() // 2 + aligned = abs(b_mid - h_mid) <= 8 + after = b_pos.x() >= h_pos.x() + w._header.width() + print(f"nut '+ Project mới': tam y={b_mid} (tieu de {h_mid}) thang hang={aligned} " + f"| x={b_pos.x()} sau tieu de={after}") + if not (aligned and after): + fails.append("nut tao project khong nam cung hang, ben phai tieu de") + + # 2. caps heading over the list + hdr = w._projects_hdr.text() + print(f"tieu de pane trai: {hdr!r}") + if not hdr or hdr != hdr.upper(): + fails.append(f"tieu de pane trai chua viet hoa: {hdr!r}") + + # 3. every row says how much is in the project + lst = w.project_list + if not lst.count(): + fails.append("khong co project nao de kiem") + shown = 0 + for i in range(lst.count()): + row = lst.itemWidget(lst.item(i)) + labels = [l.text() for l in row.findChildren(QLabel)] if row else [] + if len(labels) < 2: + fails.append(f"hang {i} khong co dong dem chat/task") + continue + shown += 1 + if i < 2: + print(f" hang {i}: {labels[0]!r} / {labels[1]!r}") + # the sub-line must be the counts string, not the name repeated + if labels[1] == labels[0] or not any(ch.isdigit() for ch in labels[1]): + fails.append(f"hang {i}: dong phu khong phai so dem: {labels[1]!r}") + print(f"so hang co dong dem: {shown}/{lst.count()}") + + # 4. the form reads as the drawing labels it + want = [tr("workspace.name"), tr("workspace.description"), + tr("workspace.instructions"), tr("workspace.folder_label")] + seen = [l.text() for l in w.findChildren(QLabel) if l.isVisible() and l.text()] + for label in want: + if label not in seen: + fails.append(f"thieu nhan {label!r}") + print(f"nhan form: {want}") + + # 5. the drawing heads a populated screen with the title alone; the + # explanation belongs to an empty one. + print(f"hint hien voi {lst.count()} project: {w._hint.isVisible()}") + if lst.count() and w._hint.isVisible(): + fails.append("doan giai thich van hien du da co project") + + # 6. the path is a field in the drawing, not caption text + from PySide6.QtWidgets import QLineEdit + is_field = isinstance(w.folder_lbl, QLineEdit) and w.folder_lbl.isReadOnly() + print(f"o thu muc: {type(w.folder_lbl).__name__} (o nhap chi doc={is_field})") + if not is_field: + fails.append("duong dan thu muc khong phai o nhap chi doc") + + # 7. Lưu project floats at the foot of the panel, not right under the form + save_y = w._save_btn.mapTo(w, QPoint(0, 0)).y() + folder_y = w.folder_lbl.mapTo(w, QPoint(0, 0)).y() + print(f"nut Luu y={save_y}, o thu muc y={folder_y}, cach {save_y - folder_y}px") + if save_y - folder_y < 80: + fails.append("nut Luu khong bi day xuong day panel") + + # 8. the rail's picker must name the projects. It reads project_choices(), + # which used to read item.text() — and when rows became widgets the item + # text went empty, so every entry showed as a bare folder glyph. Creating + # a project is when a user notices, so create one here. + before = [n for n, _pid in w.project_choices()] + w._create() + app.processEvents() + choices = w.project_choices() + picker = [win.nav_project.itemText(i) for i in range(win.nav_project.count())] + print(f"project_choices: {[n for n, _p in choices][:4]}") + print(f"picker hien thi: {picker[:4]}") + if any(not name.strip() for name, _pid in choices): + fails.append("project_choices tra ve ten rong") + if len(choices) <= len(before): + fails.append("tao project moi khong vao danh sach") + for name, _pid in choices: + if not any(name in text for text in picker): + fails.append(f"picker khong hien ten {name!r}") + break + + # 9. the title row sits above the sub-tabs, so what is on it must follow + # the title — + Project mới turned up in the corner of every other one. + for idx, name in ((w._cowork_tab_idx, "Cowork"), (w._co4e_tab_idx, "Co4E"), + (w._folder_tab_idx, "Thu muc"), + (w._graphrag_tab_idx, "GraphRAG")): + if idx < 0: + continue + win._goto(win._ROW_WORKSPACE, idx) + app.processEvents() + if w._new_btn.isVisible(): + fails.append(f"nut '+ Project moi' hien ca o man {name}") + win._goto(win._ROW_WORKSPACE, w._project_tab_idx) + app.processEvents() + print(f"nut tao chi hien o Project: {not any('Project moi' in f for f in fails)}") + + print() + for f in fails: + print("FAIL " + f) + print("PASS man Project khop ban ve" if not fails else f"{len(fails)} problem(s)") + sys.stdout.flush() + os._exit(1 if fails else 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_rail_align.py b/tools/check_rail_align.py new file mode 100644 index 0000000..aaee47f --- /dev/null +++ b/tools/check_rail_align.py @@ -0,0 +1,268 @@ +"""Rail icons must sit on one vertical line, and stay there when it collapses. + +Two bugs this catches: + · "Cài đặt" sat 42px right of "Dashboard"/"Giám sát" — its QSS margin pushed + the button in while the tree rows above start at the rail edge. + · Collapsing re-placed the icon of every label-less button, sliding + to the + middle of the 54px rail. + +Runs with the app's real stylesheet loaded. Without it the window has no +padding, margins or borders and neither bug is visible — see _apply_theme. +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts) + +TOL = 2 # px; anti-aliasing on an icon edge + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtCore import QPoint + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + theme_name = _apply_theme(app) + + from cowork_local.config import CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.config import AppConfig + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1400, 900) + win.show() + app.processEvents() + rail = win._nav_wrap + + def ink_x(w): + """Leftmost painted pixel of a widget, in rail coordinates.""" + img = w.grab().toImage() + bg = img.pixelColor(w.width() - 3, 2) + for x in range(img.width()): + for y in range(2, img.height() - 2): + c = img.pixelColor(x, y) + if (abs(c.red() - bg.red()) + abs(c.green() - bg.green()) + + abs(c.blue() - bg.blue())) > 60: + return w.mapTo(rail, QPoint(x, 0)).x() + return None + + def tree_text_x(tree): + from PySide6.QtWidgets import QStyle, QStyleOptionViewItem + index = tree.indexFromItem(tree.topLevelItem(0), 0) + opt = QStyleOptionViewItem() + tree.initViewItemOption(opt) + opt.rect = tree.visualRect(index) + tree.itemDelegate().initStyleOption(opt, index) + txt = tree.style().subElementRect(QStyle.SE_ItemViewItemText, opt, tree) + return tree.mapTo(rail, QPoint(txt.left(), 0)).x() + + def btn_text_x(w): + """Left edge of the label: first ink past the icon's gap.""" + # Settings lays its own row out now, so read the label widget directly + # rather than hunting for a gap in the painted pixels. + lbl = getattr(win, "_nav_settings_text", None) + if w is getattr(win, "_nav_settings_btn", None) and lbl is not None: + return lbl.mapTo(rail, QPoint(0, 0)).x() if lbl.isVisible() else None + if not w.text(): + return None + img = w.grab().toImage() + bg = img.pixelColor(w.width() - 3, 2) + ink = [] + for x in range(img.width()): + for y in range(2, img.height() - 2): + c = img.pixelColor(x, y) + if (abs(c.red() - bg.red()) + abs(c.green() - bg.green()) + + abs(c.blue() - bg.blue())) > 60: + ink.append(x) + break + if not ink: + return None + for a, b in zip(ink, ink[1:]): # first gap = icon/label spacing + if b - a > 2: + return w.mapTo(rail, QPoint(b, 0)).x() + return None + + def tree_icon_x(tree): + from PySide6.QtWidgets import QStyle, QStyleOptionViewItem + index = tree.indexFromItem(tree.topLevelItem(0), 0) + opt = QStyleOptionViewItem() + tree.initViewItemOption(opt) + opt.rect = tree.visualRect(index) + tree.itemDelegate().initStyleOption(opt, index) + deco = tree.style().subElementRect( + QStyle.SE_ItemViewItemDecoration, opt, tree) + return tree.mapTo(rail, QPoint(deco.left(), 0)).x() + + def snapshot(): + app.processEvents() + out = {"nav rows": tree_icon_x(win.nav), + "bottom rows": tree_icon_x(win.nav_bottom), + "bottom rows text": tree_text_x(win.nav_bottom)} + for label, attr in (("MENU", "_nav_toggle_btn"), + ("new chat", "nav_new_chat"), + ("settings", "_nav_settings_btn")): + w = getattr(win, attr, None) + if w is not None and w.isVisible(): + out[label] = ink_x(w) + if label == "settings": + out["settings text"] = btn_text_x(w) + return out + + fails = [] + for theme_name in ("dark", "light"): + _apply_theme(app, theme_name) + app.processEvents() + if win._nav_collapsed: + win._toggle_nav() + opened = snapshot() + win._toggle_nav() + app.processEvents() + closed = snapshot() + win._toggle_nav() + app.processEvents() + fails += compare(theme_name, rail, opened, closed) + fails += column_widths_do_not_move_icons(win, app, theme_name) + fails += rows_stay_under_the_header(win, app, theme_name) + + print() + for f in fails: + print(f"FAIL {f}") + print("PASS every rail icon holds its line" if not fails + else f"{len(fails)} problem(s)") + sys.stdout.flush() + os._exit(1 if fails else 0) + + +def rows_stay_under_the_header(win, app, theme_name): + """The destinations start just below the + button in both states. + + Collapsing hides RECENTS, the one item in the scroll body with a stretch + factor. With nothing left to expand, a box layout centres what remains, and + the whole group slid ~300px down the rail. + """ + from PySide6.QtCore import QPoint + + rail = win._nav_wrap + gaps = {} + for state in ("open", "collapsed"): + if (state == "collapsed") != win._nav_collapsed: + win._toggle_nav() + app.processEvents() + btn = win.nav_new_chat + below = btn.mapTo(rail, QPoint(0, btn.height())).y() + top = win.nav.mapTo(rail, QPoint(0, 0)).y() + win.nav.visualItemRect(win.nav.topLevelItem(0)).top() + gaps[state] = top - below + if win._nav_collapsed: + win._toggle_nav() + app.processEvents() + print(f" gap under +: open={gaps['open']}px collapsed={gaps['collapsed']}px") + if abs(gaps["collapsed"] - gaps["open"]) > 4: + return [f"{theme_name}: the destinations sit {gaps['collapsed']}px below " + f"the + button when collapsed but {gaps['open']}px when open"] + return [] + + +def column_widths_do_not_move_icons(win, app, theme_name): + """The icon must not care how wide the column is. + + On the machine that reported this the column matched the rail and the icons + sat in the middle; in a test render the column stayed wider than the view + and the same code drew them at the left. So sweep the width and require the + icon to hold still. + """ + from PySide6.QtWidgets import QStyle, QStyleOptionViewItem + + if not win._nav_collapsed: + win._toggle_nav() + app.processEvents() + fails, seen = [], {} + # The invariant that matters. A centred decoration is the only way Qt can + # put a label-less row's icon anywhere but the left edge, and how far it + # travels depends on the box the column hands it — which is why this + # reproduces on one machine and not another. Require the instruction + # itself, not just the pixel it happens to produce here. + from PySide6.QtCore import Qt as _Qt + for tree_name in ("nav", "nav_bottom"): + tree = getattr(win, tree_name) + index = tree.indexFromItem(tree.topLevelItem(0), 0) + opt = QStyleOptionViewItem() + tree.initViewItemOption(opt) + tree.itemDelegate().initStyleOption(opt, index) + align = int(opt.decorationAlignment) + if align & int(_Qt.AlignHCenter) or not align & int(_Qt.AlignLeft): + fails.append(f"{theme_name} {tree_name}: decorationAlignment={align}" + f" — icon is free to drift off the left edge") + for tree_name in ("nav", "nav_bottom"): + tree = getattr(win, tree_name) + keep = tree.columnWidth(0) + for width in (tree.viewport().width(), 70, 100, 140): + tree.setColumnWidth(0, width) + app.processEvents() + index = tree.indexFromItem(tree.topLevelItem(0), 0) + opt = QStyleOptionViewItem() + tree.initViewItemOption(opt) + opt.rect = tree.visualRect(index) + tree.itemDelegate().initStyleOption(opt, index) + x = tree.style().subElementRect( + QStyle.SE_ItemViewItemDecoration, opt, tree).left() + seen.setdefault(tree_name, []).append((width, x)) + tree.setColumnWidth(0, keep) + app.processEvents() + xs = {x for _w, x in seen[tree_name]} + if len(xs) > 1: + fails.append(f"{theme_name} {tree_name}: icon x changes with the " + f"column width — {seen[tree_name]}") + print(f" column sweep: " + " ".join( + f"{n}={[x for _w, x in v]}" for n, v in seen.items())) + win._toggle_nav() + app.processEvents() + return fails + + +def compare(theme_name, rail, opened, closed): + print() + print(f"theme={theme_name}") + print(f"{'element':<12}{'open':>7}{'collapsed':>11}{'drift':>8}") + fails = [] + for key in opened: + a, b = opened[key], closed.get(key) + drift = "-" if a is None or b is None else f"{b - a:+d}" + print(f"{key:<12}{str(a):>7}{str(b):>11}{drift:>8}") + if a is not None and b is not None and abs(b - a) > TOL: + fails.append(f"{key}: icon moves {b - a:+d}px when the rail collapses") + + # Settings is a button but reads as one more row in the bottom list, so + # both its icon and its label have to start where theirs do. + for state, snap in (("open", opened), ("collapsed", closed)): + for what in ("", " text"): + ref, got = snap.get("bottom rows" + what), snap.get("settings" + what) + if ref is not None and got is not None and abs(got - ref) > TOL: + fails.append(f"{theme_name} {state}: settings{what} x={got} but " + f"the rows above it start at x={ref}") + return fails + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_rail_resize.py b/tools/check_rail_resize.py new file mode 100644 index 0000000..5d616e4 --- /dev/null +++ b/tools/check_rail_resize.py @@ -0,0 +1,128 @@ +"""The splitter handle beside the rail has to actually move the rail. + +setFixedWidth left it drawn but inert: it looked draggable and did nothing. +Also checks that a width the user drags to survives a collapse/expand, and +that collapsing still pins the rail at 54px. +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import ( # noqa: E402 + _apply_theme, _freeze_schedulers, _isolate_home, _load_fonts) + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + _apply_theme(app) + + from cowork_local.config import CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import ( + _NAV_COLLAPSED_WIDTH, _NAV_MIN_WIDTH, MainWindow) + from cowork_local.config import AppConfig + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1400, 900) + win.show() + app.processEvents() + + fails = [] + rail, split = win._nav_wrap, win.split + + def drag_to(px): + """What the splitter does when the handle is dragged.""" + total = sum(split.sizes()) + split.setSizes([px, max(1, total - px)]) + app.processEvents() + win._on_split_moved(px, 1) + app.processEvents() + return rail.width() + + start = rail.width() + wide = drag_to(300) + print(f"keo rong : {start} -> {wide}px") + if wide <= start: + fails.append(f"keo tay nam ra 300px ma rail van {wide}px") + + narrow = drag_to(_NAV_MIN_WIDTH) + print(f"keo hep : {wide} -> {narrow}px") + if narrow >= wide: + fails.append(f"keo hep lai khong an: {narrow}px") + + # The ceiling is a share of the window now, so ask the window for it. + ceiling = win._nav_max_width() + over = drag_to(ceiling + 200) + print(f"keo qua max: {over}px (tran {ceiling} = {ceiling * 100 // win.width()}% cua so)") + if over > ceiling: + fails.append(f"rail vuot tran: {over} > {ceiling}") + + under = drag_to(20) + print(f"keo duoi min: {under}px (san {_NAV_MIN_WIDTH})") + if under < _NAV_MIN_WIDTH: + fails.append(f"rail thap hon san: {under} < {_NAV_MIN_WIDTH}") + + # a dragged width has to come back after a fold + chosen = drag_to(min(280, win._nav_max_width())) + win._toggle_nav() + app.processEvents() + folded = rail.width() + print(f"thu gon : {folded}px") + if folded != _NAV_COLLAPSED_WIDTH: + fails.append(f"thu gon phai la {_NAV_COLLAPSED_WIDTH}px, dang {folded}px") + win._toggle_nav() + app.processEvents() + back = rail.width() + print(f"mo lai : {back}px (da chon {chosen}px)") + if abs(back - chosen) > 4: + fails.append(f"mo lai quen be rong da keo: {back} thay vi {chosen}") + + # The ceiling is a share, so it has to move with the window — it was read + # once at construction and stuck at 162px on every monitor. + seen = {} + for w in (1280, 1600, 1936): + win.resize(w, 900) + app.processEvents() + split.setSizes([2000, 1]) # drag the handle as far right as it goes + app.processEvents() + seen[w] = (rail.width(), win._nav_max_width()) + print(f"cua so {w}: keo het co -> {seen[w][0]}px (tran {seen[w][1]}px)") + for w, (got, ceiling) in seen.items(): + if abs(got - ceiling) > 4: + fails.append(f"cua so {w}: keo het chi duoc {got}px, tran la {ceiling}px") + if len({c for _g, c in seen.values()}) == 1: + fails.append("tran khong doi theo be rong cua so — dang la px co dinh") + + print() + for f in fails: + print("FAIL " + f) + print("PASS tay nam keo duoc, nho be rong qua lan gap" if not fails + else f"{len(fails)} problem(s)") + sys.stdout.flush() + os._exit(1 if fails else 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_responsive.py b/tools/check_responsive.py new file mode 100644 index 0000000..de41bb6 --- /dev/null +++ b/tools/check_responsive.py @@ -0,0 +1,119 @@ +"""Measure what actually breaks on a small screen, screen by screen. + +A pane is "clipped" when the width it is given is smaller than the width it says +it needs (minimumSizeHint): Qt then cuts content off instead of shrinking it, +which is what shows up as half-drawn buttons and cut-off labels. + +Reports per destination, at a few window sizes, and lists the widest offenders +so a fix can be aimed at the right widget instead of guessed at. + +Run: python tools/check_responsive.py [width height ...] +""" +from __future__ import annotations + +import os +import sys + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from capture_screens import _freeze_schedulers, _isolate_home, _load_fonts # noqa: E402 + +SIZES = [(1920, 1080), (1366, 768), (1280, 720)] + + +def panes(widget): + """Direct children worth measuring: splitter panes and page-level boxes.""" + from PySide6.QtWidgets import QSplitter + out = [] + for sp in widget.findChildren(QSplitter): + for i in range(sp.count()): + w = sp.widget(i) + if w is not None and not w.isHidden(): + out.append((f"{sp.objectName() or 'splitter'}[{i}] {type(w).__name__}", w)) + return out + + +def main(argv) -> int: + sizes = SIZES + if len(argv) >= 2: + sizes = [(int(argv[i]), int(argv[i + 1])) for i in range(0, len(argv) - 1, 2)] + + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + + from cowork_local.config import AppConfig, CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from seed_demo_data import seed + seed() + + from cowork_local.app import MainWindow + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + from cowork_local.theme import set_active_theme, stylesheet + + set_language("vi") + cfg = AppConfig.load() + set_active_theme(cfg.theme) + app.setStyleSheet(stylesheet(cfg.theme)) + win = MainWindow(AppContext(cfg), user_name="local") + win.show() + for _ in range(6): + app.processEvents() + + dests = [("Project", win._ROW_WORKSPACE, win.workspace._project_tab_idx), + ("Cowork", win._ROW_WORKSPACE, win.workspace._cowork_tab_idx), + ("Co4E", win._ROW_WORKSPACE, win.workspace._co4e_tab_idx), + ("Folder", win._ROW_WORKSPACE, win.workspace._folder_tab_idx), + ("GraphRAG", win._ROW_WORKSPACE, win.workspace._graphrag_tab_idx), + ("Schedule", win._ROW_SCHEDULE, None), + ("Dashboard", win._ROW_DASHBOARD, None), + ("Monitoring", win._ROW_MONITORING, None)] + + print(f"cua so: minimumSizeHint = {win.minimumSizeHint().width()}" + f"x{win.minimumSizeHint().height()}px") + print() + worst: dict[str, int] = {} + for w, h in sizes: + win.resize(w, h) + for _ in range(4): + app.processEvents() + print(f"=== {w}x{h} ===") + for name, page, sub in dests: + win._goto(page, sub) + for _ in range(4): + app.processEvents() + widget = win._page_widgets[page] + need = widget.minimumSizeHint().width() + have = widget.width() + tight = [(n, p.minimumSizeHint().width(), p.width()) + for n, p in panes(widget) + if p.minimumSizeHint().width() > p.width() + 1] + flag = "" if need <= have else f" <-- THIEU {need - have}px" + print(f" {name:11} can {need:5}px · duoc {have:5}px{flag}") + for n, nd, hv in tight: + print(f" · {n:34} can {nd:4} duoc {hv:4}") + worst[f"{name}/{n}"] = max(worst.get(f"{name}/{n}", 0), nd - hv) + print() + + if worst: + print("BO BO NHIEU NHAT:") + for k, v in sorted(worst.items(), key=lambda kv: -kv[1])[:12]: + print(f" {v:5}px {k}") + else: + print("KET QUA: khong pane nao bi bo o cac co da thu") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/extract_controls.py b/tools/extract_controls.py new file mode 100644 index 0000000..c48d26d --- /dev/null +++ b/tools/extract_controls.py @@ -0,0 +1,137 @@ +"""Extract every interactive control from the UI source, mechanically. + +Reading the files by hand and listing what I notice is exactly how functionality +gets dropped from a redesign. This walks the AST instead, so the inventory is +exhaustive by construction: if a widget is constructed in the file, it appears. + +For each control it reports the variable it is bound to, its widget type, the +label expression (usually a ``tr("...")`` key), the signal handlers wired to it, +and the source line — enough to check "did the new design keep this?". + +Run: python tools/extract_controls.py [ui/file.py ...] +""" +from __future__ import annotations + +import ast +import io +import json +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +UI = REPO / "ui" + +# Widget types that represent something the user can click, type in or toggle. +WIDGETS = { + "QPushButton": "nút", "QToolButton": "nút icon", "QComboBox": "droplist", + "QCheckBox": "ô tick", "QRadioButton": "radio", "QLineEdit": "ô nhập", + "QPlainTextEdit": "ô nhập nhiều dòng", "QTextEdit": "ô nhập nhiều dòng", + "QSpinBox": "ô số", "QDoubleSpinBox": "ô số", "QDateTimeEdit": "ô ngày giờ", + "QDateEdit": "ô ngày", "QTimeEdit": "ô giờ", "QSlider": "thanh trượt", + "QListWidget": "danh sách", "QTreeWidget": "cây", "QTableWidget": "bảng", + "QTabWidget": "dải tab", "QTabBar": "dải tab", "QDialogButtonBox": "nút hộp thoại", +} +# Signals worth recording — these are the "it does something" wires. +SIGNALS = { + "clicked", "toggled", "currentIndexChanged", "currentTextChanged", + "textChanged", "returnPressed", "valueChanged", "itemClicked", + "itemDoubleClicked", "currentItemChanged", "currentChanged", + "customContextMenuRequested", "tabCloseRequested", "linkActivated", + "stateChanged", "activated", "triggered", "editingFinished", +} + + +def _txt(node) -> str: + """Best-effort source text for a label expression.""" + try: + return ast.unparse(node) + except Exception: # noqa: BLE001 + return "?" + + +class Visitor(ast.NodeVisitor): + def __init__(self, path: Path): + self.path = path + self.controls: dict[str, dict] = {} # var name -> record + self.menu_actions: list[dict] = [] + + # ---- self.btn = QPushButton(...) / btn = QComboBox() ------------------- + def visit_Assign(self, node: ast.Assign) -> None: + if isinstance(node.value, ast.Call): + fn = node.value.func + name = fn.id if isinstance(fn, ast.Name) else getattr(fn, "attr", "") + if name in WIDGETS: + for tgt in node.targets: + var = _txt(tgt) + args = [_txt(a) for a in node.value.args] + self.controls.setdefault(var, { + "var": var, "type": name, "kind": WIDGETS[name], + "label": args[0] if args else "", + "line": node.lineno, "signals": [], "object_name": "", + }) + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + fn = node.func + # ---- x.clicked.connect(handler) ---------------------------------- + if isinstance(fn, ast.Attribute) and fn.attr == "connect": + sig = fn.value + if isinstance(sig, ast.Attribute) and sig.attr in SIGNALS: + var = _txt(sig.value) + rec = self.controls.get(var) + if rec is not None and node.args: + rec["signals"].append(f"{sig.attr} → {_txt(node.args[0])}") + # ---- x.setText(tr("...")) / setObjectName / setToolTip ----------- + if isinstance(fn, ast.Attribute) and node.args: + var = _txt(fn.value) + rec = self.controls.get(var) + if rec is not None: + if fn.attr in ("setText", "setPlaceholderText") and not rec["label"]: + rec["label"] = _txt(node.args[0]) + elif fn.attr == "setObjectName": + rec["object_name"] = _txt(node.args[0]).strip("'\"") + elif fn.attr == "setToolTip" and not rec["label"]: + rec["label"] = _txt(node.args[0]) + # ---- menu.addAction("Xoá") — context menus are real features ----- + if isinstance(fn, ast.Attribute) and fn.attr == "addAction" and node.args: + self.menu_actions.append({ + "menu": _txt(fn.value), "label": _txt(node.args[0]), + "line": node.lineno, + }) + self.generic_visit(node) + + +def scan(path: Path) -> dict: + tree = ast.parse(io.open(path, encoding="utf-8").read(), filename=str(path)) + v = Visitor(path) + v.visit(tree) + # Drop pure containers with no wiring and no label — they are layout, not + # controls the user acts on directly. + controls = [c for c in v.controls.values() + if c["signals"] or c["label"] or c["object_name"]] + controls.sort(key=lambda c: c["line"]) + return {"file": str(path.relative_to(REPO)), + "controls": controls, "menu_actions": v.menu_actions} + + +def main(argv: list[str]) -> int: + targets = [Path(a) for a in argv] or sorted(UI.glob("*.py")) + out = [] + for t in targets: + if t.name == "__init__.py": + continue + p = t if t.is_absolute() else (REPO / t if (REPO / t).exists() else t) + try: + out.append(scan(p)) + except SyntaxError as exc: # noqa: PERF203 + print(f" SKIP {p.name}: {exc}", file=sys.stderr) + dest = REPO / "docs" / "screens" / "controls.json" + dest.write_text(json.dumps(out, indent=1, ensure_ascii=False), encoding="utf-8") + n_ctl = sum(len(f["controls"]) for f in out) + n_act = sum(len(f["menu_actions"]) for f in out) + print(f"{len(out)} file · {n_ctl} control · {n_act} mục menu chuột phải → {dest}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/extract_handwritten.py b/tools/extract_handwritten.py new file mode 100644 index 0000000..0d103c4 --- /dev/null +++ b/tools/extract_handwritten.py @@ -0,0 +1,157 @@ +"""Lift the hand-written audit sections out of 10-18.ui-audit.html. + +That file was edited by hand: eight sections carry richer wireframes, prose and +interactive tables than the generator produces, plus the CSS and scripts they +need. Keeping two HTML files around means they drift, so this pulls the +hand-written parts into ``tools/audit_handwritten.py`` — a data module the +builder merges back in, making ``docs/ui-audit.html`` the single output again. + +Two things are tokenised out before storing, so they stay generated rather than +frozen at extraction time: + {{SHOT}} the screenshot block (keeps ~8 MB of base64 out of the module) + {{CONTROLS}} the AST-derived control inventory (must track controls.json) + +Workflow when you hand-edit one of those sections directly in the page: + 1. edit docs/ui-audit.html + 2. python tools/extract_handwritten.py (reads it back into the module) + 3. python tools/build_audit_page.py (regenerates, edits preserved) +Pass another filename to import sections from a different copy. +""" +from __future__ import annotations + +import contextlib +import io +import re +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +DOCS = REPO / "docs" +OUT = REPO / "tools" / "audit_handwritten.py" + +# Hand-written sections are DETECTED, not listed: every section whose text +# differs from what the generator alone would emit is stored. +# +# There used to be a fixed list here, and it cost a section — "Monitoring ▸ Công +# cụ" was hand-written but missing from the list, so each rebuild quietly put +# the generated version back. Diffing against the live output cannot work (it +# already contains the merged result and would find nothing), so the reference +# is a generator-only render produced in-process, with the merge disabled. +# +# These are the ones known so far; anything else detected is added on top. +KNOWN = [ + "monitoring-sự-kiện-bảo-mật", "monitoring-lịch-sử-gọi-mcp", + "monitoring-nhật-ký-hành-động", "monitoring-trạng-thái-agent", + "monitoring-agents-admin", "monitoring-icon", "monitoring-công-cụ", + "dialog-settings", "dialog-task-editor", +] + +SECTION = re.compile(r'
    (.*?)
    ', re.S) +BODY = re.compile(r'(
    .*)', re.S) +SHOT = re.compile(r'
    .*?
    ', re.S) +CONTROLS = re.compile(r'
    .*?
    ', re.S) +STYLE = re.compile(r"", re.S) +SCRIPT = re.compile(r"", re.S) + + +def bodies(html: str) -> dict[str, str]: + """slug -> the section's
    …
    , header excluded.""" + out = {} + for m in SECTION.finditer(html): + b = BODY.search(m.group(2)) + if b: + out[m.group(1)] = b.group(1).strip() + return out + + +def norm(s: str) -> str: + return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", s)).strip() + + +def main(argv: list[str]) -> int: + src_path = DOCS / (argv[0] if argv else "ui-audit.html") + if not src_path.exists(): + print(f"khong thay {src_path}") + return 1 + + src = src_path.read_text(encoding="utf-8") + # Screenshots are re-embedded by the builder; keep the base64 out of here. + hand = bodies(re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "", src)) + + sys.path.insert(0, str(REPO / "tools")) + import build_audit_page as B + + # Render what the generator ALONE would produce, into a temp file, and treat + # every section that differs from it as hand-written. + with tempfile.TemporaryDirectory() as tmp: + keep_out, keep_hand = B.OUT, B.HAND_SECTIONS + B.OUT, B.HAND_SECTIONS = Path(tmp) / "gen-only.html", {} + try: + with contextlib.redirect_stdout(io.StringIO()): + B.main() + made = bodies(re.sub(r"data:image/png;base64,[A-Za-z0-9+/=]+", "", + B.OUT.read_text(encoding="utf-8"))) + finally: + B.OUT, B.HAND_SECTIONS = keep_out, keep_hand + + detected = sorted(s for s, body in hand.items() if norm(body) != norm(made.get(s, ""))) + slugs = [s for s in hand if s in set(detected) | set(KNOWN)] + new = [s for s in detected if s not in KNOWN] + gone = [s for s in KNOWN if s in hand and s not in detected] + if new: + print(f"phat hien them section viet tay: {new}") + if gone: + # Not an error: a hand section can be edited back to match the generator. + print(f"section trong KNOWN nay giong ban sinh: {gone}") + + stored = {} + for slug in slugs: + body = hand[slug] + body = SHOT.sub("{{SHOT}}", body, count=1) + body = CONTROLS.sub("{{CONTROLS}}", body, count=1) + stored[slug] = body + + # CSS rules and scripts the hand edits added. Compared against the builder's + # OWN constants, not its output — the output already carries the merge. + + extra_css = "\n".join( + ln for ln in STYLE.search(src).group(1).splitlines() + if ln.strip() and ln not in B.CSS) + # Scripts already sitting INSIDE a stored section travel with it — collecting + # them again would bind every listener twice (the +/- steppers would then + # count by two). Only page-level scripts belong in EXTRA_JS. + gen_js = {norm(B.JS)} + in_section = "".join(stored.values()) + extra_js = [j for j in SCRIPT.findall(src) + if norm(j) not in gen_js and j not in in_section] + + parts = [ + '"""Hand-written audit sections, extracted from 10-18.ui-audit.html.\n\n' + "GENERATED by tools/extract_handwritten.py — do not edit by hand; edit the\n" + "source HTML and re-run it. build_audit_page.py substitutes {{SHOT}} and\n" + '{{CONTROLS}} so those stay generated.\n"""\n', + "SECTIONS = {", + ] + for slug, body in stored.items(): + parts.append(f" {slug!r}: {body!r},") + parts.append("}\n") + parts.append(f"EXTRA_CSS = {extra_css!r}\n") + parts.append("EXTRA_JS = [") + for j in extra_js: + parts.append(f" {j!r},") + parts.append("]\n") + OUT.write_text("\n".join(parts), encoding="utf-8") + + print(f"section viet tay : {len(stored)}") + for slug, body in stored.items(): + print(f" {slug:32} {len(body):>7,} ky tu" + f" shot={'{{SHOT}}' in body} ctl={'{{CONTROLS}}' in body}") + print(f"CSS them : {len(extra_css.splitlines())} dong") + print(f"script them : {len(extra_js)}") + print(f"ghi -> {OUT.relative_to(REPO)} ({OUT.stat().st_size / 1024:.0f} KB)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/seed_demo_data.py b/tools/seed_demo_data.py new file mode 100644 index 0000000..3c68cbb --- /dev/null +++ b/tools/seed_demo_data.py @@ -0,0 +1,352 @@ +"""Populate a CoworkLocal config dir with realistic demo data, so the audit +screenshots show a working app instead of empty lists. + +MUST be imported only AFTER ``USERPROFILE``/``HOME`` have been repointed at a +sandbox — every store below resolves its path from ``CONFIG_DIR``, which is +``Path.home()/".cowork_local"`` evaluated at import time. ``seed()`` asserts this. + +Where the app exposes a write API we call it (projects, history, tasks, skills, +workflows, agents). Two stores are written as raw files on purpose: + +* **usage** and **audit** — their ``record()`` helpers always stamp + ``datetime.now()``, so they cannot backdate. A one-day spike makes a useless + chart, so the day files are written directly. +* **co4e/run_history.json** — the manager only persists from a Qt signal + handler; there is no public save. +""" +from __future__ import annotations + +import json +import os +import random +from datetime import datetime, timedelta +from pathlib import Path + +rnd = random.Random(20260808) # fixed seed → identical screenshots every run + +PROJECTS = [ + ("Trạm sạc EV — Cổng vận hành", + "Cổng nội bộ theo dõi trạm sạc: bản đồ trạng thái, cảnh báo, báo cáo doanh thu.", + "Trả lời bằng tiếng Việt. Backend FastAPI + PostgreSQL, frontend React.\n" + "Luôn viết test trước khi sửa logic thanh toán."), + ("Báo cáo tài chính Q3", + "Tự động gom số liệu từ Excel phòng ban, dựng bảng tổng hợp và slide trình bày.", + "Đơn vị tiền tệ mặc định VND. Làm tròn tới nghìn đồng.\n" + "Mọi con số phải truy được về file nguồn."), + ("Cổng tra cứu tài liệu ISO", + "Tìm kiếm ngữ nghĩa trên kho tài liệu ISO 9001 của công ty.", + "Ưu tiên trích dẫn đúng số hiệu điều khoản.\nKhông suy diễn ngoài tài liệu."), +] + +CONVERSATIONS = [ + (0, "Dựng API trạng thái trạm sạc", True, [ + ("user", "Giúp mình dựng endpoint GET /stations/{id}/status trả về trạng thái realtime."), + ("assistant", "Mình đã đọc `app/models/station.py` và thấy sẵn bảng `station_heartbeat`.\n\n" + "Đề xuất: endpoint đọc heartbeat mới nhất trong 90 giây, quá hạn thì trả `offline`.\n" + "Mình tạo `app/api/stations.py` và test đi kèm nhé?"), + ("user", "Ừ làm đi."), + ("assistant", "Đã tạo 2 file:\n- `app/api/stations.py` — router + schema phản hồi\n" + "- `tests/test_stations.py` — 4 ca: online, offline, không tồn tại, heartbeat lỗi\n\n" + "Chạy `pytest tests/test_stations.py` → 4 passed."), + ]), + (0, "Sửa lỗi tính tiền sai khi phiên sạc bị ngắt", False, [ + ("user", "Khách báo bị tính dư tiền khi rút sạc đột ngột giữa chừng."), + ("assistant", "Mình tìm thấy nguyên nhân ở `billing/session.py:142`: khi phiên kết thúc bất thường, " + "`ended_at` vẫn để `None` nên hàm tính giờ lấy `now()` — tiền chạy tiếp tới lúc job dọn chạy.\n\n" + "Sửa: lấy mốc heartbeat cuối cùng làm `ended_at`. Mình chuẩn bị diff nhé?"), + ]), + (1, "Gom số liệu doanh thu từ 6 file phòng ban", True, [ + ("user", "Có 6 file Excel trong thư mục input, gom lại thành 1 bảng tổng hợp giúp mình."), + ("assistant", "Đã đọc cả 6 file. Lưu ý: `PB_Marketing.xlsx` để cột “Doanh thu” ở vị trí khác " + "(cột F thay vì D) và có 3 dòng trống ở cuối.\n\n" + "Mình đã chuẩn hoá và xuất `tonghop_q3.xlsx` — 1.284 dòng, tổng 42.7 tỷ VND."), + ]), + (1, "Dựng slide trình bày kết quả Q3", False, [ + ("user", "Từ file tổng hợp, làm giúp bộ slide 8 trang."), + ("assistant", "Đã tạo `BaoCao_Q3.pptx`: tổng quan → doanh thu theo phòng ban → so sánh Q2 → " + "top 5 tăng trưởng → rủi ro → kế hoạch Q4."), + ]), + (2, "Đánh chỉ mục kho tài liệu ISO", False, [ + ("user", "Quét thư mục docs/iso và dựng đồ thị tri thức."), + ("assistant", "Đã quét 148 tài liệu, dựng 1.902 node và 3.418 cạnh. " + "Có 12 tài liệu bị trùng số hiệu điều khoản, mình liệt kê ở `trung_lap.md`."), + ]), + (0, "Rà soát bảo mật trước khi lên production", False, [ + ("user", "Kiểm tra giúp mình các chỗ có rủi ro bảo mật."), + ("assistant", "Tìm thấy 3 điểm cần xử lý:\n1. API key Stripe hard-code ở `config/payment.py:31`\n" + "2. Endpoint `/admin/export` không kiểm tra quyền\n" + "3. CORS đang để `allow_origins=[\"*\"]`"), + ]), +] + +TASKS = [ + # (title, status, priority, ai, enabled, offset_days, last_status) + ("Đồng bộ heartbeat trạm sạc mỗi 5 phút", "running", "high", False, True, 0, "success"), + ("Gửi báo cáo doanh thu hằng ngày 08:00", "scheduled", "medium", False, True, 1, "success"), + ("Quét lại chỉ mục ISO cuối tuần", "scheduled", "low", False, True, 3, "success"), + ("Dựng slide tổng kết Q3", "done", "high", True, False, -2, "success"), + ("Kiểm tra chứng chỉ TLS sắp hết hạn", "failed", "critical", False, True, -1, "failed"), + ("Chờ kế toán duyệt số liệu tháng 7", "waiting_input", "medium", False, False, -3, None), + ("Dọn log cũ hơn 90 ngày", "paused", "low", False, False, 7, "success"), + ("Xuất danh sách khách hàng B2B", "backlog", "low", True, False, 5, None), + ("Rà soát bảo mật trước release", "backlog", "high", False, False, 2, None), + ("Sao lưu cơ sở dữ liệu hằng đêm", "done", "critical", False, True, -1, "success"), +] + +SKILLS = [ + ("Rà soát bảo mật", "Quét mã tìm lộ khoá, thiếu kiểm tra quyền, cấu hình CORS lỏng.", + "Khi được gọi, hãy rà soát theo thứ tự:\n1. Bí mật hard-code (API key, mật khẩu, token)\n" + "2. Endpoint thiếu kiểm tra xác thực/phân quyền\n3. Cấu hình CORS, CSP, cookie\n" + "4. Truy vấn SQL ghép chuỗi\nMỗi phát hiện phải kèm file:dòng và cách sửa cụ thể."), + ("Chuẩn hoá bảng Excel", "Gom nhiều file Excel lệch cấu trúc về một bảng thống nhất.", + "Đọc từng file, dò vị trí cột theo tiêu đề chứ không theo chỉ số cột.\n" + "Bỏ dòng trống ở cuối. Báo rõ file nào lệch cấu trúc và lệch ra sao."), + ("Viết test trước", "Sinh test cho hành vi mong muốn trước khi sửa mã.", + "Trước khi sửa logic, viết test mô tả hành vi đúng.\n" + "Chạy test để xác nhận nó FAIL, rồi mới sửa mã cho nó PASS."), + ("Tóm tắt tài liệu ISO", "Tóm tắt điều khoản ISO kèm trích dẫn số hiệu.", + "Luôn trích dẫn số hiệu điều khoản. Không suy diễn ngoài văn bản.\n" + "Nếu tài liệu mâu thuẫn nhau, nêu rõ cả hai và chỉ ra chỗ mâu thuẫn."), + ("Dựng slide từ số liệu", "Chuyển bảng số liệu thành bộ slide trình bày.", + "Mỗi slide một thông điệp. Biểu đồ phải có nhãn trục và đơn vị.\n" + "Slide cuối luôn là hành động tiếp theo."), +] + +CO4E_AGENTS = [ + ("Phân tích yêu cầu", "ANALYST", "search", + "Đọc mô tả yêu cầu, bóc tách thành danh sách hạng mục rõ ràng, đánh dấu chỗ còn mơ hồ.", + ["Rà soát bảo mật"]), + ("Thiết kế giải pháp", "ARCHITECT", "flow", + "Từ danh sách hạng mục, đề xuất kiến trúc và các bước triển khai, nêu rõ đánh đổi.", []), + ("Lập trình viên", "CODER", "code", + "Hiện thực theo thiết kế. Viết test trước khi sửa logic nghiệp vụ.", ["Viết test trước"]), + ("Kiểm thử", "TESTER", "shield", + "Chạy test, đọc log lỗi, báo cáo ca nào hỏng và vì sao.", ["Rà soát bảo mật"]), + ("Soạn tài liệu", "WRITER", "book", + "Viết tài liệu hướng dẫn sử dụng từ mã nguồn và test.", ["Tóm tắt tài liệu ISO"]), +] + +WORKFLOWS = [ + ("Quy trình phát triển tính năng", + ["Phân tích yêu cầu", "Thiết kế giải pháp", "Lập trình viên", "Kiểm thử", "Soạn tài liệu"]), + ("Rà soát bảo mật định kỳ", ["Phân tích yêu cầu", "Kiểm thử"]), + ("Dựng báo cáo từ Excel", ["Phân tích yêu cầu", "Lập trình viên", "Soạn tài liệu"]), +] + +AUDIT_EVENTS = [ + ("tool_call", "read_file", True, "app/models/station.py (2.1 KB)"), + ("tool_call", "write_file", True, "app/api/stations.py — tạo mới, 84 dòng"), + ("tool_call", "run_command", True, "pytest tests/test_stations.py → 4 passed"), + ("tool_call", "fetch_url", True, "https://docs.python.org/3/library/asyncio.html"), + ("permission", "run_command", True, "Người dùng duyệt: npm install --save-dev vitest"), + ("permission", "write_file", False, "Người dùng từ chối: ghi đè .env"), + ("security_block", "path_outside_sandbox", False, "Chặn đọc C:\\Users\\NamPDT\\Documents\\personal.xlsx"), + ("security_block", "network_blocked", False, "Chặn kết nối ra 203.0.113.44:8080 (không trong danh sách cho phép)"), + ("security_block", "dangerous_command", False, "Chặn lệnh: rm -rf / --no-preserve-root"), + ("security_block", "secret_in_output", False, "Phát hiện chuỗi giống API key trong đầu ra, đã che"), + ("mcp_call", "filesystem.list_directory", True, "docs/iso → 148 mục"), + ("mcp_call", "jira.search_issues", True, "project=EV AND status=Open → 23 issue"), + ("mcp_call", "postgres.query", True, "SELECT count(*) FROM station_heartbeat → 1.284.902"), + ("mcp_call", "jira.create_issue", False, "401 Unauthorized — API token hết hạn"), + ("mcp_call", "filesystem.read_file", True, "docs/iso/9001-2015.pdf (4.2 MB)"), +] + +MODELS = [("ollama", "qwen2.5-coder:7b"), ("ollama", "llama3.1:8b"), ("openai", "gpt-4o-mini")] +LABELS = ["Dựng API trạng thái trạm sạc", "Sửa lỗi tính tiền sai", "Gom số liệu doanh thu", + "Dựng slide trình bày", "Đánh chỉ mục ISO", "Rà soát bảo mật"] + + +def _iso(dt: datetime) -> str: + return dt.isoformat(timespec="seconds") + + +def seed(days: int = 45) -> dict: + """Fill the (sandboxed) config dir. Returns a per-store count summary.""" + from cowork_local.config import CONFIG_DIR + home = str(Path.home()) + assert str(CONFIG_DIR).startswith(home), "refusing to seed outside the sandboxed HOME" + assert "cowork-capture-" in home or "cowork-seed-" in home, ( + f"HOME ({home}) does not look like a capture sandbox — refusing to seed") + + from cowork_local.core import admin_agents, co4e, history, projects, skills, tasks + + out: dict[str, int] = {} + now = datetime.now().replace(hour=14, minute=32, second=0, microsecond=0) + + # ---- projects --------------------------------------------------------- + made = [] + for name, desc, instr in PROJECTS: + p = projects.new_project(name, description=desc, instructions=instr) + p.workspace_dir().mkdir(parents=True, exist_ok=True) + # a few files so the Folder tab's tree isn't bare + for rel in ("README.md", "src/main.py", "src/billing/session.py", + "tests/test_stations.py", "docs/ghi-chu.md"): + f = p.workspace_dir() / rel + f.parent.mkdir(parents=True, exist_ok=True) + if not f.exists(): + f.write_text(f"# {rel}\n\n(nội dung mẫu cho ảnh chụp)\n", encoding="utf-8") + made.append(p) + out["projects"] = len(made) + + # ---- conversations ---------------------------------------------------- + hist_root = CONFIG_DIR / "history" + hist_root.mkdir(parents=True, exist_ok=True) + n_conv = 0 + for i, (pi, title, pinned, msgs) in enumerate(CONVERSATIONS): + proj = made[pi] + created = _iso(now - timedelta(days=i * 2 + 1, hours=i * 3)) + sid = (now - timedelta(days=i * 2 + 1)).strftime("%Y%m%d-%H%M%S-") + f"{i:03d}" + payload = [{"role": r, "content": c} for r, c in msgs] + for directory in (hist_root, proj.workspace_dir() / ".cowork_history"): + directory.mkdir(parents=True, exist_ok=True) + path = history.save_conversation( + directory, "cowork", sid, payload, title=title, + created=created, project_id=proj.project_id) + if pinned: + history.set_pinned(path, True) + # stagger mtime so the sidebar's newest-first order looks real + ts = (now - timedelta(days=i * 2 + 1)).timestamp() + os.utime(path, (ts, ts)) + n_conv += 1 + out["conversations"] = n_conv + + # ---- scheduled tasks -------------------------------------------------- + for title, status, prio, ai, enabled, off, last in TASKS: + t = tasks.new_task( + title=title, status=status, priority=prio, is_ai_generated=ai, + project_id=made[0].project_id, provider="ollama", model="qwen2.5-coder:7b", + description=f"Tác vụ tự động: {title.lower()}.", + schedule={"enabled": enabled, + "run_at": (now + timedelta(days=off)).strftime("%Y-%m-%d %H:%M"), + "repeat_type": "daily" if enabled else "none"}, + logs={"last_status": last or "", "last_run_id": "run-demo" if last else "", + "last_error": "Chứng chỉ hết hạn 2026-08-06" if last == "failed" else ""}, + ) + if last: + t["runs"] = [{"run_id": f"r{n}", "status": last, + "finished_at": (now - timedelta(days=n)).strftime("%Y-%m-%d %H:%M"), + "error": "Chứng chỉ hết hạn" if last == "failed" else None} + for n in range(1, 4)] + tasks.save_task(t) + out["tasks"] = len(TASKS) + + # ---- skills ----------------------------------------------------------- + for name, desc, instr in SKILLS: + skills.save_skill(skills.Skill(name=name, description=desc, + instructions=instr, enabled=True)) + out["skills"] = len(SKILLS) + + # ---- Co4E agents ------------------------------------------------------ + for name, role, icon, instr, sk in CO4E_AGENTS: + a = co4e.new_custom_agent(name) + a.role, a.icon, a.instructions, a.skills = role, icon, instr, sk + a.model = "qwen2.5-coder:7b" + co4e.save_custom_agent(a) + out["co4e_agents"] = len(CO4E_AGENTS) + + # ---- Co4E workflows --------------------------------------------------- + wfs = [] + for name, steps in WORKFLOWS: + wf = co4e.new_workflow(name) + prev = None + for j, label in enumerate(steps): + node = co4e.Node(id=co4e.new_node_id(), x=60.0 + j * 250, y=140.0 + (j % 2) * 120, + data=co4e.Step(label=label, role="AGENT", + instructions=f"{label}: thực hiện phần việc của mình " + f"rồi chuyển kết quả cho bước sau.", + model="qwen2.5-coder:7b")) + wf.nodes.append(node) + if prev: + wf.edges.append(co4e.Edge(id=co4e.new_edge_id(prev, node.id), + source=prev, target=node.id)) + prev = node.id + co4e.save_workflow(wf) + wfs.append(wf) + out["workflows"] = len(wfs) + + # ---- Co4E run history (no public save — written directly) ------------- + runs = [] + specs = [("done", 5, 5, 0), ("done", 2, 2, 1), ("error", 3, 5, 2), + ("done", 3, 3, 3), ("stopped", 1, 5, 4), ("done", 5, 5, 6)] + for k, (status, done, total, ago) in enumerate(specs, 1): + wf = wfs[k % len(wfs)] + runs.append({ + "id": f"run{k}", "wf_id": wf.id, "name": wf.name, + "total": total, "done": done, "status": status, + "plan_mode": False, "manual": False, "created_by": "local", + "created_at": (now - timedelta(days=ago, hours=k)).strftime("%Y-%m-%d %H:%M"), + "error": "Bước “Kiểm thử” trả về mã lỗi 1" if status == "error" else "", + "node_status": {n.id: ("done" if i < done else + ("error" if status == "error" and i == done else "idle")) + for i, n in enumerate(wf.nodes)}, + "wf": co4e.workflow_to_dict(wf), + "out_dir": str(made[0].workspace_dir()), + "project_id": made[0].project_id, + }) + hp = CONFIG_DIR / "co4e" / "run_history.json" + hp.parent.mkdir(parents=True, exist_ok=True) + hp.write_text(json.dumps({"runs": runs}, ensure_ascii=False, indent=2), encoding="utf-8") + out["co4e_runs"] = len(runs) + + # ---- usage day files (record() cannot backdate) ----------------------- + usage_dir = CONFIG_DIR / "usage" + usage_dir.mkdir(parents=True, exist_ok=True) + n_usage = 0 + for d in range(days): + day = now - timedelta(days=days - 1 - d) + # a workday rhythm: quiet weekends, a gentle upward trend + weekend = day.weekday() >= 5 + turns = rnd.randint(1, 3) if weekend else rnd.randint(4, 11) + d // 12 + lines = [] + for _ in range(turns): + prov, model = rnd.choice(MODELS) + lines.append(json.dumps({ + "ts": _iso(day.replace(hour=rnd.randint(8, 18), minute=rnd.randint(0, 59))), + "source": rnd.choice(["cowork", "cowork", "task", "co4e"]), + "label": rnd.choice(LABELS), "provider": prov, "model": model, + "in": rnd.randint(1200, 9000), "out": rnd.randint(300, 3200), + "cache": rnd.randint(0, 4200), "estimated": False, + "account": "local", "machine": "DESKTOP-DEMO", + }, ensure_ascii=False)) + n_usage += 1 + (usage_dir / f"{day:%Y-%m-%d}.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8") + out["usage_events"] = n_usage + + # ---- audit day files (drives Security / MCP / Action tables) ---------- + audit_dir = CONFIG_DIR / "audit" + audit_dir.mkdir(parents=True, exist_ok=True) + n_audit = 0 + roles = ["cowork", "code", "schedule", "graphrag", "security"] + for d in range(14): + day = now - timedelta(days=13 - d) + lines = [] + for _ in range(rnd.randint(4, 9)): + kind, name, ok, detail = rnd.choice(AUDIT_EVENTS) + lines.append(json.dumps({ + "ts": _iso(day.replace(hour=rnd.randint(8, 19), minute=rnd.randint(0, 59))), + "kind": kind, "agent_role": rnd.choice(roles), "name": name, + "ok": ok, "detail": detail, + "account": "local", "role": "admin", "machine": "DESKTOP-DEMO", + }, ensure_ascii=False)) + n_audit += 1 + (audit_dir / f"{day:%Y-%m-%d}.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8") + out["audit_events"] = n_audit + + # ---- admin agents ----------------------------------------------------- + admin_dir = admin_agents.agents_admin_dir("") + admin_dir.mkdir(parents=True, exist_ok=True) + for name, kind in [("Trợ giúp trong app", "help"), ("Tìm kiếm tài khoản", "search"), + ("Phân tích giám sát", "monitor"), ("Cowork mặc định", "cowork"), + ("Hỏi đáp GraphRAG", "graphrag"), ("Lập lịch thông minh", "schedule"), + ("Kiểm tra lệnh nguy hiểm", "security")]: + a = admin_agents.new_agent(name, task_kind=kind, provider="ollama", + model="qwen2.5-coder:7b", updated_by="local", + prompt=f"Bạn phụ trách chức năng “{kind}” của ứng dụng.") + admin_agents.save_agent(a, admin_dir) + out["admin_agents"] = 7 + + return out + + +if __name__ == "__main__": + raise SystemExit("Import and call seed() from capture_screens.py — it needs the sandboxed HOME.") diff --git a/ui/accounts_tab.py b/ui/accounts_tab.py index e5d5122..da4c0c8 100644 --- a/ui/accounts_tab.py +++ b/ui/accounts_tab.py @@ -28,7 +28,7 @@ from ..core import usage_tracker as ut from ..core.worker import AgentWorker from ..i18n import on_language_changed, tr from ..state import AppContext -from .icons import icon +from .icons import DOT_AMBER, icon from .widgets import fmt_tokens _PERIODS = ("day", "week", "month", "year") @@ -367,7 +367,7 @@ class AccountsTab(QWidget): label = f"{acc.display_name or acc.username} ({acc.username}) — {tr(f'accounts.role.{acc.role}')}" item = QTreeWidgetItem([label]) if is_subadmin: # subadmin badge → star icon instead of a ★ glyph - item.setIcon(0, icon("star", color="#f59e0b")) + item.setIcon(0, icon("star", color=DOT_AMBER)) item.setData(0, Qt.UserRole, ("account", acc.username)) if acc.email: item.setToolTip(0, acc.email) diff --git a/ui/agents_admin_tab.py b/ui/agents_admin_tab.py index ef6ab2d..db02b70 100644 --- a/ui/agents_admin_tab.py +++ b/ui/agents_admin_tab.py @@ -8,18 +8,22 @@ accounts folder, so every machine pointed at the same share picks changes up automatically (OneDrive/network sync) — non-admin machines only ever READ the catalog (their pickers in Cowork / Schedule Task list the enabled agents). -The "Check" button probes each agent's effective provider (``check_agent``) -and shows an operational-status column (🟢 reachable / 🔴 error) separate from -the Enabled config flag. +The header's "Kiểm tra tất cả" icon probes each agent's effective provider +(``check_agent``) and shows the result as the Trạng thái pill (OK / error / +checking…) — separate from the per-row Kích hoạt switch, which only toggles +the config flag. """ from __future__ import annotations +from datetime import datetime from typing import Dict, List, Optional +from PySide6.QtCore import QSize, Qt +from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout, - QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, QTableWidget, - QTableWidgetItem, QVBoxLayout, QWidget, + QHeaderView, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, + QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from ..config import PROVIDER_LABELS @@ -27,10 +31,62 @@ from ..core import admin_agents, preview_ai from ..core.worker import AgentWorker from ..i18n import on_language_changed, tr from ..state import AppContext -from .icons import icon, dot_icon, DOT_GREEN, DOT_RED, DOT_AMBER, DOT_GREY +from .icons import icon +from .widgets import ToggleSwitch, badge_pill_widget _PROVIDER_DEFAULT = "" # "" = each machine's own active provider (unchanged default) +# Identity colour (avatar circle) + badge tone per task_kind — same "fixed +# colour regardless of theme" convention as monitoring_tab.py's per-agent +# avatars, plus a badge object name (theme.py) for the Vai trò pill. Seven +# kinds, seven distinct tones — no two kinds share a badge colour. +_KIND_COLOUR = { + "search": "#8A8886", "monitor": "#FFB900", "cowork": "#0078D4", + "graphrag": "#8764B8", "schedule": "#107C10", "security": "#D13438", + "help": "#E3008C", +} +_KIND_BADGE = { + "search": "badgeNeutral", "monitor": "badgeWarn", "cowork": "badge", + "graphrag": "badgePurple", "schedule": "badgeSuccess", "security": "badgeDanger", + "help": "badgePink", +} +_STATUS_BADGE = { + "unchecked": "badgeNeutral", "checking": "badgeWarn", + "ok": "badgeSuccess", "bad": "badgeDanger", +} + + +def _initials(name: str) -> str: + return "".join(w[0] for w in name.split() if w)[:2].upper() + + +def _fmt_updated(ts: str) -> str: + """"dd/MM hh:mm" — same Cập nhật/Thời gian format as Monitoring's + Bảo mật/MCP/Hành động tables (``_fmt_event_time`` in monitoring_tab.py).""" + try: + dt = datetime.fromisoformat(ts) + except (TypeError, ValueError): + return ts + return dt.strftime("%d/%m %H:%M") + + +def _kind_avatar_icon(kind: str, name: str, size: int = 20) -> QIcon: + pm = QPixmap(size, size) + pm.fill(Qt.transparent) + p = QPainter(pm) + p.setRenderHint(QPainter.Antialiasing) + p.setPen(Qt.NoPen) + p.setBrush(QColor(_KIND_COLOUR.get(kind, "#0078D4"))) + p.drawEllipse(0, 0, size, size) + font = QFont() + font.setPixelSize(max(7, size // 2)) + font.setBold(True) + p.setFont(font) + p.setPen(QColor("#FFFFFF")) + p.drawText(pm.rect(), Qt.AlignCenter, _initials(name)) + p.end() + return QIcon(pm) + class AgentEditDialog(QDialog): """Add/Edit one admin agent. The provider/model pickers are drop-lists, @@ -161,41 +217,79 @@ class AgentsAdminTab(QWidget): self._check_workers: List[AgentWorker] = [] root = QVBoxLayout(self) + + hdr = QHBoxLayout() + self._title_lbl = QLabel() + self._title_lbl.setStyleSheet("font-weight:700; font-size:14px;") + hdr.addWidget(self._title_lbl) + hdr.addStretch(1) + # "Kiểm tra tất cả" keeps the real _check_all action reachable without + # competing with the 2 primary header buttons (Làm mới / + Thêm) — a + # flat, secondary-styled button rather than a 3rd primary one, but + # still labelled: an icon-only button here was a mystery button. + self.check_btn = QPushButton() + self.check_btn.setIcon(icon("check")) + self.check_btn.setFlat(True) + self.check_btn.setCursor(Qt.PointingHandCursor) + self.check_btn.clicked.connect(self._check_all) + hdr.addWidget(self.check_btn) + self.refresh_btn = QPushButton() + self.refresh_btn.setIcon(icon("refresh")) + self.refresh_btn.setCursor(Qt.PointingHandCursor) + self.refresh_btn.clicked.connect(self.refresh) + hdr.addWidget(self.refresh_btn) + self.add_btn = QPushButton() + self.add_btn.setIcon(icon("plus")) + self.add_btn.setObjectName("primary") + self.add_btn.setCursor(Qt.PointingHandCursor) + self.add_btn.clicked.connect(self._add) + hdr.addWidget(self.add_btn) + root.addLayout(hdr) + self._hint = QLabel("") self._hint.setObjectName("hint") self._hint.setWordWrap(True) root.addWidget(self._hint) - self.table = QTableWidget(0, 6) + self.table = QTableWidget(0, 7) self.table.setEditTriggers(QTableWidget.NoEditTriggers) - self.table.setSelectionBehavior(QTableWidget.SelectRows) + # Sửa/Xoá/Kích hoạt are now per-row widgets (button/switch), and Vai + # trò/Trạng thái are pill cell widgets — none of those track a row + # across a re-sort (a cell widget stays pinned to its screen position, + # not to the item that moves — see monitoring_tab.py's _EventTable for + # the same lesson learned the hard way), so this table doesn't sort. + self.table.setSelectionMode(QTableWidget.NoSelection) self.table.verticalHeader().setVisible(False) - self.table.horizontalHeader().setStretchLastSection(True) - self.table.setSortingEnabled(True) + # Fixed row height — letting Qt auto-size rows from content fights + # with the toggle switch / badge cell widgets: their layout settles on + # a stale, oversized geometry from an intermediate sizing pass, which + # then overlaps neighbouring rows (same bug _EventTable hit for its + # Hành động pill, fixed there the same way). + self.table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed) + self.table.verticalHeader().setDefaultSectionSize(32) + self.table.setIconSize(QSize(20, 20)) + header = self.table.horizontalHeader() + header.setStretchLastSection(False) + for col in (0, 6): + header.setSectionResizeMode(col, QHeaderView.ResizeToContents) + # Vai trò/Trạng thái (1, 4) are pill cell widgets — ResizeToContents + # only measures QTableWidgetItem content, so it kept fighting refresh()'s + # manual sizeHint()-based setColumnWidth() and clipping the pill text. + # Interactive leaves whatever width refresh() sets alone. + for col in (1, 4): + header.setSectionResizeMode(col, QHeaderView.Interactive) + header.setSectionResizeMode(2, QHeaderView.Stretch) # Model + header.setSectionResizeMode(5, QHeaderView.ResizeToContents) root.addWidget(self.table, 1) - btns = QHBoxLayout() - self.add_btn = QPushButton() - self.add_btn.setIcon(icon("plus")) - self.add_btn.setObjectName("primary") - self.add_btn.clicked.connect(self._add) - self.edit_btn = QPushButton() - self.edit_btn.setIcon(icon("edit")) - self.edit_btn.clicked.connect(self._edit) - self.del_btn = QPushButton() - self.del_btn.setIcon(icon("trash")) - self.del_btn.clicked.connect(self._delete) - self.check_btn = QPushButton() - self.check_btn.setIcon(icon("check")) - self.check_btn.clicked.connect(self._check_all) - for b in (self.add_btn, self.edit_btn, self.del_btn): - btns.addWidget(b) - btns.addStretch(1) - btns.addWidget(self.check_btn) - root.addLayout(btns) - + # on_language_changed() already invokes _retranslate() once immediately + # (see i18n.py) — calling it again here was a harmless no-op back when + # every column was a plain QTableWidgetItem, but now refresh() also + # populates cell WIDGETS (toggle switch, pills, row actions): running + # it twice back-to-back with no event-loop turn in between left the + # first pass's widgets replaced but not yet deleted, so they briefly + # painted overlapping the second pass's row 0. on_language_changed(self._retranslate) - self._retranslate() # ---- storage --------------------------------------------------------- def _dir(self): @@ -205,17 +299,6 @@ class AgentsAdminTab(QWidget): conf = self.ctx.config.provider_conf(self.ctx.config.active_provider) return conf.get("model", "") - def _selected_agent(self) -> Optional[admin_agents.AdminAgent]: - row = self.table.currentRow() - if row < 0: - return None - item = self.table.item(row, 0) - if item is None: - return None - from PySide6.QtCore import Qt - - return admin_agents.load_agent(item.data(Qt.UserRole), self._dir()) - # ---- CRUD ------------------------------------------------------------- def _add(self) -> None: dlg = AgentEditDialog(self, ctx=self.ctx, default_model_hint=self._default_model_hint()) @@ -232,8 +315,8 @@ class AgentsAdminTab(QWidget): admin_agents.save_agent(agent, self._dir()) self.refresh() - def _edit(self) -> None: - agent = self._selected_agent() + def _edit_agent(self, agent_id: str) -> None: + agent = admin_agents.load_agent(agent_id, self._dir()) if agent is None: return dlg = AgentEditDialog(self, ctx=self.ctx, agent=agent, @@ -243,7 +326,6 @@ class AgentsAdminTab(QWidget): fields = dlg.result_fields() if not fields["name"]: return - from datetime import datetime agent.name = fields["name"] agent.task_kind = fields["task_kind"] @@ -256,8 +338,8 @@ class AgentsAdminTab(QWidget): admin_agents.save_agent(agent, self._dir()) self.refresh() - def _delete(self) -> None: - agent = self._selected_agent() + def _delete_agent(self, agent_id: str) -> None: + agent = admin_agents.load_agent(agent_id, self._dir()) if agent is None: return if QMessageBox.question( @@ -267,27 +349,69 @@ class AgentsAdminTab(QWidget): admin_agents.delete_agent(agent.agent_id, self._dir()) self.refresh() + def _set_enabled(self, agent_id: str, enabled: bool) -> None: + agent = admin_agents.load_agent(agent_id, self._dir()) + if agent is None or agent.enabled == enabled: + return + + agent.enabled = enabled + agent.updated = datetime.now().isoformat(timespec="seconds") + agent.updated_by = (getattr(self.ctx, "account", None).username if getattr(self.ctx, "account", None) else "") + admin_agents.save_agent(agent, self._dir()) + self.refresh() + # ---- view -------------------------------------------------------------- def _status_cell(self, agent_id: str) -> tuple: - """(status_icon | None, display_text, tooltip) for the operational-status - column — a colored LED dot instead of the old 🟢/🔴 emoji.""" + """(state_key, display_text, tooltip) for the Trạng thái pill — + state_key indexes _STATUS_BADGE for the badge's colour tone.""" res = self._status.get(agent_id) if res is None: - return None, tr("agents_admin.status_unchecked"), tr("agents_admin.status_unchecked_tip") + return ("unchecked", tr("agents_admin.status_unchecked").lstrip("— ").strip(), + tr("agents_admin.status_unchecked_tip")) ok, msg = res if msg == "checking": - return dot_icon(DOT_AMBER), tr("agents_admin.status_checking"), "" - ic = dot_icon(DOT_GREEN if ok else DOT_RED) - return ic, (tr("agents_admin.status_ok") if ok else tr("agents_admin.status_bad")), msg + return "checking", tr("agents_admin.status_checking"), "" + return ("ok" if ok else "bad"), (tr("agents_admin.status_ok") if ok else tr("agents_admin.status_bad")), msg + + def _toggle_widget(self, agent_id: str, enabled: bool) -> QWidget: + container = QWidget() + container.setStyleSheet("background: transparent;") + lay = QHBoxLayout(container) + lay.setContentsMargins(6, 0, 0, 0) + sw = ToggleSwitch() + sw.setChecked(enabled) + sw.toggled.connect(lambda checked, aid=agent_id: self._set_enabled(aid, checked)) + lay.addWidget(sw, 0, Qt.AlignVCenter) + lay.addStretch(1) + return container + + def _row_actions_widget(self, agent_id: str) -> QWidget: + container = QWidget() + container.setStyleSheet("background: transparent;") + lay = QHBoxLayout(container) + lay.setContentsMargins(2, 0, 2, 0) + lay.setSpacing(2) + edit_btn = QPushButton() + edit_btn.setIcon(icon("edit")) + edit_btn.setFlat(True) + edit_btn.setCursor(Qt.PointingHandCursor) + edit_btn.setToolTip(tr("agents_admin.edit_row_tooltip")) + edit_btn.clicked.connect(lambda: self._edit_agent(agent_id)) + del_btn = QPushButton() + del_btn.setIcon(icon("trash")) + del_btn.setFlat(True) + del_btn.setCursor(Qt.PointingHandCursor) + del_btn.setToolTip(tr("agents_admin.delete_row_tooltip")) + del_btn.clicked.connect(lambda: self._delete_agent(agent_id)) + lay.addWidget(edit_btn) + lay.addWidget(del_btn) + return container def refresh(self) -> None: - from PySide6.QtCore import Qt - # Make sure the built-in in-app Help assistant exists, so the Admin can # manage its provider/model here (the floating Help widget uses it). admin_agents.ensure_help_agent(self._dir()) agents = admin_agents.list_agents(self._dir()) - self.table.setSortingEnabled(False) self.table.setRowCount(len(agents)) default_model = self._default_model_hint() for row, agent in enumerate(agents): @@ -296,23 +420,36 @@ class AgentsAdminTab(QWidget): model = f"{provider_lbl} — {agent.model}" if provider_lbl else agent.model else: model = tr("agents_admin.default_model", model=default_model or "—") - status_icon, status_text, status_tip = self._status_cell(agent.agent_id) - cells = [agent.name, tr(f"agents_admin.kind.{agent.task_kind}"), - model, "", status_text, agent.updated] - for col, text in enumerate(cells): - item = QTableWidgetItem(str(text)) - if col == 0: - item.setData(Qt.UserRole, agent.agent_id) - if col == 3: # Enabled — green check / grey minus icon (no emoji) - item.setIcon(icon("check", color=DOT_GREEN) if agent.enabled - else icon("minus", color=DOT_GREY)) - if col == 4: - if status_icon: - item.setIcon(status_icon) - if status_tip: - item.setToolTip(status_tip) - self.table.setItem(row, col, item) - self.table.setSortingEnabled(True) + + name_item = QTableWidgetItem(agent.name) + name_item.setIcon(_kind_avatar_icon(agent.task_kind, agent.name)) + self.table.setItem(row, 0, name_item) + + kind_tone = _KIND_BADGE.get(agent.task_kind, "badge") + self.table.setCellWidget( + row, 1, badge_pill_widget(tr(f"agents_admin.kind.{agent.task_kind}"), kind_tone)) + + self.table.setItem(row, 2, QTableWidgetItem(model)) + self.table.setCellWidget(row, 3, self._toggle_widget(agent.agent_id, agent.enabled)) + + state_key, status_text, status_tip = self._status_cell(agent.agent_id) + status_widget = badge_pill_widget(status_text, _STATUS_BADGE[state_key]) + if status_tip: + status_widget.setToolTip(status_tip) + self.table.setCellWidget(row, 4, status_widget) + + self.table.setItem(row, 5, QTableWidgetItem(_fmt_updated(agent.updated))) + self.table.setCellWidget(row, 6, self._row_actions_widget(agent.agent_id)) + + # ResizeToContents doesn't measure a cell WIDGET's real width (only + # QTableWidgetItem content) — size the Vai trò/Trạng thái pill columns + # by hand, or their text clips against whatever width it guessed. + if self.table.rowCount(): + for col in (1, 4): + needed = max(self.table.cellWidget(r, col).sizeHint().width() + for r in range(self.table.rowCount())) + if needed + 24 > self.table.columnWidth(col): + self.table.setColumnWidth(col, needed + 24) def _check_all(self) -> None: """Health-check every agent's effective provider off the UI thread and @@ -347,15 +484,15 @@ class AgentsAdminTab(QWidget): w.start() def _retranslate(self) -> None: + self._title_lbl.setText(tr("agents_admin.page_title")) self._hint.setText(tr("agents_admin.hint")) self.table.setHorizontalHeaderLabels([ tr("agents_admin.col_name"), tr("agents_admin.col_kind"), tr("agents_admin.col_model"), tr("agents_admin.col_enabled"), - tr("agents_admin.col_status"), tr("agents_admin.col_updated"), + tr("agents_admin.col_status"), tr("agents_admin.col_updated"), "", ]) self.add_btn.setText(tr("agents_admin.add_btn")) - self.edit_btn.setText(tr("agents_admin.edit_btn")) - self.del_btn.setText(tr("agents_admin.delete_btn")) + self.refresh_btn.setText(tr("monitoring.refresh")) self.check_btn.setText(tr("agents_admin.check_btn")) self.check_btn.setToolTip(tr("agents_admin.check_tooltip")) self.refresh() diff --git a/ui/calendar_view.py b/ui/calendar_view.py index dc09cca..861e20a 100644 --- a/ui/calendar_view.py +++ b/ui/calendar_view.py @@ -20,6 +20,7 @@ from ..core.calendar_grid import ( GRANULARITIES, group_tasks_by_date, month_grid, month_task_counts, shift_period, week_days, ) from ..i18n import on_language_changed, tr +from ..theme import current_palette from .icons import icon _WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun") @@ -56,20 +57,21 @@ class _DayCell(QFrame): today: bool = False, weekend: bool = False) -> None: self._date_str = d.isoformat() self.date_lbl.setText(str(d.day)) - num_color = "#0096C7" if today else ("#888" if dim else "") - self.date_lbl.setStyleSheet(f"font-weight:700; color:{num_color};") - # Today = accent border + stronger tint; weekend (Sat/Sun) = a subtle - # darker-blue tint than the base cell. rgba overlays read correctly on - # both light and dark themes. - base_border = "1px solid rgba(128,128,128,0.35)" + p = current_palette() + num_color = p.accent if today else (p.text_faint if dim else p.text) + self.date_lbl.setStyleSheet(f"font-weight:600; color:{num_color};") + # Today is the only cell that gets a filled surface + accent border; + # weekends are set apart by a recessed surface alone, so the eye lands + # on "today" first and on the weekend block only when scanning. + r = p.radius if today: - css = ("#dayCell { background: rgba(0,150,199,0.22); " - "border: 2px solid #0096C7; border-radius: 6px; }") + css = (f"#dayCell {{ background: {p.accent_soft}; " + f"border: 1px solid {p.accent}; border-radius: {r}px; }}") elif weekend: - css = ("#dayCell { background: rgba(0,120,182,0.13); " - f"border: {base_border}; border-radius: 6px; }}") + css = (f"#dayCell {{ background: {p.surface}; " + f"border: 1px solid {p.border}; border-radius: {r}px; }}") else: - css = f"#dayCell {{ border: {base_border}; border-radius: 6px; }}" + css = f"#dayCell {{ border: 1px solid {p.border}; border-radius: {r}px; }}" self.setStyleSheet(css) self.list.clear() for t in tasks: diff --git a/ui/chat_panel.py b/ui/chat_panel.py index 936e227..9457d13 100644 --- a/ui/chat_panel.py +++ b/ui/chat_panel.py @@ -24,6 +24,7 @@ from PySide6.QtWidgets import ( from ..core.worker import AgentWorker from ..i18n import on_language_changed, tr from ..state import AppContext +from ..theme import current_palette from .chat_view import ChatView, ThinkingIndicator from .composer import Composer from .icons import collapse_right_icon, icon as app_icon @@ -83,6 +84,7 @@ class ChatPanel(QWidget): self.session_name = session_name self.session_id = new_session_id() self.title = "" + self._notify_title() # Which project (workspace) this conversation belongs to — every new # thread inherits the currently selected project (Claude-Projects style). self.project_id = "default" @@ -140,8 +142,8 @@ class ChatPanel(QWidget): # updated after each turn; cost uses the Monitoring model-price table. self._usage_total_lbl = QLabel("") self._usage_total_lbl.setObjectName("hint") - self._usage_total_lbl.setStyleSheet("color: rgba(140,146,152,0.9);") - self.composer.add_bottom_left(self._usage_total_lbl) + self._usage_total_lbl.setStyleSheet(f"color: {current_palette().text_faint};") + # Per-tab "Agent" = which MODEL this tab uses (e.g. deepseek / gemma / # qwen for the local provider). Cowork and Code pick independently and @@ -165,13 +167,19 @@ class ChatPanel(QWidget): self.agent_combo.setMinimumWidth(150) self.agent_combo.setToolTip(tr("chatpanel.agent_tooltip")) self.agent_combo.currentIndexChanged.connect(self._on_agent_changed) - self.composer.add_bottom_right(self._agent_lbl) - self.composer.add_bottom_right(self.agent_combo) + self.composer.add_bottom_left(self._agent_lbl) + self.composer.add_bottom_left(self.agent_combo) # Off/Auto/Manual routing toggle — lets the router pick the best-fit # model per message (see core/routing + _apply_routing). from .routing_toggle import RoutingToggle self.routing_toggle = RoutingToggle(ctx, self.kind) - self.composer.add_bottom_right(self.routing_toggle) + # The drawing reads the strip left to right as + # Agent: … · Định tuyến: … · ↓in ↑out · $cost · 📁 folder + # so these sit together on the left, with the folder box the Cowork tab + # appends landing after them. Nén and Tự chạy stay on the right, where + # the control inventory marks them "giữ nguyên tại chỗ". + self.composer.add_bottom_left(self.routing_toggle) + self.composer.add_bottom_left(self._usage_total_lbl) # Manual "compress conversation" — trim old history to cut tokens. self.compress_btn = QPushButton(tr("chatpanel.compress_btn")) self.compress_btn.setIcon(app_icon("compress")) @@ -188,20 +196,29 @@ class ChatPanel(QWidget): cc.addWidget(self.chat_view, 1) self.thinking = ThinkingIndicator() # animated "working…" line while we wait cc.addWidget(self.thinking) - composer_wrap = QWidget() - cwl = QVBoxLayout(composer_wrap) - cwl.setContentsMargins(8, 4, 8, 8) - cwl.addWidget(self.composer) - cc.addWidget(composer_wrap) - self.center_split = QSplitter(Qt.Horizontal) self.center_split.addWidget(chat_col) root.addWidget(self.center_split, 1) + # The composer spans the whole screen, under BOTH columns — that is how + # the drawing lays it out, and it is the reason the files panel can sit + # beside the transcript without narrowing what you type into. Inside the + # chat column it stopped at the panel's edge and the input shrank + # whenever files appeared. + composer_wrap = QWidget() + cwl = QVBoxLayout(composer_wrap) + cwl.setContentsMargins(8, 4, 8, 8) + cwl.addWidget(self.composer) + root.addWidget(composer_wrap) + # Right sidebar: Output files only (see below — Input is tracked but # not shown). self.input_section = CollapsibleSection(tr("widgets.input_files")) - self.output_section = CollapsibleSection(tr("widgets.output_files")) + # No cap: this section owns the whole right panel (its header is + # hoisted into io_hdr below), so the list should fill the space down + # to the composer instead of stopping at a fixed height with empty + # panel below it. + self.output_section = CollapsibleSection(tr("widgets.output_files").upper(), max_height=None) # Input files are NOT shown in Cowork's UI anymore — but they're still # fully tracked (add/remove/paths()) exactly as before, since that list # is what gets written into the conversation's own "inputs" field on @@ -241,8 +258,15 @@ class ChatPanel(QWidget): self._io_collapse_btn.clicked.connect(lambda: self._set_io_collapsed(True)) self._files_header = QLabel() self._files_header.setStyleSheet("font-weight:600;") + # The drawing gives this panel ONE heading — "TỆP ĐẦU RA (3) ›" — and + # the section already draws exactly that, count included. A separate + # "Files" label above it was the same thing said twice, so the section's + # own header moves onto this row and the collapse chevron sits at its + # right, where the drawing puts it. _files_header stays for the tabs + # that still label their panel, just not in this layout. + self._files_header.setVisible(False) + io_hdr.addWidget(self.output_section.header, 1) io_hdr.addWidget(self._io_collapse_btn) - io_hdr.addWidget(self._files_header, 1) # The plan now shows INLINE in the conversation (an expandable block whose # steps tick off as they complete), not in this right panel — so it's kept # out of the layout here. The object stays (its set_steps/clear calls are @@ -253,8 +277,7 @@ class ChatPanel(QWidget): bl = QVBoxLayout(bl_host) bl.setContentsMargins(0, 0, 0, 0) bl.setSpacing(4) - bl.addWidget(self.output_section) # Output only — Input is tracked but hidden - bl.addStretch(1) + bl.addWidget(self.output_section, 1) # Output only — Input is tracked but hidden; fills down to the composer iol.addWidget(bl_host, 1) # Collapsing shrinks the panel to a thin clickable line (not hidden). @@ -284,7 +307,7 @@ class ChatPanel(QWidget): self.compress_btn.setText(tr("chatpanel.compress_btn")) self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip")) self.input_section.set_title(tr("widgets.input_files")) - self.output_section.set_title(tr("widgets.output_files")) + self.output_section.set_title(tr("widgets.output_files").upper()) self.plan_section.set_title(tr("widgets.plan_title")) self._io_collapse_btn.setToolTip(tr("chatpanel.collapse_files_tooltip")) self._files_header.setText(tr("chatpanel.files_header")) @@ -1023,6 +1046,7 @@ class ChatPanel(QWidget): if not self.title: base = text or (Path(attachments[0]).name if attachments else "(attachment)") self.title = (base[:60] + "…") if len(base) > 60 else base + self._notify_title() # Reset the Plan panel so each message starts from a clean checklist (the # previous message's plan never lingers/flickers into this one). @@ -1398,6 +1422,26 @@ class ChatPanel(QWidget): return [e for e in ut.load_events() if e.get("source") == self.kind and e.get("label") == label] + def refresh_usage(self) -> None: + """Show what this conversation has already cost. + + The label was written only at the end of a turn, so opening a thread + from History left the strip blank however much it had spent. + """ + from ..core import model_pricing as mp + from ..core import usage_tracker as ut + + cur = self._usage_snapshot() + if not (cur["in"] or cur["out"] or cur["cache"]): + self._usage_total_lbl.setText("") + return + # same source _show_usage reads, so the two never disagree + pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})} + self._usage_total_lbl.setText( + f"↓{mp.format_tokens(cur['in'])} ↑{mp.format_tokens(cur['out'])} " + f"▤{mp.format_tokens(cur['in'] + cur['out'] + cur['cache'])} " + f"{ut.format_cost(self._session_cost_usd(), pricing)}") + def _usage_snapshot(self) -> Dict[str, int]: """Cumulative in/out/cache tokens for THIS conversation so far.""" snap = {"in": 0, "out": 0, "cache": 0} @@ -1668,6 +1712,18 @@ class ChatPanel(QWidget): self._sync_indicators() self.history_changed.emit() # current view changed → refresh History highlight + def _notify_title(self) -> None: + """Let a screen that heads itself with the thread title follow along. + + The thread also decides what the usage strip should read, so refresh + that here rather than at each of the three places the title changes. + """ + hook = getattr(self, "refresh_title", None) + if callable(hook): + hook() + if getattr(self, "_usage_total_lbl", None) is not None: + self.refresh_usage() + def load_conversation(self, conv: Dict[str, Any]) -> None: """Switch the view to a stored conversation. Allowed while work is running — the current turns keep going in the background.""" @@ -1679,6 +1735,7 @@ class ChatPanel(QWidget): self._detach_live_turns() self.session_id = sid self.title = conv.get("title", "") + self._notify_title() self.project_id = conv.get("project_id", "") or "default" # If this conversation still has a turn running in the background, attach to # its LIVE message list (not a stale disk copy) so the two never race on save. diff --git a/ui/chat_view.py b/ui/chat_view.py index dc2c6d4..5e4bc96 100644 --- a/ui/chat_view.py +++ b/ui/chat_view.py @@ -12,7 +12,7 @@ from PySide6.QtWidgets import ( ) from ..i18n import on_language_changed, tr -from ..theme import ACCENT, resolve_theme +from ..theme import palette, resolve_theme from ..config import CONFIG_DIR from .osutil import is_image, open_folder, open_path @@ -28,11 +28,18 @@ def _app_theme() -> str: return "dark" -# Timeline dot color per role (reads on both themes — small, saturated). -_DOT = { - "user": "#48CAE4", "assistant": "#48D9A0", "tool": "#9B8FF7", - "error": "#E5484D", "success": "#48D9A0", -} +def _p(): + """Design tokens for the theme in effect right now.""" + return palette(_app_theme()) + + +def _dot_color(role: str) -> str: + """Timeline dot colour for a message role.""" + p = _p() + return { + "user": p.role_user, "assistant": p.role_assistant, "tool": p.role_tool, + "error": p.role_error, "success": p.role_result, + }.get(role, p.text_faint) class _TimelineGutter(QWidget): @@ -52,17 +59,17 @@ class _TimelineGutter(QWidget): def paintEvent(self, _e): # noqa: N802 p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) - dark = _app_theme() == "dark" + tok = _p() x = 11.0 cy = 15.0 # connector line (faint) running the full height → continuous rail - p.setPen(QPen(QColor("#243a56" if dark else "#CBDDEC"), 2)) + p.setPen(QPen(QColor(tok.border), 2)) p.drawLine(int(x), 0, int(x), self.height()) # a background ring lifts the dot off the line p.setPen(Qt.NoPen) - p.setBrush(QColor("#0A1628" if dark else "#E8F4FD")) + p.setBrush(QColor(tok.bg)) p.drawEllipse(QPointF(x, cy), 7.5, 7.5) - p.setBrush(QColor(_DOT.get(self._role, "#8FB2D4"))) + p.setBrush(QColor(_dot_color(self._role))) p.drawEllipse(QPointF(x, cy), 4.5, 4.5) @@ -72,18 +79,20 @@ def _diff_legend(diff_text: str) -> str: so the before/after distinction is explicit, not just implied by color.""" has_add = any(ln.startswith("+") and not ln.startswith("+++") for ln in diff_text.splitlines()) has_del = any(ln.startswith("-") and not ln.startswith("---") for ln in diff_text.splitlines()) - before = (f'{html.escape(tr("chat.diff_before"))}') - after = (f'{html.escape(tr("chat.diff_after"))}') + p = _p() + + def pill(bg: str, fg: str, key: str) -> str: + return (f'{html.escape(tr(key))}') + if has_add and has_del: - badge = f'{before} → {after}' + badge = (pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_before") + + f' → ' + + pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_after")) elif has_add: - badge = (f'{html.escape(tr("chat.diff_added"))}') + badge = pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_added") elif has_del: - badge = (f'{html.escape(tr("chat.diff_removed"))}') + badge = pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_removed") else: return "" return f'
    {badge}
    ' @@ -97,21 +106,22 @@ def diff_to_html(diff_text: str) -> str: empty 'before') naturally renders as all-green, which is exactly what ``difflib.unified_diff`` already produces for it.""" legend = _diff_legend(diff_text) + p = _p() rows = [] for ln in diff_text.splitlines(): esc = html.escape(ln) if ln else " " if ln.startswith(("+++", "---")): - rows.append(f'
    {esc}
    ') + rows.append(f'
    {esc}
    ') elif ln.startswith("@@"): - rows.append(f'
    {esc}
    ') + rows.append(f'
    {esc}
    ') elif ln.startswith("+"): - rows.append(f'
    {esc}
    ') + rows.append(f'
    {esc}
    ') elif ln.startswith("-"): - rows.append(f'
    {esc}
    ') + rows.append(f'
    {esc}
    ') else: rows.append(f"
    {esc}
    ") body = "".join(rows) or "(no textual change)" - return (f'{legend}
    {body}
    ') @@ -219,12 +229,12 @@ class MessageBubble(QFrame): self._head.setCursor(Qt.PointingHandCursor) self._head.setStyleSheet( "QPushButton { text-align:left; border:none; background:transparent;" - " font-weight:600; color:#8b8d98; padding:0; }") + f" font-weight:600; color:{_p().text_muted}; padding:0; }}") self._head.clicked.connect(self._toggle_body) lay.addWidget(self._head) else: head = QLabel(title) - head.setStyleSheet("font-weight:600; color:#8b8d98;") + head.setStyleSheet(f"font-weight:600; color:{_p().text_muted};") lay.addWidget(head) self.body = QTextBrowser() @@ -265,36 +275,23 @@ class MessageBubble(QFrame): def _apply_theme_styles(self, role: str) -> None: """Apply text color to the body QTextBrowser based on current theme + role.""" - theme = self._current_theme() - if theme == "light": - if role == "success": - text_color = "#1B7A3D" - elif role == "error": - text_color = "#C0392B" - elif role in ("tool",): - text_color = "#5C6B7A" # muted (secondary) like Claude's steps - else: - text_color = "#1A2332" - else: - if role == "success": - text_color = "#7ee2a8" - elif role == "error": - text_color = "#ff9aa8" - elif role in ("tool",): - text_color = "#9aa6b8" - else: - text_color = "#eceef2" + p = _p() + text_color = { + "success": p.success, + "error": p.danger, + "tool": p.text_muted, # secondary, like Claude's steps + }.get(role, p.text) self.body.setStyleSheet(f"background: transparent; border: none; color: {text_color};") def _apply_style(self, role: str) -> None: """Flat timeline row — no bubble box; the left dot/rail conveys role and structure (Claude-Code style). The user's own message gets a faint tint so questions are easy to pick out when scanning.""" - theme = self._current_theme() + p = _p() if role == "user": - tint = "rgba(72,202,228,0.10)" if theme == "dark" else "rgba(72,202,228,0.14)" self.setStyleSheet( - f"QFrame {{ background: {tint}; border: none; border-radius: 10px; }}") + f"QFrame {{ background: {p.surface}; border: none; " + f"border-radius: {p.radius}px; }}") else: self.setStyleSheet("QFrame { background: transparent; border: none; }") @@ -353,20 +350,20 @@ class MessageBubble(QFrame): existing.setText(text) return lbl = QLabel(text) - lbl.setObjectName("hint") - lbl.setStyleSheet("color: rgba(140,146,152,0.9); font-size: 11px;") + lbl.setObjectName("faint") + lbl.setStyleSheet(f"color: {_p().text_faint}; font-size: 11px;") self._usage_lbl = lbl self._content_layout.addWidget(lbl) def add_delete_link(self, callback) -> None: - link = QLabel(f'{tr("chat.delete_link")}') + link = QLabel(f'{tr("chat.delete_link")}') link.setToolTip(tr("chat.delete_tooltip")) link.linkActivated.connect(lambda *_: callback()) self._content_layout.addWidget(link) def add_folder_link(self, folder: str, label: str | None = None) -> None: label = label or tr("chat.open_workspace") - link = QLabel(f'{label}') + link = QLabel(f'{label}') link.setToolTip(str(folder)) link.linkActivated.connect(lambda *_: open_folder(folder)) self._content_layout.addWidget(link) @@ -385,7 +382,7 @@ class MessageBubble(QFrame): thumb.setCursor(Qt.PointingHandCursor) self._content_layout.addWidget(thumb) continue - file_link = QLabel(f'{name}') + file_link = QLabel(f'{name}') file_link.setToolTip(path) file_link.linkActivated.connect(lambda *_a, fp=path: open_path(fp)) self._content_layout.addWidget(file_link) diff --git a/ui/co4e_canvas.py b/ui/co4e_canvas.py index eabd988..e47e1a7 100644 --- a/ui/co4e_canvas.py +++ b/ui/co4e_canvas.py @@ -27,13 +27,20 @@ from ..core.co4e import ( STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node, Step, compute_waves, new_edge_id, new_node_id, ) +from ..theme import current_palette + + +def _status_color(status: str) -> str: + """Accent colour for a step's run status. Resolved per paint so the canvas + follows a live theme switch.""" + p = current_palette() + return { + "idle": p.text_muted, STEP_RUNNING: p.accent, STEP_DONE: p.success, + STEP_ERROR: p.danger, STEP_PLANNED: p.purple, "pending": p.text_faint, + }.get(status, p.text_muted) CO4E_MIME = "application/x-co4e-step" -_STATUS_COLOR = { - "idle": "#5C8DB8", STEP_RUNNING: "#48CAE4", STEP_DONE: "#48D9A0", - STEP_ERROR: "#E5484D", STEP_PLANNED: "#9B8FF7", "pending": "#7A8DA8", -} _NODE_W, _NODE_H = 210, 96 _PORT_R = 6 # output port radius (the drag-to-connect handle) _PORT_HIT = 15 # click tolerance around a port @@ -63,24 +70,29 @@ class _NodeItem(QGraphicsObject): return QRectF(1, 1, _NODE_W - 2, _NODE_H - 2) def paint(self, p, _opt, _widget=None): + tok = current_palette() step = self.node.data - accent = QColor(_STATUS_COLOR.get(self.status, "#5C8DB8")) - body = QColor("#0D1F35") - border = QColor("#48CAE4") if self.isSelected() else QColor("#1A2D4A") + accent = QColor(_status_color(self.status)) + body = QColor(tok.surface_raised) + border = QColor(tok.accent) if self.isSelected() else QColor(tok.border) p.setRenderHint(p.RenderHint.Antialiasing) rect = self._card_rect() path = QPainterPath() - path.addRoundedRect(rect, 10, 10) + radius = float(tok.radius_lg) + path.addRoundedRect(rect, radius, radius) p.fillPath(path, QBrush(body)) p.setPen(QPen(border, 2 if self.isSelected() else 1)) p.drawPath(path) - # header stripe + # header stripe — a tint of the status colour, not the status colour + # itself, so the card's own text stays the brightest thing on it. hdr = QRectF(rect.left(), rect.top(), rect.width(), 26) hpath = QPainterPath() - hpath.addRoundedRect(hdr, 10, 10) - p.fillPath(hpath, QBrush(accent.darker(160))) + hpath.addRoundedRect(hdr, radius, radius) + stripe = QColor(accent) + stripe.setAlpha(48) + p.fillPath(hpath, QBrush(stripe)) # label - p.setPen(QColor("#E0F0FF")) + p.setPen(QColor(tok.text)) f = p.font(); f.setBold(True); f.setPointSize(9); p.setFont(f) p.drawText(QRectF(10, 4, _NODE_W - 20, 20), Qt.AlignVCenter | Qt.AlignLeft, _elide(step.label, 26)) @@ -89,7 +101,7 @@ class _NodeItem(QGraphicsObject): p.setPen(accent) p.drawText(QRectF(10, 30, _NODE_W - 20, 16), Qt.AlignLeft, step.role) # body: instructions preview OR sub-agent chips - p.setPen(QColor("#8FB2D4")) + p.setPen(QColor(tok.text_muted)) if step.is_parallel: preview = "⇉ " + ", ".join(s.agent for s in step.sub_agents) if step.sub_agents else "⇉ (no sub-agents)" else: @@ -97,7 +109,7 @@ class _NodeItem(QGraphicsObject): p.drawText(QRectF(10, 46, _NODE_W - 20, 30), Qt.TextWordWrap | Qt.AlignTop, _elide(preview, 66)) # footer: model + skills + status dot - p.setPen(QColor("#5C8DB8")) + p.setPen(QColor(tok.text_faint)) foot = [] if step.model: foot.append(step.model) @@ -109,7 +121,7 @@ class _NodeItem(QGraphicsObject): # ---- ports --------------------------------------------------------- # input port (top-center): hollow. output port (bottom-center): filled — # the drag handle you pull to wire an edge to another step. - port_col = QColor("#48CAE4") + port_col = QColor(tok.accent) # input port (left-center): hollow. output port (right-center): filled — # the drag handle you pull to wire an edge to the next step (left→right). p.setBrush(QBrush(body)); p.setPen(QPen(port_col, 1.4)) @@ -298,12 +310,13 @@ class _EdgeItem(QGraphicsPathItem): self._apply_pen() def _apply_pen(self): + tok = current_palette() if self.isSelected(): - color, w = QColor("#48CAE4"), 3 + color, w = QColor(tok.accent), 3 elif self._hover: - color, w = QColor("#6FA8C8"), 3 + color, w = QColor(tok.text_muted), 3 else: - color, w = QColor("#3A5A78"), 2 + color, w = QColor(tok.border_strong), 2 self.setPen(QPen(color, w, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) def update_path(self, points): @@ -491,7 +504,8 @@ class Co4ECanvas(QGraphicsView): self._port_src_pt = scene_pt self._temp_edge = QGraphicsPathItem() self._temp_edge.setZValue(3.5) # above nodes + edges while connecting - self._temp_edge.setPen(QPen(QColor("#48CAE4"), 2, Qt.DashLine, Qt.RoundCap)) + self._temp_edge.setPen( + QPen(QColor(current_palette().accent), 2, Qt.DashLine, Qt.RoundCap)) self._scene.addItem(self._temp_edge) def update_port_drag(self, scene_pt: QPointF) -> None: diff --git a/ui/co4e_config_panel.py b/ui/co4e_config_panel.py index 327220d..fd2e928 100644 --- a/ui/co4e_config_panel.py +++ b/ui/co4e_config_panel.py @@ -11,18 +11,124 @@ from __future__ import annotations from typing import List, Optional -from PySide6.QtCore import Qt, Signal +from PySide6.QtCore import Qt, QEasingCurve, QPropertyAnimation, Signal from PySide6.QtWidgets import ( - QCheckBox, QComboBox, QFormLayout, QHBoxLayout, QInputDialog, QLabel, - QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit, QPushButton, - QScrollArea, QSpinBox, QVBoxLayout, QWidget, + QCheckBox, QComboBox, QFormLayout, QHBoxLayout, QInputDialog, + QLabel, QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit, + QPushButton, QScrollArea, QSpinBox, QVBoxLayout, QWidget, ) from ..config import PROVIDER_LABELS from ..core.co4e import PERMISSION_PRESETS, Step, SubAgent from ..i18n import tr +from ..theme import current_palette from .icons import icon, icon_picker_combo +_SECTION_ANIM_MS = 180 + + +class _SectionHeader(QLabel): + """A clickable label — a QPushButton's own style chrome (border, native + button margin, focus rect) always leaves a taller minimum height than a + plain label, even once its QSS padding is zeroed out, so the header that + needs to sit tight against its neighbours is a label, not a button.""" + + clicked = Signal() + + def mousePressEvent(self, event) -> None: # noqa: N802 + if event.button() == Qt.LeftButton: + self.clicked.emit() + super().mousePressEvent(event) + + def showEvent(self, event) -> None: # noqa: N802 + # fontMetrics() at construction time (before this label is ever part + # of a shown top-level window) reflects the QSS font-size only if the + # style has fully polished by then — on the very FIRST paint of the + # Co4E screen it sometimes hasn't, so the fixed height computed in + # _add_section is briefly wrong (too tall) until something else + # triggers a relayout. Recomputing here, every time the label + # actually becomes visible, means the first paint is never stale. + self.setFixedHeight(self.fontMetrics().height()) + super().showEvent(event) + + +def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]: + """One group of fields, collapsed to just its heading by default and + independently expandable, so a long step config reads as a short list of + group names until you open the one you need. Deliberately bare — no card + border/background/box — the ▶/▼ marker and the heading text are the only + things separating one group from the next; opening one never closes + another (not an accordion, not a tab bar). Returns ``(form, card)``: add + the group's rows to ``form``; ``card`` is the whole section (header + + body) — hide it to remove the group entirely (e.g. for a section that + only applies to some steps), rather than hiding individual rows inside + an always-visible header.""" + p = current_palette() + card = QWidget() + card_lay = QVBoxLayout(card) + card_lay.setContentsMargins(0, 0, 0, 0) + card_lay.setSpacing(0) + + header = _SectionHeader() + header.setCursor(Qt.PointingHandCursor) + header.setStyleSheet(f"font-weight:400; font-size:13px; color:{p.text}; padding:0; margin:0;") + header.setContentsMargins(0, 0, 0, 0) + # QSS font-size only lands on the widget's actual QFont (and therefore + # its fontMetrics()) once the style sheet is polished — ensurePolished() + # forces that now, so the fixed height below is computed from the 12px + # font just set above, not the default one this label was constructed + # with. A label's natural sizeHint still reserves font leading above/ + # below the glyphs on top of the (now zeroed) QSS padding — pinning the + # height to the text's actual cap-to-baseline span is what closes that + # last gap without clipping the ▶ glyph, the title, or Vietnamese + # diacritics. + header.ensurePolished() + header.setFixedHeight(header.fontMetrics().height()) + header.setText(f"▶ {title}") + card_lay.addWidget(header) + + body = QWidget() + body.setVisible(False) + body.setMaximumHeight(0) + form = QFormLayout(body) + form.setContentsMargins(0, 6, 0, 0) + card_lay.addWidget(body) + + anim = QPropertyAnimation(body, b"maximumHeight", body) + anim.setDuration(_SECTION_ANIM_MS) + anim.setEasingCurve(QEasingCurve.InOutCubic) + + is_open = False + + def _on_finished() -> None: + if is_open: + # Uncapped once open, so switching to a step whose fields make + # this section taller/shorter (e.g. a parallel node's sub-agent + # list appearing) is never clipped by the height this animation + # last landed on. + body.setMaximumHeight(16_777_215) + else: + body.setVisible(False) + anim.finished.connect(_on_finished) + + def _toggle() -> None: + nonlocal is_open + is_open = not is_open + header.setText(f"{'▼' if is_open else '▶'} {title}") + anim.stop() + if is_open: + body.setVisible(True) + anim.setStartValue(body.height()) + anim.setEndValue(body.sizeHint().height()) + else: + anim.setStartValue(body.height()) + anim.setEndValue(0) + anim.start() + header.clicked.connect(_toggle) + + outer.addWidget(card) + return form, card + class StepConfigPanel(QScrollArea): changed = Signal() # any field edited → repaint node + autosave @@ -39,7 +145,16 @@ class StepConfigPanel(QScrollArea): self.setWidgetResizable(True) host = QWidget() self.setWidget(host) - form = QFormLayout(host) + outer = QVBoxLayout(host) + outer.setSpacing(1) + + # Grouped sections stacked on one scrolling page — same fields as + # before, grouped by what they're for: identity, execution + # (model/permission), and the extra resources fed to the step + # (skills/files/sub-agents). No tabs/accordion: every group's border + # and heading are what separate it from its neighbours, and all three + # are on screen (or one scroll away) at once. + form, _basic_card = _add_section(outer, tr("co4e.tab_basic")) self.label_edit = QLineEdit() self.label_edit.textChanged.connect(self._on_edit) @@ -80,6 +195,8 @@ class StepConfigPanel(QScrollArea): self.context_edit.textChanged.connect(self._on_edit) form.addRow(tr("co4e.f_context"), self.context_edit) + form2, _model_card = _add_section(outer, tr("co4e.tab_model_perm")) + model_row = QHBoxLayout() self.model_combo = QComboBox() self.model_combo.setEditable(True) @@ -92,13 +209,13 @@ class StepConfigPanel(QScrollArea): model_row.addWidget(self.model_combo, 1) model_row.addWidget(self.load_models_btn) mrow = QWidget(); mrow.setLayout(model_row) - form.addRow(tr("co4e.f_model"), mrow) + form2.addRow(tr("co4e.f_model"), mrow) self.perm_combo = QComboBox() for preset in PERMISSION_PRESETS: self.perm_combo.addItem(tr(f"co4e.perm.{preset}"), preset) self.perm_combo.currentIndexChanged.connect(self._on_edit) - form.addRow(tr("co4e.f_permission"), self.perm_combo) + form2.addRow(tr("co4e.f_permission"), self.perm_combo) verify_row = QHBoxLayout() self.verify_chk = QCheckBox(tr("co4e.f_self_verify")) @@ -111,13 +228,15 @@ class StepConfigPanel(QScrollArea): verify_row.addWidget(self.rounds_spin) verify_row.addStretch(1) vrow = QWidget(); vrow.setLayout(verify_row) - form.addRow("", vrow) + form2.addRow("", vrow) + + form3, _skills_card = _add_section(outer, tr("co4e.tab_skills_files")) # Skills checklist (registry skills) self.skills_list = QListWidget() self.skills_list.setMaximumHeight(110) self.skills_list.itemChanged.connect(self._on_edit) - form.addRow(tr("co4e.f_skills"), self.skills_list) + form3.addRow(tr("co4e.f_skills"), self.skills_list) # Attachments — files whose extracted text is fed to this step at run time. self.attach_list = QListWidget() @@ -133,11 +252,15 @@ class StepConfigPanel(QScrollArea): att_btns.addWidget(self.attach_del_btn) att_btns.addStretch(1) abtn = QWidget(); abtn.setLayout(att_btns) - form.addRow(tr("co4e.f_attachments"), self.attach_list) - form.addRow("", abtn) + form3.addRow(tr("co4e.f_attachments"), self.attach_list) + form3.addRow("", abtn) - # Parallel sub-agents (only shown for parallel nodes) - self.parallel_label = QLabel(tr("co4e.f_subagents")) + # Parallel sub-agents get their OWN section — same header style as + # Cơ bản/Model & Quyền/Skills & Tệp — rather than a row buried inside + # Skills & Tệp, since it's really a distinct group, just one that + # only applies to parallel-variant steps. load_step() hides the whole + # card for a non-parallel step (see is_par below). + form4, self._parallel_card = _add_section(outer, tr("co4e.f_subagents")) self.sub_list = QListWidget() self.sub_list.setMaximumHeight(90) self.sub_list.itemDoubleClicked.connect(self._edit_subagent) # re-pick agent @@ -152,10 +275,11 @@ class StepConfigPanel(QScrollArea): sub_btns.addWidget(self.sub_del_btn) sub_btns.addStretch(1) sbtn = QWidget(); sbtn.setLayout(sub_btns) - form.addRow(self.parallel_label, self.sub_list) - form.addRow("", sbtn) + form4.addRow(self.sub_list) + form4.addRow("", sbtn) - # Footer actions — one compact row (Run · Run from here · Delete). + # Footer actions — one compact row (Run · Run from here · Delete), + # kept below every section, not inside one of the cards. self.run_btn = QPushButton(tr("co4e.run")) self.run_btn.setIcon(icon("play")) self.run_btn.setToolTip(tr("co4e.run_this_step")) @@ -170,12 +294,21 @@ class StepConfigPanel(QScrollArea): self.del_btn.setFixedWidth(38) self.del_btn.clicked.connect(lambda: self.delete_node.emit(self._node_id)) foot = QHBoxLayout() - foot.setContentsMargins(0, 0, 0, 0) foot.addWidget(self.run_btn, 1) foot.addWidget(self.run_from_btn, 1) foot.addWidget(self.del_btn) foot_w = QWidget(); foot_w.setLayout(foot) - form.addRow("", foot_w) + outer.addWidget(foot_w) + # Without this, QVBoxLayout hands every child widget an EQUAL share of + # whatever extra height the scroll area's viewport has beyond the + # content's own sizeHint (setWidgetResizable(True) stretches `host` to + # fill it) — each collapsed header's card was measuring a true + # sizeHint of ~17px but rendering over 100px taller, and no amount of + # margin/padding/spacing on the header itself could touch that: the + # surplus was being spent on the cards, not around them. One trailing + # stretch absorbs all of it instead, so every section (and the + # footer) renders at exactly its own natural height. + outer.addStretch(1) self.setEnabled(False) @@ -209,12 +342,11 @@ class StepConfigPanel(QScrollArea): item = QListWidgetItem(_P(path).name) item.setToolTip(path) self.attach_list.addItem(item) - # parallel sub-agents + # parallel sub-agents — the whole "Agent song song" section only + # applies to parallel-variant steps, so the entire card (header + # included) is hidden for any other step, not just its rows. is_par = step.is_parallel - self.parallel_label.setVisible(is_par) - self.sub_list.setVisible(is_par) - self.sub_add_btn.setVisible(is_par) - self.sub_del_btn.setVisible(is_par) + self._parallel_card.setVisible(is_par) self.sub_list.clear() if is_par: for sub in step.sub_agents: diff --git a/ui/co4e_tab.py b/ui/co4e_tab.py index 27ad7ee..b829b89 100644 --- a/ui/co4e_tab.py +++ b/ui/co4e_tab.py @@ -34,6 +34,7 @@ from ..core.co4e_builtins import BUILTIN_AGENTS from ..core.co4e_run_manager import Co4ERunManager from ..core.worker import AgentWorker from ..i18n import on_language_changed, tr +from ..theme import current_palette from .chat_view import ChatView from .co4e_canvas import CO4E_MIME, Co4ECanvas from .co4e_config_panel import StepConfigPanel @@ -260,7 +261,7 @@ class Co4ETab(QWidget): root.addWidget(self._split) sidebar = self._build_sidebar() - sidebar.setMinimumWidth(210) + sidebar.setMinimumWidth(180) # 210 pushed the whole tab past 1214px min self._split.addWidget(sidebar) self._split.addWidget(self._build_center()) self.config = StepConfigPanel(ctx) @@ -272,6 +273,19 @@ class Co4ETab(QWidget): self._config_collapsed = False self._config_expanded_w = 360 self._split.addWidget(self._wrap_config()) + from PySide6.QtCore import QTimer + + from .widgets import narrow_guard + self._narrow_guard = narrow_guard(self, self._NARROW, self._apply_narrow_layout) + # Deferred one tick: the parent chain (and therefore window()) only + # exists after whoever is building this has finished adding it. + QTimer.singleShot(0, self._narrow_guard.attach) + # Start the sidebar wider than its 180px floor — at the floor the + # "Chạy nền" button and the flow names are cut off. + self._split.setSizes([230, 720, 360]) + self._split.setStretchFactor(0, 0) + self._split.setStretchFactor(1, 1) + self._split.setStretchFactor(2, 0) self._split.setStretchFactor(0, 0) self._split.setStretchFactor(1, 1) self._split.setStretchFactor(2, 0) @@ -306,6 +320,11 @@ class Co4ETab(QWidget): self.flow_bar.setCurrentIndex(bar_idx) self._reflect_active_run(wf.id) return + # Without the strip there is nowhere to switch between open flows, so + # opening one REPLACES the one on the canvas (saved first, as the tab + # switch used to do). Runs already in progress are unaffected — they are + # tracked per flow id and keep going in the background. + self._close_other_flows() self._flows.append(wf) self.flow_bar.blockSignals(True) bar_idx = self.flow_bar.addTab(icon("flow"), wf.name or tr("co4e.untitled")) @@ -317,13 +336,43 @@ class Co4ETab(QWidget): self.flow_bar.setCurrentIndex(bar_idx) self._reflect_active_run(wf.id) + def _close_other_flows(self) -> None: + """Leave the canvas empty of flows, saving whatever was on it. + + Called before opening a flow, because the tab strip that used to hold + several at once is gone. Tab 0 (Runs) is never touched. + """ + if not self._flows: + return + if 0 <= self._active_flow_idx < len(self._flows): + self._sync_wf_from_canvas() + self.flow_bar.blockSignals(True) + for idx in range(self.flow_bar.count() - 1, 0, -1): + self.flow_bar.removeTab(idx) + self.flow_bar.blockSignals(False) + self._flows.clear() + self._active_flow_idx = -1 + + def _show_runs(self, on: bool) -> None: + """Swap the centre between the flow editor and the Runs table. + + This is where the pinned "Runs" tab went when the strip was removed — + same page, same table, reached from a toggle in the flow toolbar. + """ + target = 0 if on else min(1, self.flow_bar.count() - 1) + if self.flow_bar.currentIndex() == target: + self._on_flow_tab_changed(target) # already there → re-apply + else: + self.flow_bar.setCurrentIndex(target) + def _on_flow_tab_changed(self, idx: int) -> None: # save the outgoing flow (active_flow_idx is a FLOWS-list index) first if 0 <= self._active_flow_idx < len(self._flows) and (self._active_flow_idx + 1) != idx: self._sync_wf_from_canvas() - if idx <= 0: # the pinned Runs tab + if idx <= 0: # the Runs page self._active_flow_idx = -1 self.center_stack.setCurrentIndex(0) + self._sync_runs_toggle(True) self._refresh_runs() return flow_idx = idx - 1 @@ -331,8 +380,18 @@ class Co4ETab(QWidget): return self._active_flow_idx = flow_idx self.center_stack.setCurrentIndex(1) + self._sync_runs_toggle(False) self._apply_workflow(self._flows[flow_idx]) + def _sync_runs_toggle(self, on: bool) -> None: + """Keep the Runs toggle showing which page is up, however it got there + (a double-click in the runs table also switches pages).""" + btn = getattr(self, "runs_btn", None) + if btn is not None and btn.isChecked() != on: + blocked = btn.blockSignals(True) + btn.setChecked(on) + btn.blockSignals(blocked) + def _add_tab_close_button(self, idx: int) -> None: """Give a flow tab its own close button — a small ✕ placed by QTabBar on the tab's right side, vertically centered and INSIDE the tab (reliable @@ -423,22 +482,47 @@ class Co4ETab(QWidget): # ---- sidebar ---------------------------------------------------------- def _build_sidebar(self) -> QWidget: - self.sidebar = QTabWidget() - # Icon-only tabs share the full sidebar width equally (line up with the - # list below) via an equal-width tab bar — no left/right scroll. Colours - # come from theme.py (QTabBar#co4eSideTabs — transparent tabs + a subtle - # translucent selection with the theme text colour, like the app's lists). - self.sidebar.setTabBar(_EqualTabBar()) - _tb = self.sidebar.tabBar() - _tb.setObjectName("co4eSideTabs") - _tb.setUsesScrollButtons(False) - _tb.setElideMode(Qt.ElideNone) - # Workflows - wf_page = QWidget(); wl = QVBoxLayout(wf_page) - wl.setContentsMargins(6, 6, 6, 6) + # ONE COLUMN, four named sections — no icon tabs. Every list is on screen + # at once, so "what can I drag onto the canvas" is answered by looking + # rather than by clicking through three unlabeled tabs. + # A vertical splitter, not a fixed stack: on a short window four stacked + # lists otherwise squeeze down to one visible row each. The splitter + # hands out the available height by weight and lets the user re-balance + # it by dragging; each list keeps a small minimum so none disappears. + self._sections: dict = {} + self.sidebar = QWidget() + outer_col = QVBoxLayout(self.sidebar) + outer_col.setContentsMargins(6, 6, 6, 6) + outer_col.setSpacing(0) + self.side_split = QSplitter(Qt.Vertical) + self.side_split.setChildrenCollapsible(False) + self.side_split.setHandleWidth(8) + outer_col.addWidget(self.side_split, 1) + + class _Col: + """Adapter so the section builders below read the same as before.""" + + def __init__(self, split): + self._split = split + + def addWidget(self, w, stretch=1): + self._split.addWidget(w) + self._split.setStretchFactor(self._split.count() - 1, stretch) + + col = _Col(self.side_split) + + # --- WORKFLOWS --------------------------------------------------- + self.wf_new_btn = QPushButton(tr("co4e.new")) + self.wf_new_btn.setIcon(icon("plus")) + self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf")) + self.wf_new_btn.setObjectName("co4eSectionAction") + self.wf_new_btn.setFlat(True) + self.wf_new_btn.setCursor(Qt.PointingHandCursor) + self.wf_new_btn.clicked.connect(self._new_workflow) + wf_body = QWidget(); wl = QVBoxLayout(wf_body) + wl.setContentsMargins(0, 0, 0, 0); wl.setSpacing(4) # Draggable: drag a flow onto the canvas to merge it in (Nova-style); - # double-click loads it into an empty canvas. (The old always-on hint - # label was removed to give the flow list more room; it's a tooltip now.) + # double-click loads it onto the canvas. self.wf_list = _PaletteList(payload_role=Qt.UserRole + 2) self.wf_list.setToolTip(tr("co4e.drag_hint")) self.wf_list.itemDoubleClicked.connect(self._load_selected_workflow) @@ -446,57 +530,157 @@ class Co4ETab(QWidget): self.wf_list.customContextMenuRequested.connect(self._wf_context_menu) wl.addWidget(self.wf_list, 1) wf_btns = QHBoxLayout(); wf_btns.setSpacing(4) - # "New flow" moved to the "+" button on the flow tab strip (browser-style). - # "Load selected flow to canvas" button removed — double-click a flow in - # the list (or drag it onto the canvas) to open it; the explicit button - # was redundant. self.wf_edit_btn = self._icon_btn("edit", "co4e.tt_edit_wf", self._edit_selected_workflow) self.wf_dup_btn = self._icon_btn("branch", "co4e.tt_dup_wf", self._duplicate_selected_workflow) self.wf_del_btn = self._icon_btn("trash", "co4e.tt_del_wf", self._delete_selected_workflow) for b in (self.wf_edit_btn, self.wf_dup_btn, self.wf_del_btn): wf_btns.addWidget(b) + wf_btns.addStretch(1) + wl.addLayout(wf_btns) + # Its own row: sharing one line with the three icon buttons cut "Chạy + # nền" down to "Chạ" as soon as the sidebar hit its narrow width. self.wf_runbg_btn = QPushButton(tr("co4e.run_bg")); self.wf_runbg_btn.setIcon(icon("play")) self.wf_runbg_btn.setToolTip(tr("co4e.tt_run_bg")) self.wf_runbg_btn.clicked.connect(self._run_selected_in_background) - wf_btns.addWidget(self.wf_runbg_btn, 1) - wl.addLayout(wf_btns) - # (The "Running flows" list moved out of the sidebar into the pinned - # "Runs" tab at the front of the flow tabs — see _build_runs_page.) - # Icon-only tabs keep the sidebar narrow; the name is a tooltip. - self.sidebar.addTab(wf_page, icon("flow"), "") - self.sidebar.setTabToolTip(0, tr("co4e.tab_workflows")) + wl.addWidget(self.wf_runbg_btn) + col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3) - # Agents (drag onto canvas; CRUD custom) - ag_page = QWidget(); al = QVBoxLayout(ag_page) - al.setContentsMargins(6, 6, 6, 6) + # --- AGENTS ------------------------------------------------------ + self.ag_new_btn = QPushButton(tr("co4e.new")); self.ag_new_btn.setIcon(icon("plus")) + self.ag_new_btn.setToolTip(tr("co4e.tt_new_agent")) + self.ag_new_btn.setObjectName("co4eSectionAction") + self.ag_new_btn.setFlat(True) + self.ag_new_btn.setCursor(Qt.PointingHandCursor) + self.ag_new_btn.clicked.connect(self._new_agent) + ag_body = QWidget(); al = QVBoxLayout(ag_body) + al.setContentsMargins(0, 0, 0, 0); al.setSpacing(4) self.agent_list = _PaletteList() al.addWidget(self.agent_list, 1) ag_btns = QHBoxLayout(); ag_btns.setSpacing(4) - self.ag_new_btn = QPushButton(tr("co4e.new")); self.ag_new_btn.setIcon(icon("plus")) - self.ag_new_btn.setToolTip(tr("co4e.tt_new_agent")) - self.ag_new_btn.clicked.connect(self._new_agent) + # Edit/delete act on the selected row, so they stay with the list. self.ag_edit_btn = self._icon_btn("edit", "co4e.tt_edit_agent", self._edit_agent) self.ag_del_btn = self._icon_btn("trash", "co4e.tt_del_agent", self._delete_agent) - ag_btns.addWidget(self.ag_new_btn, 1) ag_btns.addWidget(self.ag_edit_btn) ag_btns.addWidget(self.ag_del_btn) + ag_btns.addStretch(1) al.addLayout(ag_btns) - self.sidebar.addTab(ag_page, icon("robot"), "") - self.sidebar.setTabToolTip(1, tr("co4e.tab_agents")) + col.addWidget(self._section("co4e.tab_agents", ag_body, self.ag_new_btn), 3) - # Skills (drag onto canvas; manage via existing Skills manager button) - sk_page = QWidget(); sl = QVBoxLayout(sk_page) - sl.setContentsMargins(6, 6, 6, 6) - self.skill_list = _PaletteList() - sl.addWidget(self.skill_list, 1) + # --- SKILLS ------------------------------------------------------ self.sk_manage_btn = QPushButton(tr("co4e.manage_skills")) self.sk_manage_btn.setToolTip(tr("co4e.tt_manage_skills")) + self.sk_manage_btn.setObjectName("co4eSectionAction") + self.sk_manage_btn.setFlat(True) + self.sk_manage_btn.setCursor(Qt.PointingHandCursor) self.sk_manage_btn.clicked.connect(self._manage_skills) - sl.addWidget(self.sk_manage_btn) - self.sidebar.addTab(sk_page, icon("sparkle"), "") - self.sidebar.setTabToolTip(2, tr("co4e.tab_skills")) + sk_body = QWidget(); sl = QVBoxLayout(sk_body) + sl.setContentsMargins(0, 0, 0, 0); sl.setSpacing(4) + self.skill_list = _PaletteList() + sl.addWidget(self.skill_list, 1) + col.addWidget(self._section("co4e.tab_skills", sk_body, self.sk_manage_btn), 2) + + # --- RUNS -------------------------------------------------------- + # A short, always-visible view of the same runs the Flow Status page + # tables in full. Clicking one opens that page with the run selected. + # Icon only: the heading beside it already reads FLOW STATUS, and the + # label was long enough to be cut in half in a narrow sidebar. + self.runs_more_btn = QPushButton() + self.runs_more_btn.setIcon(icon("chevron-right")) + self.runs_more_btn.setFixedWidth(30) + self.runs_more_btn.setFlat(True) + self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab")) + self.runs_more_btn.clicked.connect(lambda: self._show_runs(True)) + runs_body = QWidget(); rl = QVBoxLayout(runs_body) + rl.setContentsMargins(0, 0, 0, 0); rl.setSpacing(4) + self.runs_side_list = QListWidget() + self.runs_side_list.setToolTip(tr("co4e.tt_runs_tab")) + self.runs_side_list.itemClicked.connect(self._on_side_run_clicked) + rl.addWidget(self.runs_side_list, 1) + col.addWidget(self._section("co4e.runs_tab", runs_body, self.runs_more_btn), 2) + # Small enough that all four still fit on a laptop screen, large enough + # that each shows more than a single row. + for lst in (self.wf_list, self.agent_list, self.skill_list, self.runs_side_list): + lst.setMinimumHeight(56) return self.sidebar + _SIDE_RUNS = 6 + + def _refresh_side_runs(self) -> None: + """Mirror the newest runs into the sidebar's short list.""" + lst = getattr(self, "runs_side_list", None) + if lst is None: + return + dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"} + lst.clear() + for h in list(reversed(self.manager.runs()))[:self._SIDE_RUNS]: + it = QListWidgetItem(f"{dots.get(h.status, '•')} {h.name}" + f" {h.progress_text()}") + it.setData(Qt.UserRole, h.id) + it.setToolTip(f"{h.name} · {tr('co4e.status.' + h.status)} · {h.created_at or '-'}") + lst.addItem(it) + + def _on_side_run_clicked(self, item) -> None: + """Open the full Flow Status page with this run selected.""" + run_id = item.data(Qt.UserRole) + self._show_runs(True) + for r in range(self.runs_table.rowCount()): + cell = self.runs_table.item(r, 0) + if cell is not None and cell.data(Qt.UserRole) == run_id: + self.runs_table.setCurrentCell(r, 0) + break + + def _section(self, key: str, body: QWidget, action: QPushButton | None = None, + stretch: int = 1) -> QWidget: + """One named, foldable section of the sidebar column. + + Replaces the three icon-only tabs: all the lists are visible at once + (WORKFLOWS · AGENTS · SKILLS · runs), each under its own heading with the + action that belongs to it. Clicking the heading folds the section, so a + narrow window can still get to everything. + """ + box = QWidget() + v = QVBoxLayout(box) + v.setContentsMargins(0, 0, 0, 0) + v.setSpacing(2) + + row = QHBoxLayout() + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(4) + head = QPushButton() + head.setObjectName("co4eSectionHdr") + head.setCheckable(True) + head.setChecked(True) + head.setCursor(Qt.PointingHandCursor) + head.setFlat(True) + head.toggled.connect(lambda on, w=body, b=box: self._fold_section(key, w, b, on)) + row.addWidget(head, 1) + if action is not None: + row.addWidget(action, 0) + v.addLayout(row) + v.addWidget(body, 1) + + self._sections[key] = (head, body, stretch) + self._sync_section_arrow(key) + return box + + def _fold_section(self, key: str, body: QWidget, box: QWidget, on: bool) -> None: + """Fold/unfold a section AND give its height back to the others. + + Inside a splitter, hiding the body is not enough — the pane keeps its + share of the height, so folding would free nothing. Clamping the whole + section to its header height makes the splitter re-deal the space. + """ + body.setVisible(on) + if on: + box.setMaximumHeight(16777215) + else: + box.setMaximumHeight(box.layout().itemAt(0).sizeHint().height() + 4) + self._sync_section_arrow(key) + + def _sync_section_arrow(self, key: str) -> None: + head, _body, _s = self._sections[key] + head.setText(("▾ " if head.isChecked() else "▸ ") + tr(key).upper()) + def _icon_btn(self, icon_name: str, tip_key: str, slot) -> QPushButton: b = QPushButton(); b.setIcon(icon(icon_name)); b.setToolTip(tr(tip_key)) b.setFixedWidth(34) @@ -564,13 +748,14 @@ class Co4ETab(QWidget): # Tab colours + layout live in theme.py (QTabBar#flowTabs — theme-aware, # flush, centred). Here we only style the per-tab close (✕) button, which # QTabBar places centred on the tab's right (see _add_tab_close_button). + _fp = current_palette() self.flow_bar.setStyleSheet( "QPushButton#flowTabClose {" - " border: none; background: transparent; color: #8FB2D4;" + f" border: none; background: transparent; color: {_fp.text_muted};" " font-size: 13px; font-weight: bold; padding: 0; margin: 0;" - " border-radius: 8px; }" + f" border-radius: {_fp.radius_sm}px; }}" "QPushButton#flowTabClose:hover {" - " background: rgba(229,72,77,0.18); color: #E5484D; }") + f" background: {_fp.danger_soft}; color: {_fp.danger}; }}") runs_idx = self.flow_bar.addTab(icon("monitoring"), tr("co4e.runs_tab")) # 0 = Runs self.flow_bar.setTabButton(runs_idx, QTabBar.RightSide, None) # pinned self.flow_bar.currentChanged.connect(self._on_flow_tab_changed) @@ -606,15 +791,16 @@ class Co4ETab(QWidget): "QScrollArea#flowTabScroll { background: transparent; border: none; }" "QScrollArea#flowTabScroll QScrollBar:horizontal { height: 8px; background: transparent; margin: 0; }" "QScrollArea#flowTabScroll QScrollBar::handle:horizontal {" - " background: rgba(143,178,212,0.45); border-radius: 4px; min-width: 30px; }" + f" background: {current_palette().scroll_handle}; border-radius: 4px; min-width: 30px; }}" "QScrollArea#flowTabScroll QScrollBar::add-line:horizontal," "QScrollArea#flowTabScroll QScrollBar::sub-line:horizontal { width: 0; height: 0; }") - flow_row = QHBoxLayout() - flow_row.setContentsMargins(0, 0, 0, 0) - flow_row.setSpacing(3) # same gap as between tabs → looks like one strip - flow_row.addWidget(self.flow_scroll, 1) - flow_row.addWidget(self.flow_add_btn, 0, Qt.AlignVCenter) - lay.addLayout(flow_row) + # The strip itself is NOT shown any more (see class docstring): flows are + # picked from the WORKFLOWS list on the left, one open at a time. The + # QTabBar stays alive off-screen as the index that maps flow ↔ canvas — + # every open/close/rename path already goes through it — but the user + # never sees or drives it. + self.flow_scroll.setVisible(False) + self.flow_add_btn.setVisible(False) # Content switches between the Runs table (tab 0) and the flow editor. self.center_stack = QStackedWidget() @@ -650,6 +836,14 @@ class Co4ETab(QWidget): self.run_btn.setToolTip(tr("co4e.tt_run")) self.run_btn.clicked.connect(self._on_run_clicked) + # The pinned "Runs" tab lost its strip, so it becomes a toggle here — + # one click to the run table and one click back, from either page. + self.runs_btn = QPushButton(tr("co4e.runs_tab")) + self.runs_btn.setIcon(icon("monitoring")) + self.runs_btn.setCheckable(True) + self.runs_btn.setToolTip(tr("co4e.tt_runs_tab")) + self.runs_btn.toggled.connect(self._show_runs) + bar.addWidget(QLabel(tr("co4e.flow_name"))) bar.addWidget(self.name_edit, 1) bar.addWidget(self.add_step_btn) @@ -657,6 +851,7 @@ class Co4ETab(QWidget): bar.addWidget(self.save_tpl_btn) bar.addWidget(self.mode_combo) bar.addWidget(self.run_btn) + bar.addWidget(self.runs_btn) lay.addLayout(bar) self.canvas = Co4ECanvas() @@ -683,6 +878,13 @@ class Co4ETab(QWidget): w = QWidget() v = QVBoxLayout(w) hdr = QHBoxLayout() + # The Runs page covers the flow toolbar, so it carries its own way back — + # otherwise the toggle that opened it is off screen. + self.runs_back_btn = QPushButton(tr("co4e.back_to_flow")) + self.runs_back_btn.setIcon(icon("chevron-left")) + self.runs_back_btn.setToolTip(tr("co4e.tt_back_to_flow")) + self.runs_back_btn.clicked.connect(lambda: self._show_runs(False)) + hdr.addWidget(self.runs_back_btn) self.runs_title = QLabel(tr("co4e.running_flows")) self.runs_title.setObjectName("hint") hdr.addWidget(self.runs_title) @@ -762,6 +964,29 @@ class Co4ETab(QWidget): self.config_container = container return container + # Below this window width the three panes (rail + 180 sidebar + canvas + + # 300 config) leave the canvas too little to draw a flow in, and the config + # fields start clipping instead of shrinking. Measured with + # tools/check_responsive.py — Co4E gets the full content area (no project or + # history pane beside it), so the threshold is about its own screen only. + _NARROW = 1300 + + def showEvent(self, e): # noqa: N802 - Qt override + super().showEvent(e) + self._narrow_guard.attach() + + def _apply_narrow_layout(self, narrow: bool) -> None: # noqa: D401 + """Fold the step-config panel on a narrow window, restore it when there + is room again. + + Attached from __init__ rather than only on show: this page sits inside a + QTabWidget, whose minimum width is the MAXIMUM over all its pages — + including hidden ones. While Co4E sat unfolded in the background it was + forcing Project and Cowork to be ~1180px wide too. + """ + if narrow != self._config_collapsed: + self._toggle_config() + def _toggle_config(self) -> None: self._config_collapsed = not self._config_collapsed v = self._cfg_vlayout @@ -785,6 +1010,11 @@ class Co4ETab(QWidget): sizes[2] = 34 sizes[1] = max(200, sizes[1] + freed) self._split.setSizes(sizes) + # Without this the splitter keeps reporting the OLD minimum width, + # and since a QTabWidget's minimum is the maximum over all its pages + # — hidden ones included — Co4E would go on forcing Project and + # Cowork to be 1180px wide even while folded here. + self._refresh_min_width() else: v.removeItem(self._cfg_top_spacer) v.removeItem(self._cfg_bot_spacer) @@ -800,6 +1030,14 @@ class Co4ETab(QWidget): sizes[2] = want sizes[1] = max(200, sizes[1] - delta) self._split.setSizes(sizes) + self._refresh_min_width() + + def _refresh_min_width(self) -> None: + """Make the splitter (and everything above it) re-read its minimum.""" + self.config_container.updateGeometry() + self._split.refresh() + self._split.updateGeometry() + self.updateGeometry() def _build_canvas_overlay(self) -> None: """Zoom +/− and Fit as a small floating control at the canvas's @@ -861,7 +1099,8 @@ class Co4ETab(QWidget): # $cost) at the bottom, exactly like Cowork's conversation total. self._usage_total_lbl = QLabel("") self._usage_total_lbl.setObjectName("hint") - self._usage_total_lbl.setStyleSheet("color: rgba(140,146,152,0.9); font-size: 11px;") + self._usage_total_lbl.setStyleSheet( + f"color: {current_palette().text_faint}; font-size: 11px;") crow.addWidget(self._usage_total_lbl) _inp = QWidget(); row = QHBoxLayout(_inp); row.setContentsMargins(0, 0, 0, 0) self.chat_input = _ChatInput() @@ -969,7 +1208,14 @@ class Co4ETab(QWidget): self._refresh_usage_total() # show THIS flow's token/cost total def _new_workflow(self) -> None: - self._open_flow(co4e.new_workflow(tr("co4e.untitled"))) # opens a new tab + self._open_flow(co4e.new_workflow(tr("co4e.untitled"))) + # Pressing this while the canvas already holds an empty untitled flow + # produced an identical empty untitled flow — correct, and completely + # invisible, so the button read as broken. Say what happened and put the + # cursor where the next thing to do is: naming it. + self.name_edit.setFocus() + self.name_edit.selectAll() + self.status_message.emit(tr("co4e.new_flow_ready")) def _selected_wf(self) -> Optional[co4e.Workflow]: """Materialise the selected saved-flow row into a Workflow.""" @@ -1331,8 +1577,9 @@ class Co4ETab(QWidget): # Rebuild the always-fresh Runs table from the manager (single source of truth). if not hasattr(self, "runs_table"): return - color = {"running": "#48CAE4", "done": "#48D9A0", "error": "#E5484D", - "stopped": "#8FB2D4"} + p = current_palette() + color = {"running": p.accent, "done": p.success, "error": p.danger, + "stopped": p.text_muted} dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"} # Most-recent run at the TOP, oldest at the bottom (manager keeps runs in # chronological insertion order, so reverse it for display). @@ -1352,16 +1599,22 @@ class Co4ETab(QWidget): if c == 0: it.setData(Qt.UserRole, h.id) if c == 1: - it.setForeground(_qcolor(color.get(h.status, "#E0F0FF"))) + it.setForeground(_qcolor(color.get(h.status, p.text))) t.setItem(r, c, it) if h.id == sel_id: sel_row = r if sel_row >= 0: t.setCurrentCell(sel_row, 0) - # reflect the active run count in the pinned Runs tab title + # The sidebar's short run list is the same data — refresh it together. + self._refresh_side_runs() + # Active-run count, on the sidebar heading now that the tab strip is gone. + n = self.manager.active_count() + label = tr("co4e.runs_tab_n", n=n) if n else tr("co4e.runs_tab") if hasattr(self, "flow_bar"): - n = self.manager.active_count() - self.flow_bar.setTabText(0, tr("co4e.runs_tab_n", n=n) if n else tr("co4e.runs_tab")) + self.flow_bar.setTabText(0, label) + head = (self._sections.get("co4e.runs_tab") or (None,))[0] + if head is not None: + head.setText(("▾ " if head.isChecked() else "▸ ") + label.upper()) def _stop_selected_run(self) -> None: row = self.runs_table.currentRow() @@ -1502,7 +1755,9 @@ class Co4ETab(QWidget): return self.manager.start(wf, skill_map=self._skill_map(), plan_mode=(self._current_mode() == "plan")) - self.sidebar.setCurrentIndex(0) + # Used to jump the sidebar back to the Workflows tab; with one column + # there is nothing to jump to — show the run that just started instead. + self._refresh_side_runs() self.status_message.emit(tr("co4e.bg_started", name=wf.name)) def _wf_by_id(self, wf_id: str) -> Optional[co4e.Workflow]: @@ -1803,9 +2058,15 @@ class Co4ETab(QWidget): # ---- i18n ------------------------------------------------------------- def _retranslate(self) -> None: - self.sidebar.setTabToolTip(0, tr("co4e.tab_workflows")) - self.sidebar.setTabToolTip(1, tr("co4e.tab_agents")) - self.sidebar.setTabToolTip(2, tr("co4e.tab_skills")) + for key in self._sections: + self._sync_section_arrow(key) + self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab")) + self.wf_new_btn.setText(tr("co4e.new")) + self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf")) + self.runs_btn.setText(tr("co4e.runs_tab")) + self.runs_btn.setToolTip(tr("co4e.tt_runs_tab")) + self.runs_back_btn.setText(tr("co4e.back_to_flow")) + self.runs_back_btn.setToolTip(tr("co4e.tt_back_to_flow")) self.runs_title.setText(tr("co4e.running_flows")) self.run_stop_btn.setText(tr("co4e.stop")) self.run_rename_btn.setText(tr("co4e.rename_run")) diff --git a/ui/composer.py b/ui/composer.py index 205d7f0..0f41822 100644 --- a/ui/composer.py +++ b/ui/composer.py @@ -20,6 +20,7 @@ from PySide6.QtWidgets import ( from ..config import CONFIG_DIR from ..i18n import on_language_changed, tr +from ..theme import current_palette from .icons import icon, IconLabel @@ -451,7 +452,12 @@ class Composer(QWidget): self.stop_btn.setObjectName("danger") self.stop_btn.setVisible(False) self.stop_btn.clicked.connect(self.stop_requested.emit) + # Attach pinned to the input's top edge, Send (and Stop, once a turn + # is running) pinned to its bottom edge — the gap between them is + # absorbed by this stretch instead of splitting evenly above/below + # the whole button column, which is what centering it did before. btns.addWidget(self.attach_btn) + btns.addStretch(1) btns.addWidget(self.send_btn) btns.addWidget(self.stop_btn) row.addLayout(btns) @@ -459,11 +465,18 @@ class Composer(QWidget): # bottom row: left slot (e.g. Cowork's output-folder picker) — stretch — # right slot (e.g. Plan/Act toggle, the per-tab Agent combo on Code/Cowork) + # Its own strip UNDER the typing box, styled as a status line rather + # than a second toolbar: the design asks for the typing area to be just + # input · attach · send, with agent / routing / usage / folder reading + # as status underneath. They stay interactive — only quieter. self._bottom_left_count = 0 - self.extra_row = QHBoxLayout() - self.extra_row.setContentsMargins(0, 0, 0, 0) + self.extra_bar = QWidget() + self.extra_bar.setObjectName("composerStatus") + self.extra_row = QHBoxLayout(self.extra_bar) + self.extra_row.setContentsMargins(2, 2, 2, 0) + self.extra_row.setSpacing(6) self.extra_row.addStretch(1) - root.addLayout(self.extra_row) + root.addWidget(self.extra_bar) on_language_changed(self._retranslate) @@ -583,7 +596,10 @@ class Composer(QWidget): for p in self._attachments: item = QListWidgetItem() row = QWidget() - row.setStyleSheet("background: rgba(140,146,152,0.18); border-radius: 6px;") + _cp = current_palette() + row.setStyleSheet( + f"background: {_cp.surface_raised}; border: 1px solid {_cp.border};" + f" border-radius: {_cp.radius_sm}px;") h = QHBoxLayout(row) h.setContentsMargins(8, 2, 4, 2) h.setSpacing(4) diff --git a/ui/connectors_panel.py b/ui/connectors_panel.py index 1c9fd4d..77f9bd7 100644 --- a/ui/connectors_panel.py +++ b/ui/connectors_panel.py @@ -1,18 +1,20 @@ """Connectors (MCP / REST API) management — the setup UI. -Lives in Monitoring → Tools → "Connector" sub-tab (moved out of Settings). A -tree of the four categories (CAD / CAE / MS365 / Other); each connector has an -Enabled checkbox and can be added / edited / deleted (MCP-stdio or REST-API, -via ExtConnectorEditDialog). Built-in connectors (MS365 OneDrive / SharePoint, -and Jira under "Other") appear as rows in the tree with their checkbox bound to -config; double-clicking one opens its setup — nothing spills outside the tree. +Lives in Monitoring → Tools → "Connector" sub-tab (moved out of Settings). +Grouped by the four real categories (CAD / CAE / MS365 / Other): each is a +header (icon + name + the software it covers) above a left-aligned row of +cards, one per real connector, each with its own on/off switch — user-added +connectors (MCP-stdio or REST-API, via ExtConnectorEditDialog) also get +✎ Sửa/🗑 Xóa; the built-in ones (MS365 OneDrive/SharePoint, Jira under +"Other") only get what they actually support (Jira: ✎ Sửa only, opens its own +setup dialog; OneDrive/SharePoint: neither, they only toggle). """ from __future__ import annotations from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QCheckBox, QDialog, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox, - QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, + QDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, QMessageBox, + QPushButton, QScrollArea, QVBoxLayout, QWidget, ) from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES @@ -21,6 +23,7 @@ from ..i18n import on_language_changed, tr from ..state import AppContext from .ext_connector_dialog import ExtConnectorEditDialog from .icons import icon +from .widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_card class JiraConnectDialog(QDialog): @@ -130,174 +133,219 @@ class ConnectorsPanel(QWidget): # Master switch: connect to external connectors at all (default ON). # Off = the agent connects to NO external connector/MCP (see # AppContext.build_mcp_tools), regardless of the per-connector checks below. - self.connect_external_chk = QCheckBox(tr("connectors.connect_external")) - self.connect_external_chk.setChecked(self.ctx.config.connect_external) - self.connect_external_chk.setToolTip(tr("connectors.connect_external_tooltip")) - self.connect_external_chk.toggled.connect(self._on_connect_external_toggled) - lay.addWidget(self.connect_external_chk) + self.connect_external_sw = ToggleSwitch(tr("connectors.connect_external")) + self.connect_external_sw.setChecked(self.ctx.config.connect_external) + self.connect_external_sw.setToolTip(tr("connectors.connect_external_tooltip")) + self.connect_external_sw.toggled.connect(self._on_connect_external_toggled) + lay.addWidget(self.connect_external_sw) hint = QLabel(tr("settings.ext_hint")) hint.setObjectName("hint") hint.setWordWrap(True) lay.addWidget(hint) - self.ext_tree = QTreeWidget() - self.ext_tree.setHeaderHidden(True) - self.ext_tree.itemChanged.connect(self._on_ext_check) - self.ext_tree.itemDoubleClicked.connect(lambda *_: self._ext_edit()) - lay.addWidget(self.ext_tree, 1) + # Each category (CAD/CAE/MS365/Other) is a header + a left-aligned, + # wrapping row of cards — one per real connector — instead of a tree + # the admin had to expand to see what was inside. + self._cat_scroll = QScrollArea() + self._cat_scroll.setWidgetResizable(True) + self._cat_scroll.setFrameShape(QScrollArea.NoFrame) + cat_host = QWidget() + enable_height_for_width(cat_host) # holds several height-for-width sections — see FlowLayout + self._cat_lay = QVBoxLayout(cat_host) + self._cat_lay.setContentsMargins(0, 4, 0, 4) + self._cat_lay.setSpacing(10) + self._cat_scroll.setWidget(cat_host) + lay.addWidget(self._cat_scroll, 1) - self.dbl_hint = QLabel(tr("connectors.dbl_configure")) - self.dbl_hint.setObjectName("hint") - lay.addWidget(self.dbl_hint) - - row = QHBoxLayout() self.add_btn = QPushButton(tr("settings.ext_add_btn")) self.add_btn.setIcon(icon("plus")) self.add_btn.setObjectName("primary") + self.add_btn.setCursor(Qt.PointingHandCursor) self.add_btn.clicked.connect(self._ext_add) - self.edit_btn = QPushButton(tr("settings.ext_edit_btn")) - self.edit_btn.setIcon(icon("edit")) - self.edit_btn.clicked.connect(self._ext_edit) - self.del_btn = QPushButton(tr("settings.ext_delete_btn")) - self.del_btn.setIcon(icon("trash")) - self.del_btn.clicked.connect(self._ext_delete) - row.addWidget(self.add_btn) - row.addWidget(self.edit_btn) - row.addWidget(self.del_btn) - row.addStretch(1) - lay.addLayout(row) + add_row = QHBoxLayout() + add_row.addWidget(self.add_btn) + add_row.addStretch(1) + lay.addLayout(add_row) self.ms365_local_status = QLabel() self.ms365_local_status.setObjectName("hint") self.ms365_local_status.setWordWrap(True) lay.addWidget(self.ms365_local_status) + # on_language_changed() below already invokes _retranslate() once + # immediately (see i18n.py), which itself calls _reload_connectors() — + # calling it again here would rebuild the category cards twice + # back-to-back with no event-loop turn in between, so the first pass's + # widgets are only QUEUED for deleteLater() (not yet gone) when the + # second pass adds new ones on top: the two rows visually overlap + # (the exact bug agents_admin_tab.py hit the same way). self._refresh_ms365_local_status() - self._reload_ext_tree() on_language_changed(self._retranslate) # ---- rendering ------------------------------------------------------------ - def _reload_ext_tree(self) -> None: - self.ext_tree.blockSignals(True) - self.ext_tree.clear() - ext = self.ctx.config.ext_connectors + def _clear_categories(self) -> None: + while self._cat_lay.count(): + item = self._cat_lay.takeAt(0) + w = item.widget() + if w is not None: + w.deleteLater() + + def _reload_connectors(self) -> None: + self._clear_categories() for cat in EXT_CATEGORIES: - cat_item = QTreeWidgetItem([self._EXT_CATEGORY_LABELS.get(cat, cat)]) - cat_item.setIcon(0, icon(self._EXT_CATEGORY_ICONS.get(cat, "plug"))) - cat_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) - cat_item.setData(0, Qt.UserRole, ("category", cat)) - self.ext_tree.addTopLevelItem(cat_item) - if cat == "ms365": - conns = self.ctx.config.ms365.get("connectors", {}) - for key, label in self._MS365_BUILTIN_LABELS.items(): - b = QTreeWidgetItem([f"{label} — {tr('ext.mode_builtin')}"]) - b.setFlags(b.flags() | Qt.ItemIsUserCheckable) - b.setCheckState(0, Qt.Checked if conns.get(key) else Qt.Unchecked) - b.setData(0, Qt.UserRole, ("ms365_builtin", "ms365", key)) - cat_item.addChild(b) - for idx, entry in enumerate(ext.get(cat, [])): - mode_label = tr("ext.mode_mcp") if entry.get("mode") == "mcp_stdio" else tr("ext.mode_rest") - child = QTreeWidgetItem([f"{entry.get('name', '')} — {mode_label}"]) - child.setFlags(child.flags() | Qt.ItemIsUserCheckable) - child.setCheckState(0, Qt.Checked if entry.get("enabled") else Qt.Unchecked) - child.setData(0, Qt.UserRole, ("connector", cat, idx)) - cat_item.addChild(child) - if cat == "other": - # Jira is a built-in "Other" connector (like OneDrive under MS365): - # checkbox = enabled; double-click opens its minimal setup dialog. - jira = self.ctx.config.data.get("jira", {}) - configured = bool(jira.get("base_url") and jira.get("email") - and jira.get("api_token")) - jstate = tr("connectors.jira_connected") if configured else tr("connectors.jira_not_set") - jrow = QTreeWidgetItem([f"Jira — {tr('ext.mode_builtin')} · {jstate}"]) - jrow.setIcon(0, icon("link")) - jrow.setFlags(jrow.flags() | Qt.ItemIsUserCheckable) - on = configured and jira.get("enabled", True) - jrow.setCheckState(0, Qt.Checked if on else Qt.Unchecked) - jrow.setToolTip(0, tr("connectors.jira_setup_hint")) - jrow.setData(0, Qt.UserRole, ("jira_builtin", "other")) - cat_item.addChild(jrow) - cat_item.setExpanded(True) - self.ext_tree.blockSignals(False) + self._cat_lay.addWidget(self._category_section(cat)) - def _on_ext_check(self, item: QTreeWidgetItem, _col: int) -> None: - data = item.data(0, Qt.UserRole) - if data and data[0] == "ms365_builtin": - self.ctx.config.ms365.setdefault("connectors", {})[data[2]] = ( - item.checkState(0) == Qt.Checked) - self.ctx.save() - return - if data and data[0] == "jira_builtin": - self.ctx.config.data.setdefault("jira", {})["enabled"] = ( - item.checkState(0) == Qt.Checked) - self.ctx.save() - return - if not data or data[0] != "connector": - return - _, cat, idx = data - entries = self.ctx.config.ext_connectors.get(cat, []) - if 0 <= idx < len(entries): - entries[idx]["enabled"] = item.checkState(0) == Qt.Checked - self.ctx.save() + def _category_section(self, cat: str) -> QWidget: + section = QWidget() + enable_height_for_width(section) # this section wraps a FlowLayout row — see FlowLayout + sl = QVBoxLayout(section) + sl.setContentsMargins(0, 0, 0, 0) + sl.setSpacing(6) - def _current_ext_category(self) -> str: - item = self.ext_tree.currentItem() - data = item.data(0, Qt.UserRole) if item else None - return data[1] if data else EXT_CATEGORIES[0] + hdr = QHBoxLayout() + hdr.setSpacing(6) + icon_lbl = QLabel() + icon_lbl.setPixmap(icon(self._EXT_CATEGORY_ICONS.get(cat, "plug"), size=18).pixmap(18, 18)) + hdr.addWidget(icon_lbl) + name, _, subtitle = self._EXT_CATEGORY_LABELS.get(cat, cat).partition(" (") + name_lbl = QLabel(name) + name_lbl.setStyleSheet("font-weight:700;") + hdr.addWidget(name_lbl) + if subtitle: + sub_lbl = QLabel("(" + subtitle) + sub_lbl.setObjectName("hint") + hdr.addWidget(sub_lbl) + hdr.addStretch(1) + sl.addLayout(hdr) - def _current_ext_connector(self): - item = self.ext_tree.currentItem() - data = item.data(0, Qt.UserRole) if item else None - if not data or data[0] != "connector": - return None - _, cat, idx = data - entries = self.ctx.config.ext_connectors.get(cat, []) - return (cat, entries[idx]) if 0 <= idx < len(entries) else None + flow_host = QWidget() + flow = FlowLayout(flow_host, margin=0, h_spacing=10, v_spacing=10) + + if cat == "ms365": + conns = self.ctx.config.ms365.get("connectors", {}) + for key, label in self._MS365_BUILTIN_LABELS.items(): + flow.addWidget(self._connector_card( + label, tr("connectors.builtin_auto"), bool(conns.get(key)), + lambda on, k=key: self._toggle_ms365_builtin(k, on))) + + for entry in self.ctx.config.ext_connectors.get(cat, []): + mode_label = tr("ext.mode_mcp") if entry.get("mode") == "mcp_stdio" else tr("ext.mode_rest") + flow.addWidget(self._connector_card( + entry.get("name", ""), mode_label, bool(entry.get("enabled")), + lambda on, e=entry: self._toggle_ext_entry(e, on), + # QPushButton.clicked emits a `checked` bool — a lambda whose + # ONLY parameter is a defaulted capture (`e=entry`) looks like + # it accepts that bool, so Qt hands it the click state instead + # of using the default, silently replacing the captured dict + # with False. An explicit leading `checked=False` soaks up the + # signal's argument so `e` keeps the entry it was defined with. + edit_cb=lambda checked=False, e=entry: self._edit_ext_entry(cat, e), + delete_cb=lambda checked=False, e=entry: self._delete_ext_entry(cat, e))) + + if cat == "other": + # Jira is a built-in "Other" connector (like OneDrive under MS365): + # switch = enabled; ✎ Sửa opens its own minimal setup dialog — no + # 🗑 Xóa, same as OneDrive/SharePoint have neither (nothing to delete). + jira = self.ctx.config.data.get("jira", {}) + configured = bool(jira.get("base_url") and jira.get("email") and jira.get("api_token")) + jstate = tr("connectors.jira_connected") if configured else tr("connectors.jira_not_set") + flow.addWidget(self._connector_card( + "Jira", f"{tr('ext.mode_builtin').capitalize()} · {jstate}", + configured and jira.get("enabled", True), self._toggle_jira, + edit_cb=self._open_jira_dialog)) + + sl.addWidget(flow_host) + return section + + def _connector_card(self, title: str, subtitle: str, checked: bool, on_toggle, + edit_cb=None, delete_cb=None) -> QWidget: + card = QFrame() + card.setFrameShape(QFrame.NoFrame) + style_card(card) + lay = QVBoxLayout(card) + lay.setContentsMargins(10, 8, 10, 8) + lay.setSpacing(4) + + hdr = QHBoxLayout() + name_lbl = QLabel(title) + name_lbl.setStyleSheet("font-weight:700; border: none;") + hdr.addWidget(name_lbl) + hdr.addStretch(1) + sw = ToggleSwitch() + sw.setChecked(checked) + sw.toggled.connect(on_toggle) + hdr.addWidget(sw) + lay.addLayout(hdr) + + sub_lbl = QLabel(subtitle) + sub_lbl.setObjectName("hint") + sub_lbl.setStyleSheet("border: none;") + lay.addWidget(sub_lbl) + + if edit_cb is not None or delete_cb is not None: + actions = QHBoxLayout() + actions.setContentsMargins(0, 2, 0, 0) + actions.setSpacing(2) + if edit_cb is not None: + b = QPushButton(tr("settings.ext_edit_btn")) + b.setIcon(icon("edit")) + b.setFlat(True) + b.setCursor(Qt.PointingHandCursor) + b.clicked.connect(edit_cb) + actions.addWidget(b) + if delete_cb is not None: + b = QPushButton(tr("settings.ext_delete_btn")) + b.setIcon(icon("trash")) + b.setFlat(True) + b.setCursor(Qt.PointingHandCursor) + b.clicked.connect(delete_cb) + actions.addWidget(b) + actions.addStretch(1) + lay.addLayout(actions) + + return card + + def _toggle_ms365_builtin(self, key: str, checked: bool) -> None: + self.ctx.config.ms365.setdefault("connectors", {})[key] = checked + self.ctx.save() + + def _toggle_jira(self, checked: bool) -> None: + self.ctx.config.data.setdefault("jira", {})["enabled"] = checked + self.ctx.save() + + def _toggle_ext_entry(self, entry: dict, checked: bool) -> None: + entry["enabled"] = checked + self.ctx.save() # ---- CRUD ----------------------------------------------------------------- def _ext_add(self) -> None: - dlg = ExtConnectorEditDialog(self, category=self._current_ext_category()) + dlg = ExtConnectorEditDialog(self, category=EXT_CATEGORIES[0]) if dlg.exec(): entry = dlg.result_connector() self.ctx.config.ext_connectors.setdefault(entry["category"], []).append(entry) self.ctx.save() - self._reload_ext_tree() + self._reload_connectors() - def _ext_edit(self) -> None: - """Configure the selected row. Built-in Jira → its minimal dialog; - a normal connector → the MCP/REST editor.""" - item = self.ext_tree.currentItem() - data = item.data(0, Qt.UserRole) if item else None - if data and data[0] == "jira_builtin": - self._open_jira_dialog() - return - current = self._current_ext_connector() - if current is None: - return - cat, entry = current + def _edit_ext_entry(self, cat: str, entry: dict) -> None: dlg = ExtConnectorEditDialog(self, category=cat, connector=entry) if dlg.exec(): entry.update(dlg.result_connector()) self.ctx.save() - self._reload_ext_tree() + self._reload_connectors() def _open_jira_dialog(self) -> None: JiraConnectDialog(self.ctx, self).exec() - self._reload_ext_tree() + self._reload_connectors() - def _ext_delete(self) -> None: - current = self._current_ext_connector() - if current is None: - return - cat, entry = current + def _delete_ext_entry(self, cat: str, entry: dict) -> None: if QMessageBox.question( self, tr("settings.ext_delete_btn"), tr("settings.ext_delete_confirm", name=entry.get("name", ""))) != QMessageBox.Yes: return self.ctx.config.ext_connectors[cat].remove(entry) self.ctx.save() - self._reload_ext_tree() + self._reload_connectors() def _refresh_ms365_local_status(self) -> None: from .. import paths @@ -314,16 +362,13 @@ class ConnectorsPanel(QWidget): def _apply_connect_external_enabled(self, on: bool) -> None: """Grey out the per-connector setup when the master switch is off — the agent won't connect to any of them anyway.""" - for w in (self.ext_tree, self.add_btn, self.edit_btn, self.del_btn): + for w in (self._cat_scroll, self.add_btn): w.setEnabled(on) def _retranslate(self) -> None: - self.connect_external_chk.setText(tr("connectors.connect_external")) - self.connect_external_chk.setToolTip(tr("connectors.connect_external_tooltip")) + self.connect_external_sw.setText(tr("connectors.connect_external")) + self.connect_external_sw.setToolTip(tr("connectors.connect_external_tooltip")) self.add_btn.setText(tr("settings.ext_add_btn")) - self.edit_btn.setText(tr("settings.ext_edit_btn")) - self.del_btn.setText(tr("settings.ext_delete_btn")) - self.dbl_hint.setText(tr("connectors.dbl_configure")) self._refresh_ms365_local_status() - self._reload_ext_tree() + self._reload_connectors() self._apply_connect_external_enabled(self.ctx.config.connect_external) diff --git a/ui/cowork_tab.py b/ui/cowork_tab.py index 7b516d7..44c620f 100644 --- a/ui/cowork_tab.py +++ b/ui/cowork_tab.py @@ -81,8 +81,21 @@ class CoworkTab(ChatPanel): # succeeds (see _cleanup_turn) — never intermediate files or a folder name. on_language_changed(self._retranslate) + def refresh_title(self) -> None: + """Head the screen with the thread you are in, as the drawing does. + + It said "Cowork" on every conversation — the screen's own name, which + the rail already shows. The thread's title is the thing that changes and + the thing that tells you where you are; a thread with no title yet (a + new chat, before its first turn) falls back to the screen name. + """ + lbl = getattr(self, "_title_lbl", None) + if lbl is None: + return # ChatPanel.__init__ sets self.title before we exist + lbl.setText(getattr(self, "title", "") or tr("cowork.title")) + def _retranslate(self) -> None: - self._title_lbl.setText(tr("cowork.title")) + self.refresh_title() self.skills_btn.setText(tr("cowork.skills_btn")) self.skills_btn.setToolTip(tr("cowork.skills_tooltip")) self._new_btn.setText(tr("cowork.new_chat")) diff --git a/ui/dashboard_tab.py b/ui/dashboard_tab.py index 3c4c17a..8978b8e 100644 --- a/ui/dashboard_tab.py +++ b/ui/dashboard_tab.py @@ -24,6 +24,7 @@ from ..core import usage_tracker as ut from ..core.worker import AgentWorker from ..i18n import on_language_changed, tr from ..state import AppContext +from ..theme import current_palette from .icons import icon from .spline_chart import SplineChart from .widgets import BudgetCard as _BudgetCard @@ -91,39 +92,52 @@ class DashboardTab(QWidget): self.refresh_btn.setIcon(icon("refresh")) self.refresh_btn.setFixedWidth(34) self.refresh_btn.clicked.connect(self.refresh) + # Two rows, grouped by what the controls do, instead of nine widgets + # strung across one line where the title, a date pager, two chart + # selectors, a currency picker and Refresh all read as one undifferentiated + # strip. Row 1 is "where am I"; row 2 is "what am I looking at". head.addWidget(self._title, 1) - head.addWidget(self.chart_prev_btn) - head.addWidget(self._chart_period_lbl) - head.addWidget(self.chart_next_btn) - head.addWidget(self.gran_combo) - head.addWidget(self.metric_combo) - head.addWidget(self.currency_lbl) - head.addWidget(self.currency_combo) head.addWidget(self.refresh_btn) root.addLayout(head) + controls = QHBoxLayout() + controls.setSpacing(6) + controls.addWidget(self.chart_prev_btn) # period pager + controls.addWidget(self._chart_period_lbl) + controls.addWidget(self.chart_next_btn) + controls.addSpacing(12) + controls.addWidget(self.gran_combo) # what the chart plots + controls.addWidget(self.metric_combo) + controls.addStretch(1) + controls.addWidget(self.currency_lbl) # how money is displayed + controls.addWidget(self.currency_combo) + root.addLayout(controls) + # ---- stat cards --------------------------------------------------- - # Single row, 5 equal-width cards (same layout as Monitoring Overview) + # Cost is the headline this screen exists for, so it gets a card twice + # the height of the rest instead of being the fifth of five identical + # tiles — with six equal cards nothing said which number mattered. cards_grid = QGridLayout() cards_grid.setSpacing(8) self.card_total = _StatCard() self.card_in = _StatCard() self.card_out = _StatCard() self.card_cache = _StatCard() - self.card_cost = _StatCard() - for i, card in enumerate((self.card_total, self.card_in, self.card_out, - self.card_cache, self.card_cost)): - cards_grid.addWidget(card, 0, i) + self.card_cost = _StatCard().as_hero() + # Hero on the left, spanning both rows; the four supporting figures fill + # a 2×2 block beside it. + cards_grid.addWidget(self.card_cost, 0, 0, 2, 1) + for i, card in enumerate((self.card_total, self.card_in, + self.card_out, self.card_cache)): + cards_grid.addWidget(card, i // 2, 1 + i % 2) # Budget: remaining/budget, direct entry, auto-warns red past 85% used. self.budget_card = _BudgetCard() self.budget_card.apply_btn.setIcon(icon("check")) self.budget_card.apply_btn.clicked.connect(self._apply_budget) - cards_grid.addWidget(self.budget_card, 0, 5) - # Equal stretch on every column — otherwise the grid sizes each column - # to its widest cell's natural content (Budget's longer "$X / $Y" value - # + entry row made its column ~25% wider than the plain stat cards). - for col in range(6): - cards_grid.setColumnStretch(col, 1) + cards_grid.addWidget(self.budget_card, 0, 3, 2, 1) + # The hero and Budget columns get more room than the small tiles. + for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)): + cards_grid.setColumnStretch(col, stretch) root.addLayout(cards_grid) # ---- token/cost within the selected period (spline): WEEK → 7 days @@ -298,8 +312,11 @@ class DashboardTab(QWidget): n_points = max(1, len(parts)) refs = [] if prev[mi] > 0: + # Muted on purpose: the comparison line is a reference, not the + # series — it must not compete with the accent-coloured spline. refs.append((prev[mi] / n_points, - f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}", "#B08968")) + f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}", + current_palette().text_muted)) self.chart.set_reference_lines(refs) self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}")) self._chart_period_lbl.setText(ut.period_range_label(gran, self._chart_offset)) diff --git a/ui/ext_connector_dialog.py b/ui/ext_connector_dialog.py index 0f45eb2..2244d4a 100644 --- a/ui/ext_connector_dialog.py +++ b/ui/ext_connector_dialog.py @@ -16,9 +16,14 @@ from PySide6.QtWidgets import ( QLineEdit, QMessageBox, QPushButton, QStackedWidget, QVBoxLayout, QWidget, ) -from ..core.ext_connectors import PRESETS +from ..core.ext_connectors import CATEGORIES, PRESETS from ..i18n import tr +# ms365 has no user-created entries here (see ConnectorsPanel) — it auto-connects +# via its own built-in OneDrive/SharePoint toggles, so it's left off this picker. +_PICKABLE_CATEGORIES = tuple(c for c in CATEGORIES if c != "ms365") +_CATEGORY_LABEL = {"cad": "CAD", "cae": "CAE", "other": "Other"} + class ExtConnectorEditDialog(QDialog): def __init__(self, parent=None, category: str = "cad", connector: Optional[dict] = None): @@ -32,10 +37,20 @@ class ExtConnectorEditDialog(QDialog): lay = QVBoxLayout(self) form = QFormLayout() + # Category picker — the single "+ Thêm connector…" button (Monitoring ▸ + # Công cụ ▸ Connector) has no tree selection to infer this from anymore, + # so the dialog itself asks. Fixed once created, same as the preset. + self.category_combo = QComboBox() + for cat in _PICKABLE_CATEGORIES: + self.category_combo.addItem(_CATEGORY_LABEL.get(cat, cat), cat) + idx = self.category_combo.findData(self.category) + self.category_combo.setCurrentIndex(max(0, idx)) + self.category_combo.setEnabled(not editing) + self.category_combo.currentIndexChanged.connect(self._on_category_changed) + form.addRow(tr("ext.category_label"), self.category_combo) + self.preset_combo = QComboBox() - self.preset_combo.addItem(tr("ext.preset_custom"), "") - for p in PRESETS.get(self.category, []): - self.preset_combo.addItem(p["name"], p["id"]) + self._reload_presets() if editing: self.preset_combo.setEnabled(False) # identity fixed once created form.addRow(tr("ext.preset_label"), self.preset_combo) @@ -105,6 +120,18 @@ class ExtConnectorEditDialog(QDialog): buttons.rejected.connect(self.reject) lay.addWidget(buttons) + def _reload_presets(self) -> None: + self.preset_combo.blockSignals(True) + self.preset_combo.clear() + self.preset_combo.addItem(tr("ext.preset_custom"), "") + for p in PRESETS.get(self.category, []): + self.preset_combo.addItem(p["name"], p["id"]) + self.preset_combo.blockSignals(False) + + def _on_category_changed(self) -> None: + self.category = self.category_combo.currentData() or self.category + self._reload_presets() + def _apply_preset(self) -> None: preset_id = self.preset_combo.currentData() if preset_id and not self.name_edit.text().strip(): diff --git a/ui/folder_tab.py b/ui/folder_tab.py index c984d3a..c5aeebe 100644 --- a/ui/folder_tab.py +++ b/ui/folder_tab.py @@ -33,6 +33,7 @@ from PySide6.QtWidgets import ( from ..core.worker import AgentWorker from ..i18n import on_language_changed, tr from ..state import AppContext +from ..theme import current_palette from .chat_view import ChatView from .icons import icon from .libreoffice_view import DOC_SUFFIXES @@ -89,23 +90,26 @@ class PygmentsHighlighter(QSyntaxHighlighter): from pygments.token import ( Comment, Error, Keyword, Name, Number, Operator, Punctuation, String, ) + p = current_palette() # Ordered specific → general: first matching token type wins. + # Colours are resolved when the editor is built, so reopening a file + # after a theme switch re-highlights it in the new theme. return [ - (Comment, _fmt("#6A9955", italic=True)), - (Keyword.Type, _fmt("#4EC9B0")), - (Keyword, _fmt("#569CD6")), - (Name.Function, _fmt("#DCDCAA")), - (Name.Class, _fmt("#4EC9B0")), - (Name.Decorator, _fmt("#DCDCAA")), - (Name.Builtin, _fmt("#4EC9B0")), - (Name.Tag, _fmt("#569CD6")), - (Name.Attribute, _fmt("#9CDCFE")), - (String.Doc, _fmt("#6A9955", italic=True)), - (String, _fmt("#CE9178")), - (Number, _fmt("#B5CEA8")), - (Operator, _fmt("#D4D4D4")), - (Punctuation, _fmt("#D4D4D4")), - (Error, _fmt("#F44747")), + (Comment, _fmt(p.code_comment, italic=True)), + (Keyword.Type, _fmt(p.code_type)), + (Keyword, _fmt(p.code_keyword)), + (Name.Function, _fmt(p.code_func)), + (Name.Class, _fmt(p.code_type)), + (Name.Decorator, _fmt(p.code_func)), + (Name.Builtin, _fmt(p.code_type)), + (Name.Tag, _fmt(p.code_keyword)), + (Name.Attribute, _fmt(p.code_attr)), + (String.Doc, _fmt(p.code_comment, italic=True)), + (String, _fmt(p.code_string)), + (Number, _fmt(p.code_number)), + (Operator, _fmt(p.code_fg)), + (Punctuation, _fmt(p.code_fg)), + (Error, _fmt(p.code_error)), ] def set_filename(self, filename: str, text: str = "") -> None: @@ -179,9 +183,7 @@ class CodeEditor(QPlainTextEdit): font.setStyleHint(QFont.Monospace) font.setPointSize(10) self.setFont(font) - self.setStyleSheet( - "#codeEditor { background: #1e1e1e; color: #d4d4d4; border: none; " - "selection-background-color: #264f78; }") + # Surface comes from the central style sheet (#codeEditor) — see theme.py. self._gutter = _LineNumbers(self) self.blockCountChanged.connect(lambda _=0: self._update_gutter_width()) self.updateRequest.connect(self._on_update_request) @@ -210,13 +212,14 @@ class CodeEditor(QPlainTextEdit): self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.line_number_width(), cr.height())) def paint_line_numbers(self, event) -> None: + p = current_palette() painter = QPainter(self._gutter) - painter.fillRect(event.rect(), QColor("#1a1a1a")) + painter.fillRect(event.rect(), QColor(p.code_gutter_bg)) block = self.firstVisibleBlock() num = block.blockNumber() top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() bottom = top + self.blockBoundingRect(block).height() - painter.setPen(QColor("#858585")) + painter.setPen(QColor(p.code_gutter_fg)) while block.isValid() and top <= event.rect().bottom(): if block.isVisible() and bottom >= event.rect().top(): painter.drawText(0, int(top), self._gutter.width() - 6, @@ -258,14 +261,20 @@ class FolderTab(QWidget): root = QVBoxLayout(self) + # The path IS the title of this screen, so it is written as one rather + # than shown in a read-only text box that looks editable and costs a + # whole row of its own. Full path on hover; the button still opens the + # folder picker. bar = QHBoxLayout() - self.path_edit = QLineEdit(self._root) - self.path_edit.setReadOnly(True) + self.path_lbl = QLabel(self._root) + self.path_lbl.setObjectName("folderTitle") + self.path_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.path_lbl.setToolTip(self._root) self._open_btn = QPushButton() self._open_btn.setIcon(icon("folder")) self._open_btn.setObjectName("primary") self._open_btn.clicked.connect(self._pick_root) - bar.addWidget(self.path_edit, 1) + bar.addWidget(self.path_lbl, 1) bar.addWidget(self._open_btn) root.addLayout(bar) @@ -380,7 +389,8 @@ class FolderTab(QWidget): if not p or not os.path.isdir(p): return self._root = p - self.path_edit.setText(p) + self.path_lbl.setText(p) + self.path_lbl.setToolTip(p) self.model.setRootPath(p) self.tree.setRootIndex(self.model.index(p)) if getattr(self, "terminal", None) is not None: @@ -1096,7 +1106,7 @@ class FolderTab(QWidget): if n and hasattr(self, "_ai_status"): self._ai_status.setText("⏳ " + tr("folder.ai_status_running") + " · " + tr("folder.ai_queue_count", n=n)) - self._ai_status.setStyleSheet("color:#0096C7;") + self._ai_status.setStyleSheet(f"color:{current_palette().accent};") def _ai_maybe_dequeue(self) -> None: """When the pipeline is fully idle, start the next queued instruction.""" @@ -1308,7 +1318,7 @@ class FolderTab(QWidget): name = target if create else getattr(self, "_ai_running_file", "") self.status_message.emit(tr("folder.ai_proposed_status", name=name)) self._ai_status.setText("● " + hint) - self._ai_status.setStyleSheet("color:#c77d00;") + self._ai_status.setStyleSheet(f"color:{current_palette().warning};") def _ai_apply(self) -> None: """Confirmed by the user. If the edit GENERATES images, ask the image @@ -1464,7 +1474,7 @@ class FolderTab(QWidget): self.ai_send_btn.setEnabled(not busy) if busy: self._ai_status.setText("⏳ " + tr("folder.ai_status_running")) - self._ai_status.setStyleSheet("color:#0096C7;") + self._ai_status.setStyleSheet(f"color:{current_palette().accent};") self.ai_btn.setText(tr("folder.ai_edit") + " ⏳") # visible even when collapsed else: self._ai_status.setText("") @@ -1478,7 +1488,7 @@ class FolderTab(QWidget): self._ai_maybe_dequeue() return self._ai_status.setText("✓ " + tr("folder.ai_status_done")) - self._ai_status.setStyleSheet("color:#1f9d63;") + self._ai_status.setStyleSheet(f"color:{current_palette().success};") if not self.ai_btn.isChecked() or self._ai_panel.isHidden(): self.ai_btn.setText(tr("folder.ai_edit") + " ✓") @@ -1489,7 +1499,9 @@ class FolderTab(QWidget): else tr("folder.preview")) def _retranslate(self) -> None: - self.path_edit.setPlaceholderText(tr("folder.path_placeholder")) + # The label always shows a real path, so the placeholder became a + # tooltip hint on the button that changes it. + self._open_btn.setToolTip(tr("folder.path_placeholder")) self._open_btn.setText(tr("folder.open_folder")) self.save_btn.setText(tr("folder.save")) self.ext_btn.setText(tr("folder.open_external")) diff --git a/ui/help_agent_widget.py b/ui/help_agent_widget.py index e1eb11a..29c85a1 100644 --- a/ui/help_agent_widget.py +++ b/ui/help_agent_widget.py @@ -17,9 +17,9 @@ from pathlib import Path from typing import Any, Dict, List, Optional from PySide6.QtCore import QSize, Qt, Signal -from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtGui import QIcon from PySide6.QtWidgets import ( - QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTextBrowser, + QFrame, QHBoxLayout, QLabel, QLineEdit, QMenu, QPushButton, QTextBrowser, QVBoxLayout, QWidget, ) @@ -31,13 +31,31 @@ from .icons import icon _ASSETS = Path(__file__).resolve().parent.parent / "assets" _MARGIN = 18 # gap from the window's bottom-right corner -_LAUNCHER = 64 # collapsed app-icon badge size (a clean rounded card, like image 2) -_LAUNCHER_ICON = 52 # the icon inside it, inset so the light badge frames it -_COLLAPSE_W, _COLLAPSE_H = 18, 44 # the "hide to the edge" chevron beside it -_GAP = 2 -_TAB_W, _TAB_H = 16, 48 # the thin "show" tab when hidden at the edge +# Closed, the assistant is a single 26px dot. It used to be an 84×64 block (a +# 64px badge plus an 18px "hide" chevron beside it) sitting permanently over the +# bottom-right of every screen — on Cowork, right on top of the Send button — +# for something opened a few times a day. The name now appears on hover only, +# and "hide to the edge" moved into the panel's ⋯ menu. +# The audit page draws this at 26px ("26×26 · không chữ, không chevron"). +# Doubled at the user's request: 26 read as too small to notice on a 1920 +# screen. Still half the area of the 84×64 button it replaced. +_DOT = 52 # closed launcher (a round chip) +_DOT_ICON = 28 # the sparkle inside it +_PILL_PAD = 12 # extra width for the label when hovered +_TAB_W, _TAB_H = 28, 48 # the "show" tab when hidden at the edge (was 16 wide) _PANEL_W, _PANEL_H = 340, 460 # expanded chat panel size +# Straight from docs/ui-audit.html (.wf .fab / .fabpill / .spark): the +# assistant is teal, not the app accent, and the same in both themes — +# it is one recognisable object floating over every screen. +_TEAL_BG, _TEAL_LINE = "#E6F6F4", "#7FD0C4" +_TEAL_TEXT = "#0F6E62" +# The page's CSS says .spark{color:#0F9B8A}, but the glyph is the ✨ EMOJI and a +# colour emoji ignores CSS colour — so what the page actually renders is the +# gold star. Sampled from the page's own render of section 27 (1055 pixels of +# the star, averaged): #FDBE59. +_SPARK_GOLD = "#FDBE59" + # The three states the floating assistant cycles through. _HIDDEN, _LAUNCHER_ST, _PANEL = "hidden", "launcher", "panel" @@ -53,27 +71,46 @@ def _app_icon() -> QIcon: return QIcon(str(p)) if p.exists() else icon("robot") -def _app_pixmap(size: int) -> QPixmap: - """icon.png scaled to ``size`` (smooth), for the launcher badge label.""" - p = _ASSETS / "icon.png" - if p.exists(): - return QPixmap(str(p)).scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation) - return icon("robot").pixmap(size, size) +# _app_pixmap()/_IconTap were the 52px icon and its click-through QLabel for the +# old 64px badge. The launcher is a real button now, so both are gone. -class _IconTap(QLabel): - """A QLabel that behaves like a button (click → signal) — used for the - launcher badge so it carries NO QPushButton chrome/box, just the icon on a - clean rounded card.""" +class _HoverPill(QPushButton): + """The closed launcher: a dot at rest, a labelled pill under the pointer. - clicked = Signal() + Keyboard focus counts as hover, so the name is reachable without a mouse. + Resizing is delegated to the owner because this widget is inside an overlay + that has to re-pin itself to the window corner whenever its size changes. + """ - def mousePressEvent(self, e): # noqa: N802 - Qt override - if e.button() == Qt.LeftButton: - self.clicked.emit() - e.accept() + def __init__(self, owner): + super().__init__(owner) + self._owner = owner + self.open = False + + def _set_open(self, value: bool) -> None: + if value == self.open: return - super().mousePressEvent(e) + self.open = value + self.setText(f" {tr('help_agent.badge')}" if value else "") + self._owner._layout_launcher() + + def enterEvent(self, e): # noqa: N802 - Qt override + self._set_open(True) + super().enterEvent(e) + + def leaveEvent(self, e): # noqa: N802 - Qt override + if not self.hasFocus(): + self._set_open(False) + super().leaveEvent(e) + + def focusInEvent(self, e): # noqa: N802 - Qt override + self._set_open(True) + super().focusInEvent(e) + + def focusOutEvent(self, e): # noqa: N802 - Qt override + self._set_open(False) + super().focusOutEvent(e) class HelpAgentWidget(QWidget): @@ -91,9 +128,12 @@ class HelpAgentWidget(QWidget): self._worker: Optional[AgentWorker] = None # Conversation history (excludes the system prompt, prepended per call). # Seeded with the greeting so the panel always opens on a friendly hello. - self._history: List[Dict[str, str]] = [ - {"role": "assistant", "content": self._greeting()} - ] + # Kept by identity so retranslate() can rewrite it without having to + # guess which language the visible text is in — and without touching a + # real reply that happens to look like a greeting. + self._greet_msg: Dict[str, str] = { + "role": "assistant", "content": self._greeting()} + self._history: List[Dict[str, str]] = [self._greet_msg] self.setAttribute(Qt.WA_StyledBackground, True) self._pal = self._compute_palette() self._build_edge_tab() @@ -103,67 +143,63 @@ class HelpAgentWidget(QWidget): self._apply_state() # ---- theming ---------------------------------------------------------- - def _compute_palette(self) -> Dict[str, str]: - """Chat-body colours that FOLLOW the app's light/dark theme. The header - is intentionally NOT themed here (it stays a fixed light bar — see - _apply_style), only the conversation area adapts.""" - from ..theme import resolve_theme - dark = resolve_theme(getattr(self.ctx.config, "theme", "system")) == "dark" - if dark: - return { - "panel_bg": "#16202b", "text": "#e3ebf5", "log_bg": "#0f1720", - "input_bg": "#1b2733", "border": "#33404d", - "user_bg": "#123a52", "user_label": "#58c0ee", - "bot_bg": "#232f3b", "bot_label": "#6fe3a4", - } - return { - "panel_bg": "#ffffff", "text": "#14212b", "log_bg": "#f7f9fb", - "input_bg": "#ffffff", "border": "#d5d9de", - "user_bg": "#dceff8", "user_label": "#0077B6", - "bot_bg": "#eef1f4", "bot_label": "#2f7d55", - } + def _compute_palette(self): + """The app's design tokens for the theme in effect. The whole dock — + header included — follows the app theme; a header locked to a light + strip stranded a bright bar in the middle of the dark UI.""" + from ..theme import palette + return palette(getattr(self.ctx.config, "theme", "system")) def apply_theme(self) -> None: """Re-style + re-render when the app theme switches (called from - MainWindow._apply_theme). Header stays fixed; chat body re-colours.""" + MainWindow._apply_theme). The whole dock re-colours, icons included — + icons are painted bitmaps, so they must be rebuilt, not restyled.""" self._pal = self._compute_palette() self._apply_style() + muted = self._pal.text_muted + self.edge_tab.setIcon(icon("chevron-left", color=_TEAL_TEXT)) + self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_SPARK_GOLD)) + self.min_btn.setIcon(icon("minus", color=muted)) self._render() def _apply_style(self) -> None: - # The HEADER bar is a FIXED light strip in both themes (per request); only - # the chat body below follows the app's light/dark palette (self._pal). - from ..theme import ACCENT, ACCENT2, GRADIENT + """The dock owns its own style sheet (it floats above the window, so the + app-wide sheet does not reach it cleanly) but draws every value from the + shared tokens — see theme.py.""" p = self._pal + r, rl = p.radius, p.radius_lg self.setStyleSheet(f""" - /* Clean rounded app-icon badge (like image 2): a fixed light card - framing the icon — no QPushButton box. */ - #helpLauncher {{ background: #e8f2fb; border: 1px solid #d3e3f2; - border-radius: 16px; }} - #helpLauncher:hover {{ background: #dcedfb; }} - #helpCollapseBtn, #helpEdgeTab {{ background: rgba(0,0,0,0.06); border: none; - border-radius: 6px; }} - #helpCollapseBtn:hover, #helpEdgeTab:hover {{ background: rgba(0,0,0,0.14); }} - #helpPanel {{ background: {p['panel_bg']}; border: 1px solid {p['border']}; - border-radius: 14px; color: {p['text']}; }} - /* Faint-blue header bar — LOCKED light, dark title, in both themes. - The header AND its child labels set fixed backgrounds so the dark - theme never bleeds into the App-Assistant title strip. */ - #helpHeader {{ background: #e8f2fb; border-bottom: 1px solid #d9e6f2; - border-top-left-radius: 14px; border-top-right-radius: 14px; }} - #helpHeader QLabel {{ background: transparent; color: #14212b; }} - #helpTitle {{ color: #14212b; font-weight: 700; font-size: 13px; background: transparent; }} - #helpMinBtn {{ background: transparent; border: none; }} - #helpMinBtn:hover {{ background: rgba(0,0,0,0.10); border-radius: 6px; }} - #helpLog {{ background: {p['log_bg']}; border: none; color: {p['text']}; padding: 4px 6px; }} - #helpInputRow {{ background: {p['panel_bg']}; border-bottom-left-radius: 14px; - border-bottom-right-radius: 14px; }} - #helpInput {{ border: 1px solid {p['border']}; border-radius: 8px; padding: 5px 8px; - background: {p['input_bg']}; color: {p['text']}; }} - #helpInput:focus {{ border: 1px solid {ACCENT}; }} - #helpSendBtn {{ background: {GRADIENT}; border: none; border-radius: 8px; }} - #helpSendBtn:hover {{ background: {ACCENT2}; }} - #helpSendBtn:disabled {{ background: #b7c0c9; }} + /* Closed launcher: a {_DOT}px dot. `pill` flips to true on hover, when + the label comes out and the shape stretches to a rounded bar. */ + #helpLauncher {{ background: {_TEAL_BG}; border: 1px solid {_TEAL_LINE}; + border-radius: {_DOT // 2}px; color: {_TEAL_TEXT}; font-weight: 700; + font-size: 12px; padding: 0; text-align: center; }} + #helpLauncher[pill="true"] {{ text-align: left; padding-left: 6px; }} + #helpLauncher:hover {{ background: #D5EFEA; border-color: {_TEAL_LINE}; }} + #helpLauncher:focus {{ border: 1px solid {_TEAL_TEXT}; }} + #helpEdgeTab {{ background: {_TEAL_BG}; border: 1px solid {_TEAL_LINE}; + border-right: none; border-top-left-radius: {r}px; + border-bottom-left-radius: {r}px; }} + #helpEdgeTab:hover {{ background: #D5EFEA; }} + #helpPanel {{ background: {p.surface}; border: 1px solid {p.border}; + border-radius: {rl}px; color: {p.text}; }} + #helpHeader {{ background: {p.surface}; border-bottom: 1px solid {p.border}; + border-top-left-radius: {rl}px; border-top-right-radius: {rl}px; }} + #helpHeader QLabel {{ background: transparent; color: {p.text}; }} + #helpTitle {{ color: {p.text}; font-weight: 600; font-size: 13px; + background: transparent; }} + #helpMinBtn {{ background: transparent; border: none; border-radius: {r}px; }} + #helpMinBtn:hover {{ background: {p.hover}; }} + #helpLog {{ background: {p.sunken}; border: none; color: {p.text}; + padding: 4px 6px; }} + #helpInputRow {{ background: {p.surface}; + border-bottom-left-radius: {rl}px; border-bottom-right-radius: {rl}px; }} + #helpInput {{ border: 1px solid {p.border}; border-radius: {r}px; padding: 5px 8px; + background: {p.surface_raised}; color: {p.text}; }} + #helpInput:focus {{ border: 1px solid {p.focus_ring}; }} + #helpSendBtn {{ background: {p.accent_solid}; border: none; border-radius: {r}px; }} + #helpSendBtn:hover {{ background: {p.accent_solid_hover}; }} + #helpSendBtn:disabled {{ background: {p.border_strong}; }} """) # ---- greeting / labels ------------------------------------------------ @@ -177,28 +213,21 @@ class HelpAgentWidget(QWidget): # assistant back (chevron points left = "slide out"). self.edge_tab = QPushButton(self) self.edge_tab.setObjectName("helpEdgeTab") - self.edge_tab.setIcon(icon("chevron-left", color="#5a6570")) + self.edge_tab.setIcon(icon("chevron-left", color=_TEAL_TEXT)) self.edge_tab.setCursor(Qt.PointingHandCursor) self.edge_tab.setToolTip(tr("help_agent.show_tooltip")) self.edge_tab.clicked.connect(self._show_launcher) def _build_launcher(self) -> None: - # A left-side chevron collapses the assistant to the edge… - self.collapse_btn = QPushButton(self) - self.collapse_btn.setObjectName("helpCollapseBtn") - self.collapse_btn.setIcon(icon("chevron-right", color="#5a6570")) - self.collapse_btn.setCursor(Qt.PointingHandCursor) - self.collapse_btn.setToolTip(tr("help_agent.hide_tooltip")) - self.collapse_btn.clicked.connect(self._hide_to_edge) - # …and the app icon itself opens the chat — a clean rounded badge (like - # image 2), NOT a QPushButton (which added a pale box around the icon). - self.launcher = _IconTap(self) + # One control, one job: this opens the chat. The chevron that used to sit + # beside it (a second 18px hit target for a second meaning of "closed") + # is gone — hiding to the edge is now a line in the panel's ⋯ menu. + self.launcher = _HoverPill(self) self.launcher.setObjectName("helpLauncher") - self.launcher.setFixedSize(_LAUNCHER, _LAUNCHER) - self.launcher.setAlignment(Qt.AlignCenter) - self.launcher.setPixmap(_app_pixmap(_LAUNCHER_ICON)) + self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_SPARK_GOLD)) self.launcher.setCursor(Qt.PointingHandCursor) - self.launcher.setToolTip(tr("help_agent.open_tooltip")) + self.launcher.setToolTip( + f'{tr("help_agent.open_tooltip")} · {tr("help_agent.dot_hint")}') self.launcher.clicked.connect(self._expand) def _build_panel(self) -> None: @@ -215,19 +244,31 @@ class HelpAgentWidget(QWidget): hb = QHBoxLayout(header) hb.setContentsMargins(12, 8, 8, 8) self.title_icon = QLabel(header) - self.title_icon.setPixmap(_app_icon().pixmap(20, 20)) + self.title_icon.setPixmap( + icon("sparkle", size=16, color=_SPARK_GOLD).pixmap(16, 16)) hb.addWidget(self.title_icon) self.title = QLabel(tr("help_agent.title"), header) self.title.setObjectName("helpTitle") hb.addWidget(self.title, 1) self.min_btn = QPushButton(header) self.min_btn.setObjectName("helpMinBtn") - self.min_btn.setIcon(icon("minus", color="#5a6570")) + self.min_btn.setIcon(icon("minus", color=self._pal.text_muted)) self.min_btn.setFixedSize(24, 24) self.min_btn.setCursor(Qt.PointingHandCursor) self.min_btn.setToolTip(tr("help_agent.collapse_tooltip")) self.min_btn.clicked.connect(self._collapse) hb.addWidget(self.min_btn) + # No ⋯ menu. The audit page put "Ẩn trợ lý" in one, but its two entries + # were "thu nhỏ" — which the − button beside it already does — and + # "ẩn vào cạnh phải". A drop-list to reach one action that duplicates + # its neighbour is chrome; removed at the user's request. + # + # Hiding stays reachable by right-click, on the header while the panel + # is open and on the dot while it is shut, so no route is lost. + for target in (header, self.launcher): + target.setContextMenuPolicy(Qt.CustomContextMenu) + target.customContextMenuRequested.connect( + lambda pos, w=target: self._hide_menu(w, pos)) v.addWidget(header) # Conversation log @@ -268,6 +309,19 @@ class HelpAgentWidget(QWidget): self._state = _LAUNCHER_ST self._apply_state() + def _hide_menu(self, widget, pos) -> None: + """Right-click, on the dot or the open panel's header: hide to the edge. + + The only action worth offering here — collapsing is what the − button + and the dot itself already are. + """ + from PySide6.QtWidgets import QMenu + + menu = QMenu(widget) + act = menu.addAction(tr("help_agent.hide_tooltip")) + act.triggered.connect(self._hide_to_edge) + menu.exec(widget.mapToGlobal(pos)) + def _hide_to_edge(self) -> None: self._state = _HIDDEN self._apply_state() @@ -276,36 +330,57 @@ class HelpAgentWidget(QWidget): self._state = _LAUNCHER_ST self._apply_state() + def _layout_launcher(self) -> None: + """Size the overlay to the dot, or to the pill while it is hovered.""" + w = _DOT + if self.launcher.open: + w = max(_DOT, self.launcher.fontMetrics() + .horizontalAdvance(self.launcher.text()) + _DOT + _PILL_PAD) + self.resize(w, _DOT) + self.launcher.setGeometry(0, 0, w, _DOT) + # Round while it is a dot, pill-shaped once the label is out. + self.launcher.setProperty("pill", bool(self.launcher.open)) + self.launcher.style().unpolish(self.launcher) + self.launcher.style().polish(self.launcher) + self.reposition() + self.raise_() + def _apply_state(self) -> None: st = self._state self.edge_tab.setVisible(st == _HIDDEN) - self.collapse_btn.setVisible(st == _LAUNCHER_ST) self.launcher.setVisible(st == _LAUNCHER_ST) self.panel.setVisible(st == _PANEL) if st == _PANEL: self.resize(_PANEL_W, _PANEL_H) self.panel.setGeometry(0, 0, _PANEL_W, _PANEL_H) elif st == _LAUNCHER_ST: - w = _LAUNCHER + _GAP + _COLLAPSE_W - self.resize(w, _LAUNCHER) - # Icon on the left, the collapse chevron on the RIGHT (toward the - # screen edge it tucks into). - self.launcher.setGeometry(0, 0, _LAUNCHER, _LAUNCHER) - self.collapse_btn.setGeometry(_LAUNCHER + _GAP, (_LAUNCHER - _COLLAPSE_H) // 2, - _COLLAPSE_W, _COLLAPSE_H) + self._layout_launcher() + return # _layout_launcher repositions and raises else: # hidden self.resize(_TAB_W, _TAB_H) self.edge_tab.setGeometry(0, 0, _TAB_W, _TAB_H) self.reposition() self.raise_() + # A screen whose bottom edge is an input row (Cowork's composer) must not + # have the dock sitting on top of it — set by MainWindow when the page + # changes, in window coordinates. + _bottom_guard = 0 + + def set_bottom_guard(self, height: int) -> None: + """Reserve `height` px at the foot of the window for the page's own + controls; the dock floats above it instead of over the Send button.""" + if height != self._bottom_guard: + self._bottom_guard = max(0, height) + self.reposition() + def reposition(self) -> None: """Pin to the parent's bottom-right corner (called on parent resize).""" p = self.parentWidget() if p is None: return x = max(0, p.width() - self.width() - _MARGIN) - y = max(0, p.height() - self.height() - _MARGIN) + y = max(0, p.height() - self.height() - _MARGIN - self._bottom_guard) self.move(x, y) # ---- rendering -------------------------------------------------------- @@ -318,17 +393,19 @@ class HelpAgentWidget(QWidget): p = self._pal text = (content or "").replace("&", "&").replace("<", "<").replace(">", ">") text = text.replace("\n", "
    ") + # bgcolor= is a solid-only HTML attribute, hence accent_wash (pre-blended) + # rather than the translucent accent_soft used in style sheets. if who == "user": - align, bg, label_color = "right", p["user_bg"], p["user_label"] + align, bg, label_color = "right", p.accent_wash, p.accent label = tr("chat.you") else: - align, bg, label_color = "left", p["bot_bg"], p["bot_label"] + align, bg, label_color = "left", p.surface_raised, p.success label = tr("help_agent.title") return ( f'' f'
    ' f'' - f'
    ' + f'' f'{label}
    {text}' f'
    ' '
     
    ' # gap between turns @@ -388,9 +465,18 @@ class HelpAgentWidget(QWidget): self.send_btn.setEnabled(not busy) def retranslate(self) -> None: + # The transcript is rendered HTML, so switching language left the + # greeting — and every "AI Assistant" speaker label — in the language + # the panel was built in. + if self._history and self._history[0] is self._greet_msg: + self._greet_msg["content"] = self._greeting() + self._render() self.title.setText(tr("help_agent.title")) self.input.setPlaceholderText(tr("help_agent.placeholder")) - self.launcher.setToolTip(tr("help_agent.open_tooltip")) + self.launcher.setToolTip( + f'{tr("help_agent.open_tooltip")} · {tr("help_agent.dot_hint")}') + if self.launcher.open: + self.launcher.setText(f" {tr('help_agent.badge')}") + self._layout_launcher() self.min_btn.setToolTip(tr("help_agent.collapse_tooltip")) - self.collapse_btn.setToolTip(tr("help_agent.hide_tooltip")) self.edge_tab.setToolTip(tr("help_agent.show_tooltip")) diff --git a/ui/icons.py b/ui/icons.py index 1307367..a91160c 100644 --- a/ui/icons.py +++ b/ui/icons.py @@ -21,7 +21,12 @@ from PySide6.QtGui import QBrush, QColor, QIcon, QPainter, QPen, QPixmap from PySide6.QtSvg import QSvgRenderer from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QWidget -_COLOR = "#8b8d98" # neutral grey, visible on both light and dark buttons +def _default_color() -> str: + """The default icon tint: the theme's muted text colour, so glyphs sit at + the same weight as the labels beside them. Resolved per call — icons are + painted bitmaps, so a theme switch must repaint them, not restyle them.""" + from ..theme import current_palette + return current_palette().text_muted def _hidpi_pixmap(size: int) -> QPixmap: @@ -230,10 +235,11 @@ def icon_picker_combo(current: str = "") -> QComboBox: return combo -def icon(name: str, size: int = 16, color: str = _COLOR) -> QIcon: +def icon(name: str, size: int = 16, color: str | None = None) -> QIcon: """A flat thin-line icon for ``name`` (see ``_PATHS`` for the full list), tinted ``color`` — rendered from local SVG data, no image files/network. Stroke width 1.7 matches the Nova Platform web app's shared icon set.""" + color = color or _default_color() # A user-added custom icon (full SVG under ~/.cowork_local/icons) is rendered # as-is (keeps its own colours). Then built-in glyphs; then a neutral fallback. if name not in _PATHS: @@ -264,9 +270,10 @@ def icon(name: str, size: int = 16, color: str = _COLOR) -> QIcon: return QIcon(pm) -def _panel_icon(fill_left: bool, size: int = 16, color: str = _COLOR) -> QIcon: +def _panel_icon(fill_left: bool, size: int = 16, color: str | None = None) -> QIcon: """A rounded panel split by a divider, with one narrow side filled solid (the 'sidebar' toggle look).""" + color = color or _default_color() pm = _hidpi_pixmap(size) p = QPainter(pm) p.setRenderHint(QPainter.Antialiasing) @@ -302,19 +309,24 @@ def collapse_right_icon() -> QIcon: return _panel_icon(fill_left=False) -def pixmap(name: str, size: int = 16, color: str = _COLOR) -> QPixmap: +def pixmap(name: str, size: int = 16, color: str | None = None) -> QPixmap: """The line-icon ``name`` as a QPixmap (for QLabel.setPixmap — QLabel has no setIcon). Same glyph/renderer as ``icon()``.""" return icon(name, size, color).pixmap(size, size) -# Status-LED colors — a filled dot, the one place a solid glyph (not a line +# Status-LED colours — a filled dot, the one place a solid glyph (not a line # icon) is the right metaphor for an on/off/running indicator. -DOT_GREEN = "#22c55e" -DOT_RED = "#ef4444" -DOT_AMBER = "#f59e0b" -DOT_BLUE = "#3b82f6" -DOT_GREY = "#9ca3af" +# +# Deliberately the SAME in light and dark. An LED means one thing regardless of +# theme, and these mid-saturation hues clear 3:1 against both #0B0B0C and +# #FFFFFF, so a status dot never has to be re-learned. Everything else in the +# UI goes through theme.palette(); this is the documented exception. +DOT_GREEN = "#2EA043" +DOT_RED = "#E5484D" +DOT_AMBER = "#B7791F" +DOT_BLUE = "#4C7BE8" +DOT_GREY = "#8B8B94" def dot_icon(color: str = DOT_GREY, size: int = 12) -> QIcon: @@ -338,7 +350,7 @@ class IconLabel(QWidget): status labels (lock/unlock, …) keep working.""" def __init__(self, name: str, text: str = "", *, size: int = 16, - color: str = _COLOR, gap: int = 6, parent=None): + color: str | None = None, gap: int = 6, parent=None): super().__init__(parent) self._size = size lay = QHBoxLayout(self) @@ -357,7 +369,7 @@ class IconLabel(QWidget): def setText(self, text: str) -> None: # noqa: N802 — QLabel-compatible alias self._text.setText(text) - def set_icon(self, name: str, color: str = _COLOR) -> None: + def set_icon(self, name: str, color: str | None = None) -> None: self._icon.setPixmap(pixmap(name, self._size, color)) def text_label(self) -> QLabel: diff --git a/ui/icons_admin_tab.py b/ui/icons_admin_tab.py index 6ba27d2..6e43a2d 100644 --- a/ui/icons_admin_tab.py +++ b/ui/icons_admin_tab.py @@ -22,6 +22,7 @@ from .icons import icon def _grid() -> QListWidget: g = QListWidget() + g.setObjectName("iconGrid") # accent border on hover/selection, see theme.py g.setViewMode(QListWidget.IconMode) g.setResizeMode(QListWidget.Adjust) g.setMovement(QListWidget.Static) @@ -36,34 +37,44 @@ class IconsAdminTab(QWidget): super().__init__() self.ctx = ctx root = QVBoxLayout(self) - self._hint = QLabel(); self._hint.setObjectName("hint"); self._hint.setWordWrap(True) - root.addWidget(self._hint) - - # search over built-in names - self.search = QLineEdit() - self.search.textChanged.connect(self._reload_builtin) - root.addWidget(self.search) - - self._builtin_lbl = QLabel() - root.addWidget(self._builtin_lbl) - self.builtin_grid = _grid() - root.addWidget(self.builtin_grid, 2) - - self._custom_lbl = QLabel() - root.addWidget(self._custom_lbl) - self.custom_grid = _grid() - root.addWidget(self.custom_grid, 1) - - btns = QHBoxLayout() + # Header row: the three actions sit beside the title, where the drawing + # puts them, instead of in a strip below the two grids where they read + # as belonging to the custom grid alone. + head = QHBoxLayout() + self._title = QLabel() + self._title.setObjectName("monTitle") + head.addWidget(self._title) + head.addStretch(1) self.add_btn = QPushButton(); self.add_btn.setIcon(icon("plus")) self.add_btn.clicked.connect(self._add_icon) self.paste_btn = QPushButton() self.paste_btn.clicked.connect(self._add_from_svg_text) self.del_btn = QPushButton(); self.del_btn.setIcon(icon("trash")) self.del_btn.clicked.connect(self._delete_icon) - btns.addWidget(self.add_btn); btns.addWidget(self.paste_btn) - btns.addWidget(self.del_btn); btns.addStretch(1) - root.addLayout(btns) + for b in (self.add_btn, self.paste_btn, self.del_btn): + head.addWidget(b) + root.addLayout(head) + + self._hint = QLabel(); self._hint.setObjectName("hint"); self._hint.setWordWrap(True) + root.addWidget(self._hint) + + # search over built-in names, with the magnifier the drawing asks for + self.search = QLineEdit() + self.search.addAction(icon("search"), QLineEdit.LeadingPosition) + self.search.textChanged.connect(self._reload_builtin) + root.addWidget(self.search) + + self._builtin_lbl = QLabel() + self._builtin_lbl.setObjectName("navSectionHdr") # quiet caps heading + root.addWidget(self._builtin_lbl) + self.builtin_grid = _grid() + root.addWidget(self.builtin_grid, 2) + + self._custom_lbl = QLabel() + self._custom_lbl.setObjectName("navSectionHdr") + root.addWidget(self._custom_lbl) + self.custom_grid = _grid() + root.addWidget(self.custom_grid, 1) on_language_changed(self._retranslate) self._retranslate() @@ -131,10 +142,13 @@ class IconsAdminTab(QWidget): self._reload_custom() def _retranslate(self) -> None: + self._title.setText(tr("monitoring.tab_icons")) self._hint.setText(tr("icons_admin.hint")) self.search.setPlaceholderText(tr("icons_admin.search")) - self._builtin_lbl.setText(tr("icons_admin.builtin")) - self._custom_lbl.setText(tr("icons_admin.custom")) + # ICON TÍCH HỢP / ICON TÙY CHỈNH — caps, like every other section + # heading the audit page draws. + self._builtin_lbl.setText(tr("icons_admin.builtin").upper()) + self._custom_lbl.setText(tr("icons_admin.custom").upper()) self.add_btn.setText(tr("icons_admin.add")) self.paste_btn.setText(tr("icons_admin.paste")) self.del_btn.setText(tr("icons_admin.delete")) diff --git a/ui/monitoring_tab.py b/ui/monitoring_tab.py index 699ce12..ddc30c7 100644 --- a/ui/monitoring_tab.py +++ b/ui/monitoring_tab.py @@ -24,20 +24,24 @@ import time from datetime import datetime from typing import List -from PySide6.QtCore import Qt, QTimer, Signal -from PySide6.QtGui import QBrush, QColor +from PySide6.QtCore import Qt, QEvent, QObject, QRect, QSize, QTimer, Signal +from PySide6.QtGui import ( + QBrush, QColor, QFont, QGuiApplication, QIcon, QKeySequence, QPainter, + QPixmap, QShortcut, +) from PySide6.QtWidgets import ( - QComboBox, QGridLayout, QGroupBox, QHBoxLayout, + QApplication, QComboBox, QGridLayout, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QProgressBar, QPushButton, QScrollArea, - QTableWidget, QTableWidgetItem, QTabWidget, QVBoxLayout, QWidget, + QSplitter, QTableWidget, QTableWidgetItem, QTabWidget, QVBoxLayout, QWidget, ) from ..core import agent_roles, audit_log from ..core import usage_tracker as ut from ..i18n import on_language_changed, tr from ..state import AppContext +from ..theme import current_palette from .icons import icon, DOT_GREEN, DOT_RED, DOT_AMBER -from .widgets import BudgetCard, StatCard, fmt_tokens +from .widgets import BudgetCard, StatCard, badge_pill_widget, fmt_tokens _REFRESH_MS = 3000 _MAX_ROWS = 300 @@ -51,6 +55,59 @@ def _fmt_bytes(n: float) -> str: return f"{n:.1f} TB" +def _fmt_event_time(ts: str) -> str: + """"dd/MM hh:mm" for the Time column — e.g. 25/05 15:03.""" + try: + dt = datetime.fromisoformat(ts) + except (TypeError, ValueError): + return ts + return dt.strftime("%d/%m %H:%M") + + +def _agent_initials(name: str) -> str: + """First letter of each word, max 2 — ``ini()`` in ui-audit_v2.html.""" + return "".join(w[0] for w in name.split() if w)[:2].upper() + + +def _agent_avatar_colour(name: str) -> str: + """Same mapping as ``ac()`` in ui-audit_v2.html — a fixed identity colour + per agent kind, unchanged by theme (like the mockup's badge colours).""" + if "Security" in name: + return "#D13438" + if "Cowork" in name: + return "#0078D4" + if name == "schedule" or "Task" in name: + return "#FFB900" + if name == "graphrag" or "Knowledge" in name: + return "#8764B8" + if "Code" in name: + return "#107C10" + if "Planner" in name or "Reasoning" in name: + return "#8A8886" + return "#0078D4" + + +def _agent_avatar_icon(name: str, size: int = 20) -> QIcon: + """A small round initials badge for the Agent column — 1:1 with the + ``.wf .av`` avatar in ui-audit_v2.html (colour-coded circle + up to + 2-letter initials, drawn to the left of the agent's name).""" + pm = QPixmap(size, size) + pm.fill(Qt.transparent) + p = QPainter(pm) + p.setRenderHint(QPainter.Antialiasing) + p.setPen(Qt.NoPen) + p.setBrush(QColor(_agent_avatar_colour(name))) + p.drawEllipse(0, 0, size, size) + font = QFont() + font.setPixelSize(max(7, size // 2)) + font.setBold(True) + p.setFont(font) + p.setPen(QColor("#FFFFFF")) + p.drawText(pm.rect(), Qt.AlignCenter, _agent_initials(name)) + p.end() + return QIcon(pm) + + def _relative_time(ts: str) -> str: """A short "Xm ago"-style string for an audit-log ``ts`` (naive local ISO timestamp, see ``audit_log.record``); "" if unparsable.""" @@ -68,29 +125,73 @@ def _relative_time(ts: str) -> str: return tr("monitoring.time_days_ago", n=int(delta // 86400)) +class _TimeItem(QTableWidgetItem): + """The Time column shows "dd/MM hh:mm", which does not sort correctly as + text (day-of-month leads, not year/month) — so sorting compares the raw + ISO ``ts`` each item is built from instead of its displayed text.""" + + def __init__(self, raw_ts: str, display: str): + super().__init__(display) + self._raw_ts = raw_ts + + def __lt__(self, other): + if isinstance(other, _TimeItem): + return self._raw_ts < other._raw_ts + return super().__lt__(other) + + class _EventTable(QTableWidget): """A read-only table of audit-log events — newest-first by default, and every column header is click-to-sort (ascending/descending toggle; the - Time column's ISO timestamps sort correctly as text).""" + Time column sorts by the underlying ISO timestamp, not its "dd/MM hh:mm" + display text — see :class:`_TimeItem`).""" - def __init__(self): - super().__init__(0, 7) + # What each blocked action is, as a colour. Security events all record + # ok=False, so the tick/cross column said the same thing on every row; the + # useful distinction is WHICH rule fired. + _ACTION_TINTS = { + "prompt": "accent", + "dangerous_command": "danger", + "run_command": "danger", + "install_package": "warning", + "path_outside_sandbox": "success", + "network_blocked": "accent", + "secret_in_output": "warning", + } + + def __init__(self, show_result: bool = True): + # Security Events drops the result column entirely (see _ACTION_TINTS). + self._show_result = show_result + super().__init__(0, 7 if show_result else 6) self.setEditTriggers(QTableWidget.NoEditTriggers) self.setSelectionBehavior(QTableWidget.SelectRows) + self.setIconSize(QSize(20, 20)) # ~1.25x the mockup's 16x16 avatar badge self.verticalHeader().setVisible(False) + # Fixed row height — letting Qt auto-size rows from content fought with + # the Hành động column's cell widget (its layout would settle on a + # stale, oversized geometry from an intermediate sizing pass, clipping + # the pill's text). A fixed height sidesteps that entirely. + self.verticalHeader().setSectionResizeMode(QHeaderView.Fixed) + self.verticalHeader().setDefaultSectionSize(32) self.setSortingEnabled(True) header = self.horizontalHeader() header.setStretchLastSection(True) - for col in range(6): + for col in range(self.columnCount() - 1): header.setSectionResizeMode(col, QHeaderView.ResizeToContents) def retranslate(self) -> None: - self.setHorizontalHeaderLabels([ - tr("monitoring.col_time"), tr("monitoring.col_role"), - tr("monitoring.col_account"), tr("monitoring.col_machine"), - tr("monitoring.col_name"), tr("monitoring.col_result"), - tr("monitoring.col_detail"), - ]) + # Security Events (show_result=False) is the ui-audit_v2.html + # wireframe's table 1:1 — "Agent" and "Chi tiết chặn", not the + # longer generic wording MCP/Action Logs share. + cols = [tr("monitoring.col_time"), + tr("monitoring.col_agent") if not self._show_result else tr("monitoring.col_role"), + tr("monitoring.col_account"), tr("monitoring.col_machine")] + if self._show_result: + cols += [tr("monitoring.col_name"), tr("monitoring.col_result")] + else: + cols += [tr("monitoring.col_action")] + cols += [tr("monitoring.col_detail_block") if not self._show_result else tr("monitoring.col_detail")] + self.setHorizontalHeaderLabels(cols) def set_events(self, events: List[dict]) -> None: events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:_MAX_ROWS] @@ -101,14 +202,47 @@ class _EventTable(QTableWidget): cells = [ ev.get("ts", ""), agent_roles.label_for(ev.get("agent_role", "")), ev.get("account", "") or "—", ev.get("machine", "") or "—", - ev.get("name", ""), "", - (ev.get("detail") or "")[:300], + ev.get("name", ""), ] + if self._show_result: + cells.append("") + cells.append((ev.get("detail") or "")[:300]) + pal = current_palette() for col, text in enumerate(cells): - item = QTableWidgetItem(str(text)) - if col == 5: # Result — green check / red close icon (no emoji) + item = (_TimeItem(str(text), _fmt_event_time(str(text))) if col == 0 + else QTableWidgetItem(str(text))) + if col == 0: + # Stash the full event (untruncated detail included) on the + # Time cell, so a click-to-open detail panel survives the + # user re-sorting the table by any column. + item.setData(Qt.UserRole, ev) + if col == 1: + # Agent — colour-coded initials avatar (see ui-audit_v2.html). + item.setIcon(_agent_avatar_icon(str(text))) + if self._show_result and col == 5: + # Result — green check / red close icon (no emoji) item.setIcon(icon("check", color=DOT_GREEN) if ev.get("ok") else icon("close", color=DOT_RED)) + if not self._show_result and col == 4: + # Human-readable label ("Path ngoài sandbox"), not the raw + # audit_log name ("path_outside_sandbox") — same wording as + # the detail panel's Loại field (_action_label). Tinted by + # which rule fired, via the ITEM's own colours — NOT a + # setCellWidget() pill: a cell widget is pinned to a (row, + # column) screen position, not to the item that travels + # with a sort, so the table's OWN re-sort (every refresh() + # re-applies the active sort indicator, and a user click on + # any column header does too) left a stale pill floating + # over whatever row ended up at that position instead — + # the "wrong colour/text peeking out" glitch. + tint = getattr(pal, self._ACTION_TINTS.get( + ev.get("name", ""), "text_muted"), pal.text_muted) + item.setText(_action_label(str(text))) + colour = QColor(tint) + item.setForeground(QBrush(colour)) + soft = QColor(colour) + soft.setAlpha(38) + item.setBackground(QBrush(soft)) if is_admin_violation: item.setBackground(QBrush(QColor(229, 72, 77, 60))) self.setItem(row, col, item) @@ -127,6 +261,322 @@ class _EventTable(QTableWidget): for col in range(self.columnCount())) self.setRowHidden(row, not match) + def event_at_row(self, row: int) -> dict | None: + item = self.item(row, 0) + return item.data(Qt.UserRole) if item else None + + +def _fmt_event_time_full(ts: str) -> str: + """"dd/MM/yyyy · HH:mm:ss" — the detail panel's Thời gian field, per + ``fmtFull()`` in ui-audit_v2.html (the table's own Time column uses the + shorter ``_fmt_event_time`` instead).""" + try: + dt = datetime.fromisoformat(ts) + except (TypeError, ValueError): + return ts + return dt.strftime("%d/%m/%Y · %H:%M:%S") + + +def _event_id(ts: str, row: int) -> str: + """A display-only id in the ``evt__`` shape the + mockup uses — the real audit log has no native event id, so this is + derived from the timestamp and the row's position in the currently + displayed (sorted) table, not persisted anywhere.""" + digits = "".join(ch for ch in ts if ch.isdigit())[:12] + return f"evt_{digits}_{row:03d}" + + +# Human label for the raw ``name`` an audit event is recorded under — the +# "Loại" field in the detail panel. Anything not in this map (custom tool +# names, etc.) just shows its raw name, same as the table's Hành động column. +_ACTION_LABEL_KEYS = { + "prompt": "monitoring.action_prompt", + "dangerous_command": "monitoring.action_dangerous_command", + "run_command": "monitoring.action_dangerous_command", + "install_package": "monitoring.action_install_package", + "path_outside_sandbox": "monitoring.action_path_outside_sandbox", + "network_blocked": "monitoring.action_network_blocked", + "secret_in_output": "monitoring.action_secret_in_output", +} + + +def _action_label(name: str) -> str: + key = _ACTION_LABEL_KEYS.get(name) + return tr(key) if key else name + + +# (badge QSS object name, i18n key) for the "Trạng thái" pill — statusInfo() +# in ui-audit_v2.html, mapped onto the app's existing badge* tones (theme.py) +# rather than adding the mockup's one-off teal/orange hues. +_STATUS_INFO = { + "path_outside_sandbox": ("badgeSuccess", "monitoring.status_path"), + "network_blocked": ("badge", "monitoring.status_network"), + "secret_in_output": ("badgeWarn", "monitoring.status_secret"), +} +_STATUS_DEFAULT = ("badgePurple", "monitoring.status_blocked") + + +def _status_info(name: str, kind: str = "security_block", ok: bool = False) -> tuple[str, str]: + if name in _STATUS_INFO: + return _STATUS_INFO[name] + if kind == "security_block": + # Security Events rows are always ok=False (see _EventTable's + # _ACTION_TINTS comment) — an unmapped name here still means "blocked + # by some rule", never a plain failure. + return _STATUS_DEFAULT + # MCP calls / generic Action Logs rows: no fixed enforcement-rule + # vocabulary applies, so fall back to the event's own ok/fail outcome. + return ("badgeSuccess", "monitoring.status_ok") if ok else ("badgeDanger", "monitoring.status_failed") + + +def _severity_info(name: str, kind: str = "security_block", ok: bool = False) -> tuple[str, str]: + # Same two-tier read as ui-audit_v2.html's mock: an unapproved shell + # command is the one CRITICAL case; everything else blocked is MEDIUM. + if name in ("dangerous_command", "run_command"): + return "badgeDanger", "monitoring.severity_critical" + if kind == "security_block": + return "badgeWarn", "monitoring.severity_medium" + # A successful MCP call / action is routine (INFO); a failed one still + # deserves the same MEDIUM tone Security Events uses for a blocked rule. + return ("badge", "monitoring.severity_info") if ok else ("badgeWarn", "monitoring.severity_medium") + + +def _agent_badge_name(name: str) -> str: + """Badge tone for the Agent field's pill — the same identity-colour + mapping ``_agent_avatar_colour``/``ac()`` (ui-audit_v2.html) uses, + expressed as one of the shared badge* QSS classes (theme.py) instead of a + literal hex, since this pill lives on a themed label, not a custom swatch.""" + if "Security" in name: + return "badgeDanger" + if "Cowork" in name: + return "badge" + if name == "schedule": + return "badgeWarn" + if name == "graphrag": + return "badgePurple" + if "Code" in name: + return "badgeSuccess" + return "badge" + + +_STATIC_POLICY_LABEL = "security_policy_v2" # cosmetic label only — no real policy-versioning system exists yet + + +class _EventDetailPanel(QWidget): + """Right-hand "Chi tiết sự kiện" panel — the full record behind whichever + row is selected in an :class:`_EventTable`, laid out to match the + "Đề xuất" detail panel in ui-audit_v2.html (openDetail()): three labelled + sections, a terminal-style block quote for the detail text, and a + METADATA footer, closed by the header ✕, the footer button, Esc, or a + click outside the table/panel (see :class:`_ClickOutsideCloser`).""" + + closed = Signal() + + def __init__(self): + super().__init__() + self.setObjectName("monSection") + self._detail_text = "" + outer = QVBoxLayout(self) + + hdr = QHBoxLayout() + self._title_lbl = QLabel() + self._title_lbl.setStyleSheet("font-weight:700;") + hdr.addWidget(self._title_lbl, 1) + self._close_btn = QPushButton() + self._close_btn.setIcon(icon("close")) + self._close_btn.setFlat(True) + self._close_btn.setFixedWidth(28) + self._close_btn.setCursor(Qt.PointingHandCursor) + self._close_btn.clicked.connect(self.closed.emit) + hdr.addWidget(self._close_btn) + outer.addLayout(hdr) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.NoFrame) + body = QWidget() + self._body_lay = QVBoxLayout(body) + self._body_lay.setContentsMargins(0, 0, 4, 0) + scroll.setWidget(body) + outer.addWidget(scroll, 1) + + self._section_hdrs: list[tuple[str, QLabel]] = [] + self._rows: dict[str, tuple[QLabel, QLabel]] = {} + + def _section(key: str) -> None: + lbl = QLabel() + lbl.setObjectName("detailSectionHdr") + self._body_lay.addWidget(lbl) + self._section_hdrs.append((key, lbl)) + + def _field(key: str) -> QLabel: + r = QHBoxLayout() + lbl = QLabel() + lbl.setObjectName("hint") + val = QLabel() + r.addWidget(lbl) + r.addStretch(1) + r.addWidget(val) + self._body_lay.addLayout(r) + self._rows[key] = (lbl, val) + return val + + _section("general") + _field("time") + self._agent_val = _field("agent") + _field("account") + self._machine_val = _field("machine") + self._machine_val.setObjectName("monoChip") + + _section("action") + self._type_val = _field("type") + self._type_val.setObjectName("neutralTag") + self._status_val = _field("status") + + _section("block") + code_box = QWidget() + code_box.setObjectName("detailCodeBlock") + code_lay = QHBoxLayout(code_box) + code_lay.setContentsMargins(8, 6, 8, 6) + self._code_text = QLabel() + self._code_text.setObjectName("detailCodeText") + self._code_text.setWordWrap(True) + self._code_text.setTextInteractionFlags(Qt.TextSelectableByMouse) + code_lay.addWidget(self._code_text, 1) + self._copy_btn = QPushButton() + self._copy_btn.setObjectName("detailCopyBtn") + self._copy_btn.setCursor(Qt.PointingHandCursor) + self._copy_btn.clicked.connect(self._copy_detail) + code_lay.addWidget(self._copy_btn, 0, Qt.AlignVCenter) + self._body_lay.addWidget(code_box) + + _section("metadata") + self._event_id_val = _field("event_id") + self._event_id_val.setObjectName("monoChip") + self._policy_val = _field("policy") + self._severity_val = _field("severity") + + self._body_lay.addStretch(1) + + footer = QHBoxLayout() + footer.setContentsMargins(0, 6, 0, 0) + self._footer_close_btn = QPushButton() + self._footer_close_btn.setObjectName("primary") + self._footer_close_btn.setCursor(Qt.PointingHandCursor) + self._footer_close_btn.clicked.connect(self.closed.emit) + footer.addWidget(self._footer_close_btn, 1) + outer.addLayout(footer) + + @staticmethod + def _apply_badge(label: QLabel, object_name: str) -> None: + label.setObjectName(object_name) + label.style().unpolish(label) + label.style().polish(label) + + def retranslate(self) -> None: + self._title_lbl.setText(tr("monitoring.security_detail_title")) + self._close_btn.setToolTip(tr("monitoring.security_detail_close")) + self._footer_close_btn.setText(tr("monitoring.security_detail_close")) + self._footer_close_btn.setIcon(icon("close")) + section_keys = { + "general": "monitoring.detail_section_general", + "action": "monitoring.detail_section_action", + "block": "monitoring.col_detail_block", + "metadata": "monitoring.detail_section_metadata", + } + for key, lbl in self._section_hdrs: + lbl.setText(tr(section_keys[key]).upper()) + self._rows["time"][0].setText(tr("monitoring.col_time")) + self._rows["agent"][0].setText(tr("monitoring.col_agent")) + self._rows["account"][0].setText(tr("monitoring.col_account")) + self._rows["machine"][0].setText(tr("monitoring.col_machine")) + self._rows["type"][0].setText(tr("monitoring.detail_type")) + self._rows["status"][0].setText(tr("monitoring.detail_status")) + self._rows["event_id"][0].setText(tr("monitoring.detail_event_id")) + self._rows["policy"][0].setText(tr("monitoring.detail_policy")) + self._rows["severity"][0].setText(tr("monitoring.detail_severity")) + if not self._copy_btn.text() or self._copy_btn.text() != tr("monitoring.detail_copied"): + self._reset_copy_btn() + + def show_event(self, ev: dict, row: int) -> None: + na = "—" + self._rows["time"][1].setText(_fmt_event_time_full(ev.get("ts", "")) or na) + + agent_label = agent_roles.label_for(ev.get("agent_role", "")) or na + self._agent_val.setText(agent_label) + self._apply_badge(self._agent_val, + _agent_badge_name(agent_label) if agent_label != na else "badge") + + self._rows["account"][1].setText(ev.get("account", "") or na) + self._machine_val.setText(ev.get("machine", "") or na) + + name = ev.get("name", "") + kind = ev.get("kind", "security_block") + ok = ev.get("ok", False) + self._type_val.setText(_action_label(name) or na) + status_badge, status_key = _status_info(name, kind, ok) + self._status_val.setText(tr(status_key)) + self._apply_badge(self._status_val, status_badge) + + self._detail_text = ev.get("detail", "") or na + self._code_text.setText(self._detail_text) + self._reset_copy_btn() + + self._event_id_val.setText(_event_id(ev.get("ts", ""), row)) + # The static policy label names a real enforcement ruleset — only + # meaningful for a Security Events row; MCP calls/generic actions + # were never evaluated against it. + self._policy_val.setText(_STATIC_POLICY_LABEL if kind == "security_block" else na) + severity_badge, severity_key = _severity_info(name, kind, ok) + self._severity_val.setText(tr(severity_key)) + self._apply_badge(self._severity_val, severity_badge) + + def _copy_detail(self) -> None: + QGuiApplication.clipboard().setText(self._detail_text) + self._copy_btn.setText(tr("monitoring.detail_copied")) + self._copy_btn.setIcon(icon("check")) + QTimer.singleShot(1500, self._reset_copy_btn) + + def _reset_copy_btn(self) -> None: + self._copy_btn.setText(tr("monitoring.detail_copy")) + self._copy_btn.setIcon(icon("document")) + + +class _ClickOutsideCloser(QObject): + """Closes the event-detail panel on a click anywhere outside the + table/panel splitter — a row click changes the selection instead (its + own handler), so this only needs to catch everything else: the search + box, another tab, the nav rail… mirrors the document-level "click + outside the panel" listener in ui-audit_v2.html. + + Judged by screen-space GEOMETRY (is the click's global position inside + the splitter's on-screen rectangle), not by which exact widget object + received the event. Two earlier attempts both broke on the splitter's + drag handle: ``QApplication.widgetAt(globalPos)`` re-hit-tests through + the window server rather than using what Qt actually delivered the event + to, and even the delivered ``obj`` isn't reliable mid-drag — the handle + grabs the mouse and Qt's internal drag bookkeeping doesn't always hand + back the same widget identity a plain ``isAncestorOf`` check expects. + A geometric rect containment check has neither problem: it doesn't care + which sub-widget (viewport, cell widget, scrollbar, handle) the event + was actually delivered to, only whether the click landed on-screen + within the container's bounds.""" + + def __init__(self, table: "_EventTable", panel: "_EventDetailPanel", container: QWidget): + super().__init__(container) + self._table = table + self._panel = panel + self._container = container + + def eventFilter(self, obj, event) -> bool: + if event.type() == QEvent.MouseButtonPress and self._panel.isVisible(): + global_pos = event.globalPosition().toPoint() + top_left = self._container.mapToGlobal(self._container.rect().topLeft()) + rect = QRect(top_left, self._container.size()) + if not rect.contains(global_pos): + self._table.clearSelection() + return False + class MonitoringTab(QWidget): status_message = Signal(str) @@ -145,10 +595,6 @@ class MonitoringTab(QWidget): self._title.setStyleSheet("font-weight:700; font-size:15px;") head.addWidget(self._title) head.addStretch(1) - self.refresh_btn = QPushButton() - self.refresh_btn.setIcon(icon("refresh")) - self.refresh_btn.clicked.connect(self.refresh) - head.addWidget(self.refresh_btn) root.addLayout(head) self.tabs = QTabWidget() @@ -158,25 +604,41 @@ class MonitoringTab(QWidget): self.tabs.addTab(self._build_overview_page(), "") visible = self._tab_visible - self.security_table = _EventTable() - self.security_page = self._wrap_with_filter(self.security_table) + # Security events are always ok=False, so this table trades the + # tick/cross column for a tinted Action column (see _EventTable). + self.security_table = _EventTable(show_result=False) + self.security_page = self._wrap_with_filter( + self.security_table, with_detail=True, title_key="monitoring.security_events_title") if visible("security_events"): self.tabs.addTab(self.security_page, "") self.mcp_table = _EventTable() + self.mcp_page = self._wrap_with_filter( + self.mcp_table, with_detail=True, title_key="monitoring.mcp_history_title") if visible("mcp_history"): - self.tabs.addTab(self.mcp_table, "") + self.tabs.addTab(self.mcp_page, "") self.action_table = _EventTable() - self.action_page = self._wrap_with_filter(self.action_table) + self.action_page = self._wrap_with_filter( + self.action_table, with_detail=True, title_key="monitoring.action_logs_title") if visible("action_logs"): self.tabs.addTab(self.action_page, "") # ---- Agent Status ------------------------------------------------- self.status_table = QTableWidget(0, 3) self.status_table.setEditTriggers(QTableWidget.NoEditTriggers) + # No detail to open on click, no filtering — a plain read-only table, + # so selection is off rather than left dangling with no effect (and it + # sidesteps the Trạng thái badge's dark-theme rgba() background ever + # compositing differently selected vs not — see badge_pill_widget). + self.status_table.setSelectionMode(QTableWidget.NoSelection) self.status_table.verticalHeader().setVisible(False) self.status_table.horizontalHeader().setStretchLastSection(True) + self.status_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.status_table.setColumnWidth(1, 130) # Trạng thái is a cell widget — size it explicitly + self.status_table.setIconSize(QSize(20, 20)) + self.status_page = self._wrap_with_filter( + self.status_table, title_key="monitoring.agent_status_title", with_search=False) if visible("agent_status"): - self.tabs.addTab(self.status_table, "") + self.tabs.addTab(self.status_page, "") # ---- Agents Admin (catalog: assign a role + pinned model per agent) -- # The system-management agents (Security, GraphRAG/Knowledge, Monitor…) @@ -203,8 +665,14 @@ class MonitoringTab(QWidget): self._timer = QTimer(self) # (nav integration methods defined below) + # Only the Overview cards auto-refresh on this tick — Security, MCP, + # Action Logs, Agent Status (and the Agents Admin/Tools/Icons tabs, + # which were never wired to this timer) are read-only tables that a + # background re-sort would otherwise disturb mid-interaction (e.g. + # while a row is selected or the detail-panel splitter is being + # dragged); the user refreshes them explicitly via a "Làm mới" button. self._timer.setInterval(_REFRESH_MS) - self._timer.timeout.connect(self.refresh) + self._timer.timeout.connect(self._auto_refresh) self._timer.start() on_language_changed(self._retranslate) @@ -217,8 +685,8 @@ class MonitoringTab(QWidget): they're correct in every language.""" by_widget = {self.agents_admin_tab: "robot", self.tools_admin_tab: "wrench", self.icons_admin_tab: "star"} - for attr, name in (("security_page", "shield"), ("mcp_table", "plug"), - ("action_page", "bolt"), ("status_table", "monitor")): + for attr, name in (("security_page", "shield"), ("mcp_page", "plug"), + ("action_page", "bolt"), ("status_page", "monitor")): w = getattr(self, attr, None) if w is not None: by_widget[w] = name @@ -338,26 +806,94 @@ class MonitoringTab(QWidget): self.ctx.save() self._reload_pricing_table() - def _wrap_with_filter(self, table: "_EventTable") -> QWidget: + def _wrap_with_filter(self, table: QTableWidget, with_detail: bool = False, + title_key: str | None = None, with_search: bool = True) -> QWidget: page = QWidget() lay = QVBoxLayout(page) lay.setContentsMargins(0, 0, 0, 0) - row = QHBoxLayout() - search = QLineEdit() - search.setPlaceholderText(tr("monitoring.filter_placeholder")) - search.textChanged.connect(table.apply_filter) - ai_btn = QPushButton(tr("monitoring.ai_filter_btn")) - ai_btn.setIcon(icon("sparkle")) - ai_btn.setToolTip(tr("monitoring.ai_filter_tooltip")) - ai_btn.clicked.connect(lambda: self._ai_filter(search, ai_btn)) - row.addWidget(search, 1) - row.addWidget(ai_btn) - lay.addLayout(row) - lay.addWidget(table, 1) - page.filter_edit = search - page.ai_filter_btn = ai_btn + + if title_key: + # The wireframe titles this tab's content on its own row — "Sự kiện + # bảo mật" + a primary Refresh button — separately from the section + # tab strip above (which just says "Bảo mật"). + hdr = QHBoxLayout() + title_lbl = QLabel(tr(title_key)) + title_lbl.setStyleSheet("font-weight:700; font-size:14px;") + hdr.addWidget(title_lbl) + hdr.addStretch(1) + refresh_btn = QPushButton(tr("monitoring.refresh")) + refresh_btn.setIcon(icon("refresh")) + refresh_btn.setObjectName("primary") + refresh_btn.setCursor(Qt.PointingHandCursor) + refresh_btn.clicked.connect(self.refresh) + hdr.addWidget(refresh_btn) + lay.addLayout(hdr) + page.title_lbl = title_lbl + page.title_key = title_key + page.title_refresh_btn = refresh_btn + + if with_search: + row = QHBoxLayout() + search = QLineEdit() + search.setPlaceholderText(tr("monitoring.filter_placeholder")) + search.textChanged.connect(table.apply_filter) + ai_btn = QPushButton(tr("monitoring.ai_filter_btn")) + ai_btn.setIcon(icon("sparkle")) + ai_btn.setToolTip(tr("monitoring.ai_filter_tooltip")) + ai_btn.setCursor(Qt.PointingHandCursor) + ai_btn.clicked.connect(lambda: self._ai_filter(search, ai_btn)) + row.addWidget(search, 1) + row.addWidget(ai_btn) + lay.addLayout(row) + page.filter_edit = search + page.ai_filter_btn = ai_btn + + if with_detail: + # Click a row → its full record opens in a "Chi tiết sự kiện" panel + # on the right (the table itself clips the detail text to 300 chars). + # A pointing-hand cursor over the rows signals that they're clickable. + table.setCursor(Qt.PointingHandCursor) + detail = _EventDetailPanel() + detail.setVisible(False) + detail.closed.connect(table.clearSelection) + table.itemSelectionChanged.connect(lambda: self._sync_event_detail(table, detail)) + split = QSplitter(Qt.Horizontal) + split.addWidget(table) + split.addWidget(detail) + split.setStretchFactor(0, 1) + split.setStretchFactor(1, 0) + split.setChildrenCollapsible(False) + split.setSizes([700, 320]) + lay.addWidget(split, 1) + page.detail_panel = detail + + # Esc, anywhere focus is inside this page, closes the panel the + # same way the close button does. + esc = QShortcut(QKeySequence(Qt.Key_Escape), page) + esc.setContext(Qt.WidgetWithChildrenShortcut) + esc.activated.connect(table.clearSelection) + page.detail_esc_shortcut = esc + + # A click outside both the table and the panel also closes it — + # mirrors ui-audit_v2.html's document-level click-outside listener. + click_filter = _ClickOutsideCloser(table, detail, split) + QApplication.instance().installEventFilter(click_filter) + page.detail_click_filter = click_filter + else: + lay.addWidget(table, 1) return page + def _sync_event_detail(self, table: "_EventTable", panel: "_EventDetailPanel") -> None: + # currentRow() alone is not enough: clearSelection() (used by the + # panel's close button) drops the selection but leaves the current + # cell in place, so a stale currentRow() would keep the panel open. + row = table.currentRow() + has_selection = bool(table.selectedItems()) + ev = table.event_at_row(row) if (has_selection and row >= 0) else None + if ev: + panel.show_event(ev, row) + panel.setVisible(bool(ev)) + def _ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None: query = search.text().strip() if not query or getattr(self, "_ai_filter_worker", None) is not None: @@ -403,17 +939,20 @@ class MonitoringTab(QWidget): scroll.setWidget(content) outer.addWidget(scroll) - root = QHBoxLayout(content) + # ONE main column, scrolled vertically, sections in a fixed order — the + # two-column grid put six group boxes side by side and mixed three + # unrelated concerns (cost, machine resources, security) at the same + # level, which made this the densest screen in the app. Each section now + # spans the full width and lays its own contents out horizontally, so a + # wide window is still used well. + root = QVBoxLayout(content) root.setSpacing(12) - left = QVBoxLayout() - left.setSpacing(12) - right = QVBoxLayout() - right.setSpacing(12) - root.addLayout(left, 2) - root.addLayout(right, 1) + left = root # sections are appended in reading order + right = root # ---- Token Usage & Cost -------------------------------------------- self.ov_usage_group = QGroupBox() + self.ov_usage_group.setObjectName("monSection") usage_lay = QGridLayout(self.ov_usage_group) usage_lay.setSpacing(8) self.ov_usage_total = StatCard() @@ -421,19 +960,27 @@ class MonitoringTab(QWidget): self.ov_usage_out = StatCard() self.ov_usage_cache = StatCard() self.ov_usage_cost = StatCard() - for i, card in enumerate((self.ov_usage_total, self.ov_usage_in, self.ov_usage_out, - self.ov_usage_cache, self.ov_usage_cost)): + self.ov_usage_calls = StatCard() + # The wireframe's usage block is five figures: cost (with the turn + # count on its label), then Tổng token / Input / Output / Cache. Folding + # the last three into a sub-line took their PER-PART COST off screen — + # the total tile has room for the token counts but not for three more + # prices — and the drawing asks for the tiles anyway. + for i, card in enumerate((self.ov_usage_cost, self.ov_usage_total, + self.ov_usage_in, self.ov_usage_out, + self.ov_usage_cache)): usage_lay.addWidget(card, 0, i) + self.ov_usage_calls.setVisible(False) # rides on the cost tile's label # Budget: remaining/budget, direct entry, auto-warns red past 85% used — # same box (and same usage.budget_* config) as the Dashboard's. self.ov_budget_card = BudgetCard() self.ov_budget_card.apply_btn.setIcon(icon("check")) self.ov_budget_card.apply_btn.clicked.connect(self._apply_budget) - usage_lay.addWidget(self.ov_budget_card, 0, 5, 2, 1) + usage_lay.addWidget(self.ov_budget_card, 0, 3) # Equal stretch on every column — otherwise the grid sizes each column # to its widest cell's natural content (Budget's longer "$X / $Y" value # + entry row makes its column wider than the plain stat cards). - for col in range(6): + for col in range(4): usage_lay.setColumnStretch(col, 1) # Unit prices are NOT entered here anymore — the cost total is computed @@ -444,41 +991,60 @@ class MonitoringTab(QWidget): # ---- Recent Activity ---------------------------------------------- self.ov_activity_group = QGroupBox() + self.ov_activity_group.setObjectName("monSection") act_lay = QVBoxLayout(self.ov_activity_group) self.ov_activity_lbl = QLabel() self.ov_activity_lbl.setWordWrap(True) self.ov_activity_lbl.setTextFormat(Qt.RichText) act_lay.addWidget(self.ov_activity_lbl) - left.addWidget(self.ov_activity_group) + # added near the bottom, beside the audit log — see below # ---- Resource Usage ------------------------------------------------- self.ov_resource_group = QGroupBox() - res_lay = QVBoxLayout(self.ov_resource_group) + self.ov_resource_group.setObjectName("monSection") + # ONE compact line, as the wireframe writes it: name and value sit + # together and the pairs are separated by a middle dot, packed left — + # spread across the full width they read as four unrelated columns with + # the value stranded at the far edge of a 1900px screen. + res_lay = QHBoxLayout(self.ov_resource_group) + res_lay.setSpacing(6) + self._res_first = True + + def _pair(): + if not self._res_first: + sep = QLabel("·") + sep.setObjectName("hint") + res_lay.addWidget(sep) + self._res_first = False + lbl = QLabel(); lbl.setObjectName("hint") + val = QLabel() + res_lay.addWidget(lbl) + res_lay.addWidget(val) + return lbl, val def _bar_row(): - row = QHBoxLayout() - lbl = QLabel(); lbl.setFixedWidth(70) - bar = QProgressBar(); bar.setFixedHeight(8); bar.setTextVisible(False) - val = QLabel(); val.setFixedWidth(90); val.setAlignment(Qt.AlignRight) - row.addWidget(lbl); row.addWidget(bar, 1); row.addWidget(val) - res_lay.addLayout(row) + # The gauge is kept and updated, but off the line: at a glance the + # number is what is read, and the bar was drawing a 700px rule. + lbl, val = _pair() + bar = QProgressBar() + bar.setVisible(False) return lbl, bar, val def _text_row(): - row = QHBoxLayout() - lbl = QLabel(); val = QLabel(); val.setAlignment(Qt.AlignRight) - row.addWidget(lbl); row.addStretch(1); row.addWidget(val) - res_lay.addLayout(row) - return lbl, val + return _pair() self.ov_cpu_lbl, self.ov_cpu_bar, self.ov_cpu_val = _bar_row() self.ov_mem_lbl, self.ov_mem_bar, self.ov_mem_val = _bar_row() - self.ov_disk_lbl, self.ov_disk_val = _text_row() - self.ov_network_lbl, self.ov_network_val = _text_row() + self.ov_diskfree_lbl, self.ov_diskfree_val = _text_row() + # I/O and network rates keep working; they are read from the detail + # fold rather than the summary line the wireframe draws. + self.ov_disk_lbl, self.ov_disk_val = QLabel(), QLabel() + self.ov_network_lbl, self.ov_network_val = QLabel(), QLabel() res_lay.addStretch(1) # ---- Model pricing (beside the CPU/resource group) ------------------ self.ov_pricing_group = QGroupBox() + self.ov_pricing_group.setObjectName("monSection") pg = QVBoxLayout(self.ov_pricing_group) phdr = QHBoxLayout() self.ov_pricing_ccy_lbl = QLabel(); self.ov_pricing_ccy_lbl.setObjectName("hint") @@ -513,17 +1079,32 @@ class MonitoringTab(QWidget): self.ov_pricing_table.setSelectionBehavior(QTableWidget.SelectRows) pg.addWidget(self.ov_pricing_table, 1) - res_row = QHBoxLayout() - res_row.setSpacing(12) - res_row.addWidget(self.ov_resource_group, 1) - res_row.addWidget(self.ov_pricing_group, 2) # pricing sits beside CPU/resources - left.addLayout(res_row) - left.addStretch(1) + # Resources keep the row to themselves; the model price table gets its + # own full-width section further down (it is a reference table, not a + # live meter, and squeezing it next to the CPU bars made both unreadable). + left.addWidget(self.ov_resource_group) self._reload_pricing_table() # ---- Sandbox Details -------------------------------------------- self.ov_sandbox_details_group = QGroupBox() + self.ov_sandbox_details_group.setObjectName("monSection") sbx_lay = QVBoxLayout(self.ov_sandbox_details_group) + self.ov_sbx_summary = QLabel() + self.ov_sbx_summary.setWordWrap(True) + sbx_lay.addWidget(self.ov_sbx_summary) + self.ov_sbx_more_btn = QPushButton() + self.ov_sbx_more_btn.setObjectName("co4eSectionAction") + self.ov_sbx_more_btn.setFlat(True) + self.ov_sbx_more_btn.setCheckable(True) + self.ov_sbx_more_btn.setCursor(Qt.PointingHandCursor) + sbx_lay.addWidget(self.ov_sbx_more_btn, 0, Qt.AlignLeft) + self._sbx_detail = QWidget() + self._sbx_detail.setVisible(False) + self.ov_sbx_more_btn.toggled.connect(self._sbx_detail.setVisible) + self.ov_sbx_more_btn.toggled.connect(self._sync_sbx_more_label) + sbx_lay.addWidget(self._sbx_detail) + sbx_lay = QVBoxLayout(self._sbx_detail) + sbx_lay.setContentsMargins(0, 4, 0, 0) def _kv(): row = QHBoxLayout() @@ -551,10 +1132,13 @@ class MonitoringTab(QWidget): sbx_lay.addLayout(limits_row) self.ov_sbx_net_lbl, self.ov_sbx_net_val = _kv() - right.addWidget(self.ov_sandbox_details_group) + # Sandbox and Permissions answer the same question ("what is the agent + # allowed to touch?"), so they share one full-width row. + root.addWidget(self.ov_sandbox_details_group) # ---- Permissions ----------------------------------------------- self.ov_permissions_group = QGroupBox() + self.ov_permissions_group.setObjectName("monSection") perm_lay = QVBoxLayout(self.ov_permissions_group) def _pkv(): @@ -572,11 +1156,19 @@ class MonitoringTab(QWidget): self.ov_perm_edit_btn = QPushButton() self.ov_perm_edit_btn.setFlat(True) self.ov_perm_edit_btn.clicked.connect(self._open_settings_and_refresh) - perm_lay.addWidget(self.ov_perm_edit_btn, 0, Qt.AlignRight) - right.addWidget(self.ov_permissions_group) + perm_lay.addWidget(self.ov_perm_edit_btn, 0, Qt.AlignLeft) + # Inside the same fold as the sandbox rows — one section, one line. + self._sbx_detail.layout().addWidget(self.ov_permissions_group) + + # ---- Model pricing — its own section, full width ------------------ + root.addWidget(self.ov_pricing_group) + + # ---- What actually happened, last --------------------------------- + root.addWidget(self.ov_activity_group) # ---- Audit Log ---------------------------------------------------- self.ov_audit_group = QGroupBox() + self.ov_audit_group.setObjectName("monSection") audit_lay = QVBoxLayout(self.ov_audit_group) self.ov_audit_lbl = QLabel() self.ov_audit_lbl.setWordWrap(True) @@ -616,13 +1208,12 @@ class MonitoringTab(QWidget): # ---- i18n ------------------------------------------------------------ def _retranslate(self) -> None: self._title.setText(tr("monitoring.title")) - self.refresh_btn.setText(tr("monitoring.refresh")) if self.tabs.count(): self.tabs.setTabText(0, tr("monitoring.tab_overview")) self._set_tab_text_if_present(self.security_page, tr("monitoring.tab_security")) - self._set_tab_text_if_present(self.mcp_table, tr("monitoring.tab_mcp")) + self._set_tab_text_if_present(self.mcp_page, tr("monitoring.tab_mcp")) self._set_tab_text_if_present(self.action_page, tr("monitoring.tab_actions")) - self._set_tab_text_if_present(self.status_table, tr("monitoring.tab_agents")) + self._set_tab_text_if_present(self.status_page, tr("monitoring.tab_agents")) self._set_tab_text_if_present(self.agents_admin_tab, tr("monitoring.tab_agents_admin")) self._set_tab_text_if_present(self.tools_admin_tab, tr("monitoring.tab_tools")) self._set_tab_text_if_present(self.icons_admin_tab, tr("monitoring.tab_icons")) @@ -630,23 +1221,34 @@ class MonitoringTab(QWidget): self.mcp_table.retranslate() self.action_table.retranslate() self.status_table.setHorizontalHeaderLabels([ - tr("monitoring.col_agent"), tr("monitoring.col_active"), tr("monitoring.col_source"), + tr("monitoring.col_agent"), tr("monitoring.detail_status"), tr("monitoring.col_source"), ]) - self.ov_usage_group.setTitle(tr("monitoring.overview_usage_title")) + # A QGroupBox title treats "&" as a mnemonic marker, so "token & Chi + # phí" rendered as "token _Chi phí". Double it to show a literal "&". + self.ov_usage_group.setTitle( + tr("monitoring.overview_usage_title").upper().replace("&", "&&")) self.ov_budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip")) self.ov_budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip")) - self.ov_activity_group.setTitle(tr("monitoring.overview_activity_title")) - self.ov_resource_group.setTitle(tr("monitoring.overview_resource_title")) - self.security_page.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder")) - self.action_page.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder")) + self.ov_activity_group.setTitle(tr("monitoring.overview_activity_title").upper()) + self.ov_resource_group.setTitle(tr("monitoring.overview_resource_title").upper()) + for page in (self.security_page, self.mcp_page, self.action_page): + page.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder")) + page.detail_panel.retranslate() + page.title_lbl.setText(tr(page.title_key)) + page.title_refresh_btn.setText(tr("monitoring.refresh")) + # status_page has no search box and no detail panel (with_search=False, + # with_detail=False) — just the title + Làm mới header. + self.status_page.title_lbl.setText(tr(self.status_page.title_key)) + self.status_page.title_refresh_btn.setText(tr("monitoring.refresh")) self.ov_cpu_lbl.setText(tr("monitoring.overview_res_cpu")) self.ov_mem_lbl.setText(tr("monitoring.overview_res_mem")) + self.ov_diskfree_lbl.setText(tr("monitoring.overview_disk_label")) self.ov_disk_lbl.setText(tr("monitoring.overview_res_disk")) self.ov_network_lbl.setText(tr("monitoring.overview_res_network")) # model pricing panel - self.ov_pricing_group.setTitle(tr("monitoring.pricing_title")) + self.ov_pricing_group.setTitle(tr("monitoring.pricing_title").upper()) self.ov_pricing_ccy_lbl.setText(tr("monitoring.pricing_currency")) self.ov_price_import_btn.setText(tr("monitoring.pricing_import")) self.ov_price_export_btn.setText(tr("monitoring.pricing_export")) @@ -658,7 +1260,9 @@ class MonitoringTab(QWidget): tr("monitoring.pricing_col_maxout"), tr("monitoring.pricing_col_input"), tr("monitoring.pricing_col_output")]) - self.ov_sandbox_details_group.setTitle(tr("monitoring.overview_sandbox_details_title")) + self.ov_sandbox_details_group.setTitle( + # "&" is a mnemonic marker in a group-box title — double it. + tr("monitoring.overview_sandbox_details_title").upper().replace("&", "&&")) self.ov_sbx_id_lbl.setText(tr("monitoring.overview_sandbox_id")) self.ov_sbx_status_lbl.setText(tr("monitoring.overview_status")) self.ov_sbx_created_lbl.setText(tr("monitoring.overview_created")) @@ -666,7 +1270,7 @@ class MonitoringTab(QWidget): self.ov_sbx_edit_btn.setText(tr("monitoring.overview_edit")) self.ov_sbx_net_lbl.setText(tr("monitoring.overview_network_label")) - self.ov_permissions_group.setTitle(tr("monitoring.overview_permissions_title")) + self.ov_permissions_group.setTitle(tr("monitoring.overview_permissions_title").upper()) self.ov_perm_fs_lbl.setText(tr("monitoring.overview_perm_fs")) self.ov_perm_fs_val.setText(tr("monitoring.overview_perm_fs_value")) self.ov_perm_network_lbl.setText(tr("monitoring.overview_perm_network")) @@ -676,13 +1280,17 @@ class MonitoringTab(QWidget): self.ov_perm_env_val.setText(tr("monitoring.overview_perm_env_value")) self.ov_perm_edit_btn.setText(tr("monitoring.overview_edit")) - self.ov_audit_group.setTitle(tr("monitoring.overview_audit_title")) + self.ov_audit_group.setTitle(tr("monitoring.overview_audit_title").upper()) self.ov_view_all_btn.setText(tr("monitoring.overview_view_all")) self.refresh() # ---- refresh ----------------------------------------------------------- def refresh(self) -> None: + """Full refresh — Overview cards plus every table. Wired to the + top-of-page and per-section "Làm mới" buttons, called once at + startup/language-change, but NOT to the auto-refresh timer (see + ``_auto_refresh``).""" self._refresh_resource_usage() events = self._load_events() self.security_table.set_events([e for e in events if e.get("kind") == "security_block"]) @@ -691,6 +1299,11 @@ class MonitoringTab(QWidget): self._refresh_agent_status() self._refresh_overview(events) + def _auto_refresh(self) -> None: + """3-second timer tick — Overview cards only (see ``refresh``).""" + self._refresh_resource_usage() + self._refresh_overview(self._load_events()) + def _load_events(self) -> List[dict]: shared_dir = self.ctx.config.shared_dir if shared_dir: @@ -722,7 +1335,18 @@ class MonitoringTab(QWidget): except Exception: mem_pct = 0 self.ov_mem_bar.setValue(min(mem_pct, 100)) - self.ov_mem_val.setText(_fmt_bytes(own_mem)) + try: + self.ov_mem_val.setText(f"{_fmt_bytes(own_mem)}/{_fmt_bytes(total_mem)}") + except Exception: # noqa: BLE001 + self.ov_mem_val.setText(_fmt_bytes(own_mem)) + # Free disk on the workspace drive — a capacity fact, unlike the I/O + # rate that used to sit here, and the one the drawing shows. + try: + free = psutil.disk_usage(str(self.ctx.config.cowork_output_dir())).free + self.ov_diskfree_val.setText( + tr("monitoring.overview_disk_free", size=_fmt_bytes(free))) + except Exception: # noqa: BLE001 + self.ov_diskfree_val.setText(tr("monitoring.na")) now = time.monotonic() try: @@ -781,12 +1405,22 @@ class MonitoringTab(QWidget): ] self.status_table.setRowCount(len(rows)) for row, (role_key, count, source) in enumerate(rows): - self.status_table.setItem(row, 0, QTableWidgetItem(agent_roles.label_for(role_key))) + label = agent_roles.label_for(role_key) + name_item = QTableWidgetItem(label) + name_item.setIcon(_agent_avatar_icon(label)) + self.status_table.setItem(row, 0, name_item) + if role_key == agent_roles.SECURITY: - active_text = tr("monitoring.on") if sec_on else tr("monitoring.off") + running = sec_on + status_text = tr("monitoring.on") if sec_on else tr("monitoring.off") + elif count is not None: + running = count > 0 + status_text = tr("monitoring.active_n", n=count) if running else tr("monitoring.idle") else: - active_text = tr("monitoring.active_n", n=count) if count is not None else "—" - self.status_table.setItem(row, 1, QTableWidgetItem(active_text)) + running = False + status_text = "—" + badge_tone = "badgeSuccess" if running else "badgeNeutral" + self.status_table.setCellWidget(row, 1, badge_pill_widget(status_text, badge_tone)) self.status_table.setItem(row, 2, QTableWidgetItem(source)) def _activity_line(self, event: dict) -> str: @@ -801,7 +1435,8 @@ class MonitoringTab(QWidget): mark = f"✗" name = event.get("name", "") or event.get("kind", "") rel = _relative_time(event.get("ts", "")) - suffix = f" — {rel}" if rel else "" + muted = current_palette().text_muted + suffix = f" — {rel}" if rel else "" return f"{mark} {name}{suffix}" def _refresh_usage_cards(self) -> None: @@ -811,18 +1446,26 @@ class MonitoringTab(QWidget): events = ut.load_events() s = ut.summarize(events) costs = ut.cost_usd_events(events, pricing) - self.ov_usage_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"]), - tr("dashboard.card_turns", n=s["turns"])) + self.ov_usage_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"])) + self.ov_usage_calls.set(tr("monitoring.overview_calls"), str(s["turns"]), "") self.ov_usage_in.set(tr("dashboard.card_in"), fmt_tokens(s["in"]), ut.format_cost(costs["in"], pricing)) self.ov_usage_out.set(tr("dashboard.card_out"), fmt_tokens(s["out"]), ut.format_cost(costs["out"], pricing)) self.ov_usage_cache.set(tr("dashboard.card_cache"), fmt_tokens(s["cache"]), ut.format_cost(costs["cache"], pricing)) - self.ov_usage_cost.set(tr("dashboard.card_cost"), - ut.format_cost(sum(costs.values()), pricing, digits=2)) + # "Tổng chi phí · 57 lượt", exactly as the wireframe labels it. + self.ov_usage_cost.set( + f'{tr("dashboard.card_cost")} · {s["turns"]} {tr("monitoring.overview_calls")}', + ut.format_cost(sum(costs.values()), pricing, digits=2)) self._refresh_budget() + def _sync_sbx_more_label(self, *_a) -> None: + """Label the fold with what it will do next.""" + open_ = self.ov_sbx_more_btn.isChecked() + self.ov_sbx_more_btn.setText( + ("▾ " if open_ else "▸ ") + tr("monitoring.overview_sbx_detail")) + def _apply_budget(self) -> None: """Persist the spin box's value as the new budget — starts a fresh remaining-balance window (spend before now is no longer counted).""" @@ -884,6 +1527,18 @@ class MonitoringTab(QWidget): tr("monitoring.overview_network_disabled") if net_blocked else tr("monitoring.overview_network_enabled")) self._set_badge(self.ov_sbx_net_val, "badgeWarn" if net_blocked else "badgeSuccess") + # The one line the wireframe shows; the detail above stays a fold away. + self.ov_sbx_summary.setText(" · ".join([ + f'{tr("monitoring.overview_perm_fs")}: ' + f'{tr("monitoring.overview_perm_fs_value")}', + f'{tr("monitoring.overview_perm_network")}: ' + f'{tr("monitoring.overview_network_disabled") if net_blocked else tr("monitoring.overview_network_enabled")}', + f'{tr("monitoring.overview_perm_process")}: ' + f'{tr("monitoring.overview_perm_process_value")}', + f'{tr("monitoring.overview_resource_limits")}: ' + f'{", ".join(limit_parts) if limit_parts else tr("monitoring.na")}', + ])) + self._sync_sbx_more_label() self.ov_perm_network_val.setText( tr("monitoring.overview_perm_network_blocked") if net_blocked diff --git a/ui/schedule_task_tab.py b/ui/schedule_task_tab.py index 7849ad7..bcc2a58 100644 --- a/ui/schedule_task_tab.py +++ b/ui/schedule_task_tab.py @@ -1,716 +1,794 @@ -"""Schedule Task tab — Kanban board for scheduled/automated tasks. - -Columns: Backlog / Scheduled / Running / Waiting Input / Done / Failed / -Paused. Cards drag between columns (dropping = changing status), double-click -edits, right-click offers Run now / Edit / Duplicate / Pause / Delete / View -logs / Create-next-from-output. Header has search, a type filter, Add Task -and AI Create Task (preview first — nothing is created until confirmed). -""" -from __future__ import annotations - -import copy -from pathlib import Path -from typing import Dict, List, Optional - -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import ( - QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, - QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, - QPlainTextEdit, QPushButton, QScrollArea, QStackedWidget, QTableWidget, - QTableWidgetItem, QVBoxLayout, QWidget, -) - -from ..core import tasks as taskrepo -from ..core.projects import list_projects -from ..core.tasks import STATUSES, chain_error, duplicate_task, new_task -from ..core.worker import AgentWorker -from ..i18n import on_language_changed, tr -from ..state import AppContext -from .calendar_view import CalendarView -from .icons import icon -from .osutil import open_path - -_VIEWS = ("kanban", "calendar") - -# Priority shown as a plain text tag (no colored-emoji squares). Only the -# elevated priorities get a visible marker; low/medium stay unmarked as before. -_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"} - - -class _KanbanColumn(QListWidget): - """One status lane. Accepts drops from sibling columns; a drop means - 'move this task to my status'.""" - - task_dropped = Signal(str, str) # task_id, new_status - - def __init__(self, status: str): - super().__init__() - self.status = status - self.setDragDropMode(QAbstractItemView.DragDrop) - self.setDefaultDropAction(Qt.MoveAction) - # Shift/Ctrl-click several cards in the SAME column, then right-click - # → "Delete N selected" to bulk-remove tasks instead of one at a time. - self.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.setWordWrap(True) - self.setMinimumWidth(190) - - def dropEvent(self, event): # noqa: N802 - source = event.source() - if isinstance(source, _KanbanColumn) and source is not self: - item = source.currentItem() - tid = item.data(Qt.UserRole) if item else None - if tid: - event.acceptProposedAction() - self.task_dropped.emit(tid, self.status) - return - event.ignore() - - -class ScheduleTaskTab(QWidget): - status_message = Signal(str) - - def __init__(self, ctx: AppContext, scheduler=None): - super().__init__() - self.ctx = ctx - self.scheduler = scheduler # TaskScheduler (may be None in tests) - self._ai_worker: Optional[AgentWorker] = None - self._tasks_dir: Optional[Path] = None # None → default repo dir - - root = QVBoxLayout(self) - - # ---- header ---------------------------------------------------- - header = QHBoxLayout() - self._title = QLabel() - self._title.setStyleSheet("font-weight:700; font-size:15px;") - self.counts_lbl = QLabel("") - self.counts_lbl.setObjectName("hint") - self.add_btn = QPushButton() - self.add_btn.setIcon(icon("plus")) - self.add_btn.setObjectName("primary") - self.add_btn.clicked.connect(self._add_task) - self.ai_btn = QPushButton() - self.ai_btn.setIcon(icon("sparkle")) - self.ai_btn.clicked.connect(self._ai_create) - self.view_combo = QComboBox() - for v in _VIEWS: - self.view_combo.addItem("", v) - self.view_combo.currentIndexChanged.connect(self._on_view_changed) - header.addWidget(self._title) - header.addWidget(self.counts_lbl, 1) - header.addWidget(self.view_combo) - header.addWidget(self.add_btn) - header.addWidget(self.ai_btn) - root.addLayout(header) - - # ---- board / calendar (two views of the SAME tasks) ----------------- - self._view_stack = QStackedWidget() - scroll = QScrollArea() - scroll.setWidgetResizable(True) - board = QWidget() - scroll.setWidget(board) - cols = QHBoxLayout(board) - cols.setSpacing(8) - self.columns: Dict[str, _KanbanColumn] = {} - self.column_headers: Dict[str, QLabel] = {} - for status in STATUSES: - box = QVBoxLayout() - head = QLabel() - head.setStyleSheet("font-weight:600;") - col = _KanbanColumn(status) - col.task_dropped.connect(self._on_task_dropped) - col.itemDoubleClicked.connect(self._on_double_click) - col.setContextMenuPolicy(Qt.CustomContextMenu) - col.customContextMenuRequested.connect( - lambda pos, c=col: self._context_menu(c, pos)) - box.addWidget(head) - box.addWidget(col, 1) - holder = QWidget() - holder.setLayout(box) - cols.addWidget(holder) - self.columns[status] = col - self.column_headers[status] = head - self._view_stack.addWidget(scroll) - self.calendar = CalendarView() - self.calendar.edit_task.connect(self._edit_task) - self.calendar.add_task_on_date.connect(self._add_task_on_date) - self._view_stack.addWidget(self.calendar) - root.addWidget(self._view_stack, 1) - - if self.scheduler is not None: - self.scheduler.tasks_changed.connect(self.refresh) - self.scheduler.task_started.connect(lambda _tid: self.refresh()) - self.scheduler.task_finished.connect(lambda _tid, _ok: self.refresh()) - - # Belt-and-braces: also re-read the board every 10s so a card's lane - # ALWAYS reflects reality (Scheduled → Running → Done) even if some - # change slipped past the signals (e.g. task files edited externally). - from PySide6.QtCore import QTimer - self._refresh_timer = QTimer(self) - self._refresh_timer.setInterval(10_000) - self._refresh_timer.timeout.connect(self.refresh) - self._refresh_timer.start() - - self.refresh() - on_language_changed(self._retranslate) - - # ---- i18n ------------------------------------------------------------ - def _retranslate(self) -> None: - self._title.setText(tr("schedtask.title")) - self.add_btn.setText(tr("schedtask.add_btn")) - self.add_btn.setToolTip(tr("schedtask.add_tooltip")) - self.ai_btn.setText(tr("schedtask.ai_btn")) - self.ai_btn.setToolTip(tr("schedtask.ai_tooltip")) - for i, v in enumerate(_VIEWS): - self.view_combo.setItemText(i, tr(f"schedtask.view.{v}")) - for status, col in self.columns.items(): - col.setToolTip(tr(f"schedtask.col_tip.{status}")) - self.refresh() - - # ---- Kanban / Calendar view switch -------------------------------- - def _on_view_changed(self) -> None: - self._view_stack.setCurrentIndex(self.view_combo.currentIndex()) - - def _add_task_on_date(self, date_str: str) -> None: - """Create a task pre-filled with the clicked calendar date (default - 09:00) — same editor Add Task opens, nothing is saved until confirmed.""" - from .task_editor_dialog import TaskEditorDialog - - t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"}) - dlg = TaskEditorDialog(t, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) - if dlg.exec() and dlg.edited_task: - self._save_and_refresh(dlg.edited_task) - self.status_message.emit(tr("schedtask.msg_created")) - - # ---- board rendering --------------------------------------------------- - def _card_text(self, t: dict) -> str: - prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "") - ai = "[AI] " if t.get("is_ai_generated") else "" - sched = t.get("schedule", {}) - when = sched.get("run_at") if sched.get("enabled") else None - when_line = when or tr("schedtask.no_schedule") - chain = "" - if t.get("dependency", {}).get("next_task_id") or t.get("dependency", {}).get("previous_task_id"): - chain = " (linked)" - last = t.get("logs", {}).get("last_status") - last_line = {"success": tr("schedtask.last_success"), - "failed": tr("schedtask.last_failed")}.get(last, tr("schedtask.last_never")) - # Card shows ONLY the task's own title (plus the [AI] marker and chain - # note) — no "[Cowork]"/"[Code]" task-type tag cluttering it. - return (f"{ai}{t.get('title', '')}{chain}\n" - f"{when_line} {prio}\n{last_line}") - - def refresh(self) -> None: - all_tasks = taskrepo.list_tasks(self._tasks_dir) - counts = {s: 0 for s in STATUSES} - for col in self.columns.values(): - col.clear() - for t in all_tasks: - status = t.get("status", "backlog") - if status not in self.columns: - continue - counts[status] += 1 - item = QListWidgetItem(self._card_text(t)) - item.setData(Qt.UserRole, t["task_id"]) - self.columns[status].addItem(item) - for status, col in self.columns.items(): - self.column_headers[status].setText( - f"{tr(f'schedtask.status.{status}')} ({counts[status]})") - if col.count() == 0: - empty = QListWidgetItem(tr("schedtask.no_tasks")) - empty.setFlags(Qt.NoItemFlags) - col.addItem(empty) - self.counts_lbl.setText(" ".join( - f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s])) - self.calendar.set_tasks(all_tasks) - - # ---- actions -------------------------------------------------------- - def _save_and_refresh(self, task: dict) -> None: - taskrepo.save_task(task, self._tasks_dir) - self.refresh() - - def _add_task(self) -> None: - from .task_editor_dialog import TaskEditorDialog - - dlg = TaskEditorDialog(None, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) - if dlg.exec() and dlg.edited_task: - self._save_and_refresh(dlg.edited_task) - self.status_message.emit(tr("schedtask.msg_created")) - - def _edit_task(self, task_id: str) -> None: - from .task_editor_dialog import TaskEditorDialog - - task = taskrepo.load_task(task_id, self._tasks_dir) - if not task: - return - dlg = TaskEditorDialog(task, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) - if dlg.exec() and dlg.edited_task: - self._save_and_refresh(dlg.edited_task) - - def _on_double_click(self, item: QListWidgetItem) -> None: - tid = item.data(Qt.UserRole) - if tid: - self._edit_task(tid) - - def _on_task_dropped(self, task_id: str, new_status: str) -> None: - """Dropping a card into a lane ACTS on the task, not just relabels it: - → Running actually runs it now; → Done marks it completed; → Scheduled - puts it on the calendar (opening the editor if no time is set yet).""" - task = taskrepo.load_task(task_id, self._tasks_dir) - if not task: - return - if task.get("status") == "running": - self.refresh() # can't drag a running task - return - if new_status == "running": - # Dropping into Running = "run it now" (counts as manual approval). - self.refresh() - self._run_now(task) - return - if new_status == "done": - task["status"] = "done" - task["schedule"]["enabled"] = False # done by hand → don't re-fire - self._save_and_refresh(task) - return - task["status"] = new_status - if new_status == "scheduled" and not task["schedule"].get("enabled"): - if task["schedule"].get("run_at"): - task["schedule"]["enabled"] = True - else: - # No time set yet — a silently-disabled "Scheduled" card would - # never run and look broken. Open the editor so the user sets - # the schedule right away. - self._save_and_refresh(task) - self.status_message.emit(tr("schedtask.msg_set_schedule")) - self._edit_task(task_id) - return - self._save_and_refresh(task) - - @staticmethod - def _is_multi_selection(item, selected) -> bool: - """True when the right-clicked card is part of an existing multi-item - selection — pure boolean, kept separate from _context_menu so it's - testable without ever invoking Qt's (modal, event-loop-blocking) menu.""" - return len(selected) > 1 and item in selected - - def _context_menu(self, col: _KanbanColumn, pos) -> None: - item = col.itemAt(pos) - if item is None or not item.data(Qt.UserRole): - return - selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)] - if self._is_multi_selection(item, selected): - self._bulk_delete_menu(col, pos, selected) - return - tid = item.data(Qt.UserRole) - task = taskrepo.load_task(tid, self._tasks_dir) - if not task: - return - menu = QMenu(col) - run_act = menu.addAction(tr("schedtask.menu_run")) - edit_act = menu.addAction(tr("schedtask.menu_edit")) - dup_act = menu.addAction(tr("schedtask.menu_duplicate")) - paused = task.get("status") == "paused" - pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause")) - logs_act = menu.addAction(tr("schedtask.menu_logs")) - hist_act = menu.addAction(tr("schedtask.menu_history")) - next_act = menu.addAction(tr("schedtask.menu_create_next")) - menu.addSeparator() - del_act = menu.addAction(tr("schedtask.menu_delete")) - chosen = menu.exec(col.viewport().mapToGlobal(pos)) - if chosen == run_act: - self._run_now(task) - elif chosen == edit_act: - self._edit_task(tid) - elif chosen == dup_act: - self._save_and_refresh(duplicate_task(task)) - elif chosen == pause_act: - task["status"] = "backlog" if paused else "paused" - self._save_and_refresh(task) - elif chosen == logs_act: - self._view_logs(task) - elif chosen == hist_act: - _RunHistoryDialog(task, self).exec() - elif chosen == next_act: - self._create_next_from_output(task) - elif chosen == del_act: - if QMessageBox.question(self, tr("schedtask.menu_delete"), - tr("schedtask.delete_confirm", title=task.get("title", "")) - ) == QMessageBox.Yes: - taskrepo.delete_task(tid, self._tasks_dir) - self.refresh() - - def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None: - """Right-click on a multi-selection within one column (Shift/Ctrl-click - several cards first): one action deletes every selected task. The - popup itself is a thin wrapper — see _confirm_and_delete_selected for - the actual (independently testable) confirm+delete logic.""" - menu = QMenu(col) - del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected))) - chosen = menu.exec(col.viewport().mapToGlobal(pos)) - if chosen == del_act: - self._confirm_and_delete_selected(selected) - - def _confirm_and_delete_selected(self, selected) -> bool: - """Confirm, then delete every task in ``selected``. Split out of - _bulk_delete_menu so tests can drive it directly without having to - fake a real (modal, event-loop-blocking) QMenu popup.""" - if QMessageBox.question( - self, tr("schedtask.menu_delete"), - tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes: - return False - for item in selected: - tid = item.data(Qt.UserRole) - if tid: - taskrepo.delete_task(tid, self._tasks_dir) - self.refresh() - return True - - def _run_now(self, task: dict) -> None: - if task.get("task_type") == "manual": - self.status_message.emit(tr("schedtask.msg_manual_norun")) - return - if self.scheduler is None: - self.status_message.emit(tr("schedtask.msg_no_scheduler")) - return - if self.scheduler.run_now(task["task_id"]): - self.status_message.emit(tr("schedtask.msg_running", title=task.get("title", ""))) - self.refresh() - - def _view_logs(self, task: dict) -> None: - run_id = task.get("logs", {}).get("last_run_id") - if not run_id: - QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet")) - return - folder = taskrepo.ARTIFACTS_DIR / task["task_id"] / run_id - if folder.exists(): - open_path(str(folder)) - else: - QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet")) - - def _create_next_from_output(self, task: dict) -> None: - """Scaffold a follow-up task pre-wired to consume this task's output.""" - nxt = new_task(tr("schedtask.next_of", title=task.get("title", ""))) - nxt["task_type"] = "cowork" - nxt["input"]["mode"] = "previous_task_output" - nxt["input"]["previous_task_id"] = task["task_id"] - nxt["dependency"]["previous_task_id"] = task["task_id"] - err = chain_error(taskrepo.list_tasks(self._tasks_dir) + [nxt], - task["task_id"], nxt["task_id"]) - if err: - QMessageBox.warning(self, tr("schedtask.g_dependency"), err) - return - taskrepo.save_task(nxt, self._tasks_dir) - task["dependency"]["next_task_id"] = nxt["task_id"] - task["dependency"]["pass_output_to_next"] = True - if task["dependency"].get("run_next_mode", "none") == "none": - task["dependency"]["run_next_mode"] = "run_after_success" - taskrepo.save_task(task, self._tasks_dir) - self.refresh() - self._edit_task(nxt["task_id"]) - - # ---- AI create ---------------------------------------------------------- - def _ai_create(self) -> None: - dlg = _AiCreateDialog(self.ctx, self) - if dlg.exec() and dlg.created_tasks: - for t in dlg.created_tasks: - taskrepo.save_task(t, self._tasks_dir) - self.refresh() - self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks))) - - -class _RunHistoryDialog(QDialog): - """Run history of one task as a table (newest first): time, status, error; - double-click a row to open that run's artifact folder.""" - - def __init__(self, task: dict, parent=None): - super().__init__(parent) - self._task = task - self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}") - self.resize(620, 380) - root = QVBoxLayout(self) - hint = QLabel(tr("schedtask.hist_hint")) - hint.setObjectName("hint") - root.addWidget(hint) - - runs = list(reversed(task.get("runs", []) or [])) - self.table = QTableWidget(len(runs), 4) - self.table.setHorizontalHeaderLabels([ - tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"), - tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"), - ]) - self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) - self.table.setSelectionBehavior(QAbstractItemView.SelectRows) - for row, run in enumerate(runs): - ok = run.get("status") == "success" - cells = ( - run.get("finished_at", ""), - str(run.get("status", "")), - run.get("run_id", ""), - (run.get("error") or "")[:200], - ) - for col, text in enumerate(cells): - item = QTableWidgetItem(str(text)) - if col == 0: - item.setData(Qt.UserRole, run.get("run_id", "")) - self.table.setItem(row, col, item) - self.table.resizeColumnsToContents() - self.table.horizontalHeader().setStretchLastSection(True) - self.table.itemDoubleClicked.connect(self._open_artifact) - root.addWidget(self.table, 1) - - buttons = QDialogButtonBox(QDialogButtonBox.Close) - buttons.rejected.connect(self.reject) - buttons.accepted.connect(self.accept) - root.addWidget(buttons) - - def _open_artifact(self, item: QTableWidgetItem) -> None: - first = self.table.item(item.row(), 0) - run_id = first.data(Qt.UserRole) if first else "" - if not run_id: - return - folder = taskrepo.ARTIFACTS_DIR / self._task["task_id"] / run_id - if folder.exists(): - open_path(str(folder)) - - -class _DropZone(QLabel): - """Drag-an-.xlsx-here area for the Import tab.""" - - file_dropped = Signal(str) - - def __init__(self): - super().__init__() - self.setAlignment(Qt.AlignCenter) - self.setMinimumHeight(70) - self.setStyleSheet( - "QLabel { border: 2px dashed rgba(140,146,152,0.6); border-radius: 10px;" - " color: #8c9298; padding: 10px; }") - self.setAcceptDrops(True) - - def dragEnterEvent(self, event): # noqa: N802 - urls = event.mimeData().urls() - if urls and urls[0].toLocalFile().lower().endswith( - (".xlsx", ".xlsm", ".xls", ".csv", ".json")): - event.acceptProposedAction() - - def dropEvent(self, event): # noqa: N802 - urls = event.mimeData().urls() - if urls: - self.file_dropped.emit(urls[0].toLocalFile()) - - -class _AiCreateDialog(QDialog): - """Create tasks two ways, one tab each (both preview first — nothing is - saved until the user confirms): ✨ AI gen from a natural-language - description, or 📥 Import from a filled Excel template (pick or drag).""" - - def __init__(self, ctx: AppContext, parent=None): - super().__init__(parent) - from PySide6.QtWidgets import QTabWidget - - self.ctx = ctx - self.created_tasks: List[dict] = [] - self._planned: List[dict] = [] - self._worker: Optional[AgentWorker] = None - self.setWindowTitle(tr("schedtask.ai_btn")) - self.resize(600, 520) - - root = QVBoxLayout(self) - ws_row = QHBoxLayout() - ws_row.addWidget(QLabel(tr("schedtask.f_workspace"))) - self.workspace_combo = QComboBox() - self.workspace_combo.addItem(tr("schedtask.no_workspace"), "") - for p in list_projects(): - self.workspace_combo.addItem(p.name, p.project_id) - self.workspace_combo.setToolTip(tr("schedtask.hint_workspace")) - ws_row.addWidget(self.workspace_combo, 1) - root.addLayout(ws_row) - self.tabs = QTabWidget() - root.addWidget(self.tabs, 1) - - # ---- tab 1: AI gen ------------------------------------------------ - ai_page = QWidget() - al = QVBoxLayout(ai_page) - al.addWidget(QLabel(tr("schedtask.ai_desc_label"))) - self.desc_edit = QPlainTextEdit() - self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph")) - self.desc_edit.setMaximumHeight(110) - al.addWidget(self.desc_edit) - # Attachments (files + links) — merged into every task this generates, - # AND into the planning prompt so the AI knows they exist. - attach_row = QHBoxLayout() - self.ai_files_edit = QLineEdit() - self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder")) - ai_pick_btn = QPushButton(tr("schedtask.pick_files")) - ai_pick_btn.setIcon(icon("folder")) - ai_pick_btn.clicked.connect(self._ai_pick_files) - attach_row.addWidget(self.ai_files_edit, 1) - attach_row.addWidget(ai_pick_btn) - al.addWidget(QLabel(tr("schedtask.f_files"))) - al.addLayout(attach_row) - self.ai_links_edit = QLineEdit() - self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder")) - al.addWidget(QLabel(tr("schedtask.f_links"))) - al.addWidget(self.ai_links_edit) - self.gen_btn = QPushButton(tr("schedtask.ai_generate")) - self.gen_btn.setIcon(icon("sparkle")) - self.gen_btn.setObjectName("primary") - self.gen_btn.clicked.connect(self._generate) - al.addWidget(self.gen_btn) - al.addWidget(QLabel(tr("schedtask.ai_preview_label"))) - self.preview = QPlainTextEdit() - self.preview.setReadOnly(True) - al.addWidget(self.preview, 1) - self.tabs.addTab(ai_page, tr("schedtask.tab_ai")) - - # ---- tab 2: Import from Excel -------------------------------------- - imp_page = QWidget() - il = QVBoxLayout(imp_page) - tpl_btn = QPushButton(tr("schedtask.export_template_btn")) - tpl_btn.setIcon(icon("upload")) - tpl_btn.clicked.connect(self._export_template) - il.addWidget(tpl_btn) - pick_row = QHBoxLayout() - pick_btn = QPushButton(tr("schedtask.import_pick_btn")) - pick_btn.setIcon(icon("folder")) - pick_btn.clicked.connect(self._pick_import_file) - pick_row.addWidget(pick_btn) - pick_row.addStretch(1) - il.addLayout(pick_row) - self.drop_zone = _DropZone() - self.drop_zone.setText(tr("schedtask.drop_hint")) - self.drop_zone.file_dropped.connect(self._load_import_file) - il.addWidget(self.drop_zone) - il.addWidget(QLabel(tr("schedtask.ai_preview_label"))) - self.import_preview = QPlainTextEdit() - self.import_preview.setReadOnly(True) - il.addWidget(self.import_preview, 1) - self.tabs.addTab(imp_page, tr("schedtask.tab_import")) - - self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm")) - self.buttons.button(QDialogButtonBox.Ok).setEnabled(False) - self.buttons.accepted.connect(self._confirm) - self.buttons.rejected.connect(self.reject) - root.addWidget(self.buttons) - - # ---- Import tab ------------------------------------------------------ - def _export_template(self) -> None: - from PySide6.QtWidgets import QFileDialog - - from ..core.task_excel import export_template - - path, _ = QFileDialog.getSaveFileName( - self, tr("schedtask.export_template_btn"), - "cowork_tasks_template.xlsx", "Excel (*.xlsx)") - if not path: - return - try: - export_template(path) - open_path(str(Path(path).parent)) - except Exception as exc: # noqa: BLE001 - QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc)) - - def _pick_import_file(self) -> None: - from PySide6.QtWidgets import QFileDialog - - from ..core.task_import import IMPORT_FILTER - - path, _ = QFileDialog.getOpenFileName( - self, tr("schedtask.import_pick_btn"), "", IMPORT_FILTER) - if path: - self._load_import_file(path) - - def _load_import_file(self, path: str) -> None: - from ..core.task_import import import_tasks - - try: - self._planned = import_tasks(path) - except ValueError as exc: - self.import_preview.setPlainText(str(exc)) - self.buttons.button(QDialogButtonBox.Ok).setEnabled(False) - return - by_id = {t["task_id"]: t["title"] for t in self._planned} - lines = [] - for i, t in enumerate(self._planned, 1): - sched = t.get("schedule", {}) - when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule") - deps = t.get("dependency", {}).get("depends_on") or [] - dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else "" - lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n" - f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}") - self.import_preview.setPlainText("\n\n".join(lines)) - self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned)) - - def _ai_pick_files(self) -> None: - from PySide6.QtWidgets import QFileDialog - - files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files")) - if files: - existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()] - self.ai_files_edit.setText("; ".join(existing + files)) - - def _attached_files(self) -> List[str]: - return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()] - - def _attached_links(self) -> List[str]: - return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()] - - def _generate(self) -> None: - description = self.desc_edit.toPlainText().strip() - if not description or self._worker is not None: - return - files, links = self._attached_files(), self._attached_links() - self.gen_btn.setEnabled(False) - self.gen_btn.setText(tr("schedtask.ai_generating")) - - def job(worker: AgentWorker): - from ..core.ai_task_planner import plan_tasks - - provider = self.ctx.build_active_provider() - full_desc = description - if files or links: - attach_note = "; ".join(files + links) - full_desc += f"\n\n(Attached references available: {attach_note})" - planned = plan_tasks(provider, full_desc, cancel=worker.is_cancelled) - # Attachments apply to every generated task so they're available - # at RUN time too, not just visible to the planner. - for t in planned: - t["input"]["file_paths"] = list(files) - t["input"]["links"] = list(links) - return {"tasks": planned} - - w = AgentWorker(job) - w.finished_ok.connect(self._on_planned) - w.failed.connect(self._on_failed) - self._worker = w - w.start() - - def _on_planned(self, result: dict) -> None: - self._worker = None - self.gen_btn.setEnabled(True) - self.gen_btn.setText(tr("schedtask.ai_generate")) - self._planned = result.get("tasks") or [] - lines = [] - for i, t in enumerate(self._planned, 1): - sched = t.get("schedule", {}) - when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule") - dep = t.get("dependency", {}) - chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else "" - lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n" - f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n" - f" {t.get('description', '')[:150]}") - self.preview.setPlainText("\n\n".join(lines)) - self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned)) - - def _on_failed(self, err: str) -> None: - self._worker = None - self.gen_btn.setEnabled(True) - self.gen_btn.setText(tr("schedtask.ai_generate")) - self.preview.setPlainText(str(err)) - - def _confirm(self) -> None: - project_id = self.workspace_combo.currentData() or "" - for t in self._planned: - t["project_id"] = project_id - self.created_tasks = self._planned - self.accept() +"""Schedule Task tab — Kanban board for scheduled/automated tasks. + +Columns: Backlog / Scheduled / Running / Waiting Input / Done / Failed / +Paused. Cards drag between columns (dropping = changing status), double-click +edits, right-click offers Run now / Edit / Duplicate / Pause / Delete / View +logs / Create-next-from-output. Header has search, a type filter, Add Task +and AI Create Task (preview first — nothing is created until confirmed). +""" +from __future__ import annotations + +import copy +from pathlib import Path +from typing import Dict, List, Optional + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, + QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, + QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget, + QTabBar, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +from ..core import tasks as taskrepo +from ..core.projects import list_projects +from ..core.tasks import STATUSES, chain_error, duplicate_task, new_task +from ..core.worker import AgentWorker +from ..i18n import on_language_changed, tr +from ..state import AppContext +from ..theme import current_palette +from .calendar_view import CalendarView +from .icons import icon +from .osutil import open_path + +_VIEWS = ("kanban", "calendar") + +# Priority shown as a plain text tag (no colored-emoji squares). Only the +# elevated priorities get a visible marker; low/medium stay unmarked as before. +_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"} + + +class _KanbanColumn(QListWidget): + """One status lane. Accepts drops from sibling columns; a drop means + 'move this task to my status'.""" + + task_dropped = Signal(str, str) # task_id, new_status + + def __init__(self, status: str): + super().__init__() + self.status = status + self.setDragDropMode(QAbstractItemView.DragDrop) + self.setDefaultDropAction(Qt.MoveAction) + # Shift/Ctrl-click several cards in the SAME column, then right-click + # → "Delete N selected" to bulk-remove tasks instead of one at a time. + self.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.setWordWrap(True) + # Cards wrap, so there is never anything to reach by scrolling sideways + # — but QListWidget's own column hint runs 1-6px past the viewport, and + # a lane sprouted a horizontal scrollbar at 36 of 38 window widths I + # measured. Which lanes grew one changed with the width, which is why it + # looked like it depended on the screen. + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setResizeMode(QListWidget.Adjust) # re-wrap on every resize + # No pixel floor here. A fixed one is always wrong on some screen: + # 190 lost the seventh lane, 150 still wanted 1242px where a 1280 + # window leaves 1091 — so the 1280 monitor scrolled sideways and the + # 1920 one did not, same app, same build. The board divides whatever + # width it has by seven instead; see _fit_lanes(). + + def dropEvent(self, event): # noqa: N802 + source = event.source() + if isinstance(source, _KanbanColumn) and source is not self: + item = source.currentItem() + tid = item.data(Qt.UserRole) if item else None + if tid: + event.acceptProposedAction() + self.task_dropped.emit(tid, self.status) + return + event.ignore() + + +class ScheduleTaskTab(QWidget): + status_message = Signal(str) + + def __init__(self, ctx: AppContext, scheduler=None): + super().__init__() + self.ctx = ctx + self.scheduler = scheduler # TaskScheduler (may be None in tests) + self._ai_worker: Optional[AgentWorker] = None + self._tasks_dir: Optional[Path] = None # None → default repo dir + + root = QVBoxLayout(self) + + # ---- header ---------------------------------------------------- + header = QHBoxLayout() + self._title = QLabel() + self._title.setStyleSheet("font-weight:700; font-size:15px;") + self.counts_lbl = QLabel("") + self.counts_lbl.setObjectName("hint") + # A one-line summary of every lane's count. Left to size itself it + # reported a sizeHint wide enough to set the MINIMUM width of the whole + # screen — 1285px at 150% scaling, which then became the window's + # minimum and stopped the app fitting a 1280px laptop. It is a summary, + # and the same numbers are on each lane header, so it gives way first. + self.counts_lbl.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) + self.counts_lbl.setMinimumWidth(0) + self.add_btn = QPushButton() + self.add_btn.setIcon(icon("plus")) + self.add_btn.setObjectName("primary") + self.add_btn.clicked.connect(self._add_task) + self.ai_btn = QPushButton() + self.ai_btn.setIcon(icon("sparkle")) + self.ai_btn.clicked.connect(self._ai_create) + # Two views of the same tasks, so they read as a pair of tabs rather + # than a drop-list you have to open to discover the Calendar exists. + self.view_tabs = QTabBar() + self.view_tabs.setObjectName("viewTabs") + self.view_tabs.setDrawBase(False) + self.view_tabs.setExpanding(False) + for _v in _VIEWS: + self.view_tabs.addTab("") + self.view_tabs.currentChanged.connect(self._on_view_changed) + header.addWidget(self._title) + header.addWidget(self.counts_lbl, 1) + header.addWidget(self.view_tabs) + header.addWidget(self.add_btn) + header.addWidget(self.ai_btn) + root.addLayout(header) + + # ---- board / calendar (two views of the SAME tasks) ----------------- + self._view_stack = QStackedWidget() + scroll = QScrollArea() + scroll.setWidgetResizable(True) + board = QWidget() + scroll.setWidget(board) + cols = QHBoxLayout(board) + # Gutters wide enough to read as a break between lanes without eating + # too much of the seven-way split — they still share the board equally + # (see _fit_lanes below), so a wider gutter narrows every lane by the + # same share automatically; nothing else to compute here. + cols.setSpacing(2) + self.columns: Dict[str, _KanbanColumn] = {} + self.column_headers: Dict[str, QLabel] = {} + for status in STATUSES: + box = QVBoxLayout() + # The per-lane holder's own margins were the style's default + # (~9px a side) on top of the inter-column gap — with seven lanes + # that outweighs the gap itself. Zero it out and let the lane's + # header/list fill the width _fit_lanes() hands them. + box.setContentsMargins(0, 0, 0, 0) + box.setSpacing(2) + head = QLabel() + head.setStyleSheet("font-weight:600;") + col = _KanbanColumn(status) + col.setObjectName("kanbanLane") + col.task_dropped.connect(self._on_task_dropped) + col.itemDoubleClicked.connect(self._on_double_click) + col.setContextMenuPolicy(Qt.CustomContextMenu) + col.customContextMenuRequested.connect( + lambda pos, c=col: self._context_menu(c, pos)) + box.addWidget(head) + box.addWidget(col, 1) + holder = QWidget() + holder.setLayout(box) + cols.addWidget(holder) + self.columns[status] = col + self.column_headers[status] = head + self._board_scroll = scroll + self._board_gap = cols.spacing() + scroll.viewport().installEventFilter(self) + self._view_stack.addWidget(scroll) + self.calendar = CalendarView() + self.calendar.edit_task.connect(self._edit_task) + self.calendar.add_task_on_date.connect(self._add_task_on_date) + self._view_stack.addWidget(self.calendar) + root.addWidget(self._view_stack, 1) + + if self.scheduler is not None: + self.scheduler.tasks_changed.connect(self.refresh) + self.scheduler.task_started.connect(lambda _tid: self.refresh()) + self.scheduler.task_finished.connect(lambda _tid, _ok: self.refresh()) + + # Belt-and-braces: also re-read the board every 10s so a card's lane + # ALWAYS reflects reality (Scheduled → Running → Done) even if some + # change slipped past the signals (e.g. task files edited externally). + from PySide6.QtCore import QTimer + self._refresh_timer = QTimer(self) + self._refresh_timer.setInterval(10_000) + self._refresh_timer.timeout.connect(self.refresh) + self._refresh_timer.start() + + self.refresh() + on_language_changed(self._retranslate) + + # ---- i18n ------------------------------------------------------------ + def _retranslate(self) -> None: + self._title.setText(tr("schedtask.title")) + self.add_btn.setText(tr("schedtask.add_btn")) + self.add_btn.setToolTip(tr("schedtask.add_tooltip")) + self.ai_btn.setText(tr("schedtask.ai_btn")) + self.ai_btn.setToolTip(tr("schedtask.ai_tooltip")) + for i, v in enumerate(_VIEWS): + self.view_tabs.setTabText(i, tr(f"schedtask.view.{v}")) + for status, col in self.columns.items(): + col.setToolTip(tr(f"schedtask.col_tip.{status}")) + self.refresh() + + # ---- Kanban / Calendar view switch -------------------------------- + def _on_view_changed(self) -> None: + self._view_stack.setCurrentIndex(self.view_tabs.currentIndex()) + + def _add_task_on_date(self, date_str: str) -> None: + """Create a task pre-filled with the clicked calendar date (default + 09:00) — same editor Add Task opens, nothing is saved until confirmed.""" + from .task_editor_dialog import TaskEditorDialog + + t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"}) + dlg = TaskEditorDialog(t, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) + if dlg.exec() and dlg.edited_task: + self._save_and_refresh(dlg.edited_task) + self.status_message.emit(tr("schedtask.msg_created")) + + # ---- lane widths ------------------------------------------------------ + # + # The seven lanes share the board equally — that is the layout's stretch + # doing the work, so the split is a proportion of whatever width there is, + # on any monitor. The only pixel question left is how narrow a lane may get + # before scrolling sideways beats squeezing, and that is a question about + # TEXT: roughly eight characters of a task title plus its padding. Reading + # it off the font keeps it right at 125%/150% scaling and at a user's own + # font size, where a constant would not be. + _LANE_FLOOR_CH = 8 + + def eventFilter(self, obj, event): # noqa: N802 + from PySide6.QtCore import QEvent + + if obj is self._board_scroll.viewport() and event.type() == QEvent.Resize: + self._fit_lanes() + return super().eventFilter(obj, event) + + def _fit_lanes(self) -> None: + floor = self.fontMetrics().averageCharWidth() * self._LANE_FLOOR_CH + 24 + for col in self.columns.values(): + if col.minimumWidth() != floor: + col.setMinimumWidth(floor) + + # ---- board rendering --------------------------------------------------- + def _card_text(self, t: dict) -> str: + prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "") + ai = "[AI] " if t.get("is_ai_generated") else "" + sched = t.get("schedule", {}) + when = sched.get("run_at") if sched.get("enabled") else None + when_line = when or tr("schedtask.no_schedule") + chain = "" + if t.get("dependency", {}).get("next_task_id") or t.get("dependency", {}).get("previous_task_id"): + chain = " (linked)" + last = t.get("logs", {}).get("last_status") + last_line = {"success": tr("schedtask.last_success"), + "failed": tr("schedtask.last_failed")}.get(last, tr("schedtask.last_never")) + # Card shows ONLY the task's own title (plus the [AI] marker and chain + # note) — no "[Cowork]"/"[Code]" task-type tag cluttering it. + return (f"{ai}{t.get('title', '')}{chain}\n" + f"{when_line} {prio}\n{last_line}") + + def refresh(self) -> None: + all_tasks = taskrepo.list_tasks(self._tasks_dir) + counts = {s: 0 for s in STATUSES} + for col in self.columns.values(): + col.clear() + for t in all_tasks: + status = t.get("status", "backlog") + if status not in self.columns: + continue + counts[status] += 1 + item = QListWidgetItem(self._card_text(t)) + item.setData(Qt.UserRole, t["task_id"]) + self.columns[status].addItem(item) + pal = current_palette() + for status, col in self.columns.items(): + self.column_headers[status].setText( + f"{tr(f'schedtask.status.{status}')} ({counts[status]})") + # Dropping a card into Running STARTS the task for real, so that + # lane is outlined while it holds anything — the one column here + # with a side effect should not look like the other six. + if status == "running" and counts[status]: + col.setStyleSheet( + f"border: 1px solid {pal.warning}; border-radius: {pal.radius}px;") + self.column_headers[status].setStyleSheet( + f"font-weight:600; color: {pal.warning};") + else: + col.setStyleSheet("") + self.column_headers[status].setStyleSheet("font-weight:600;") + if col.count() == 0: + empty = QListWidgetItem(tr("schedtask.no_tasks")) + empty.setFlags(Qt.NoItemFlags) + col.addItem(empty) + summary = " ".join( + f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s]) + self.counts_lbl.setText(summary) + self.counts_lbl.setToolTip(summary) # full text stays reachable if clipped + self.calendar.set_tasks(all_tasks) + + # ---- actions -------------------------------------------------------- + def _save_and_refresh(self, task: dict) -> None: + taskrepo.save_task(task, self._tasks_dir) + self.refresh() + + def _add_task(self) -> None: + from .task_editor_dialog import TaskEditorDialog + + dlg = TaskEditorDialog(None, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) + if dlg.exec() and dlg.edited_task: + self._save_and_refresh(dlg.edited_task) + self.status_message.emit(tr("schedtask.msg_created")) + + def _edit_task(self, task_id: str) -> None: + from .task_editor_dialog import TaskEditorDialog + + task = taskrepo.load_task(task_id, self._tasks_dir) + if not task: + return + dlg = TaskEditorDialog(task, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) + if dlg.exec() and dlg.edited_task: + self._save_and_refresh(dlg.edited_task) + + def _on_double_click(self, item: QListWidgetItem) -> None: + tid = item.data(Qt.UserRole) + if tid: + self._edit_task(tid) + + def _on_task_dropped(self, task_id: str, new_status: str) -> None: + """Dropping a card into a lane ACTS on the task, not just relabels it: + → Running actually runs it now; → Done marks it completed; → Scheduled + puts it on the calendar (opening the editor if no time is set yet).""" + task = taskrepo.load_task(task_id, self._tasks_dir) + if not task: + return + if task.get("status") == "running": + self.refresh() # can't drag a running task + return + if new_status == "running": + # Dropping into Running = "run it now" (counts as manual approval). + self.refresh() + self._run_now(task) + return + if new_status == "done": + task["status"] = "done" + task["schedule"]["enabled"] = False # done by hand → don't re-fire + self._save_and_refresh(task) + return + task["status"] = new_status + if new_status == "scheduled" and not task["schedule"].get("enabled"): + if task["schedule"].get("run_at"): + task["schedule"]["enabled"] = True + else: + # No time set yet — a silently-disabled "Scheduled" card would + # never run and look broken. Open the editor so the user sets + # the schedule right away. + self._save_and_refresh(task) + self.status_message.emit(tr("schedtask.msg_set_schedule")) + self._edit_task(task_id) + return + self._save_and_refresh(task) + + @staticmethod + def _is_multi_selection(item, selected) -> bool: + """True when the right-clicked card is part of an existing multi-item + selection — pure boolean, kept separate from _context_menu so it's + testable without ever invoking Qt's (modal, event-loop-blocking) menu.""" + return len(selected) > 1 and item in selected + + def _context_menu(self, col: _KanbanColumn, pos) -> None: + item = col.itemAt(pos) + if item is None or not item.data(Qt.UserRole): + return + selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)] + if self._is_multi_selection(item, selected): + self._bulk_delete_menu(col, pos, selected) + return + tid = item.data(Qt.UserRole) + task = taskrepo.load_task(tid, self._tasks_dir) + if not task: + return + menu = QMenu(col) + run_act = menu.addAction(tr("schedtask.menu_run")) + edit_act = menu.addAction(tr("schedtask.menu_edit")) + dup_act = menu.addAction(tr("schedtask.menu_duplicate")) + paused = task.get("status") == "paused" + pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause")) + logs_act = menu.addAction(tr("schedtask.menu_logs")) + hist_act = menu.addAction(tr("schedtask.menu_history")) + next_act = menu.addAction(tr("schedtask.menu_create_next")) + menu.addSeparator() + del_act = menu.addAction(tr("schedtask.menu_delete")) + chosen = menu.exec(col.viewport().mapToGlobal(pos)) + if chosen == run_act: + self._run_now(task) + elif chosen == edit_act: + self._edit_task(tid) + elif chosen == dup_act: + self._save_and_refresh(duplicate_task(task)) + elif chosen == pause_act: + task["status"] = "backlog" if paused else "paused" + self._save_and_refresh(task) + elif chosen == logs_act: + self._view_logs(task) + elif chosen == hist_act: + _RunHistoryDialog(task, self).exec() + elif chosen == next_act: + self._create_next_from_output(task) + elif chosen == del_act: + if QMessageBox.question(self, tr("schedtask.menu_delete"), + tr("schedtask.delete_confirm", title=task.get("title", "")) + ) == QMessageBox.Yes: + taskrepo.delete_task(tid, self._tasks_dir) + self.refresh() + + def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None: + """Right-click on a multi-selection within one column (Shift/Ctrl-click + several cards first): one action deletes every selected task. The + popup itself is a thin wrapper — see _confirm_and_delete_selected for + the actual (independently testable) confirm+delete logic.""" + menu = QMenu(col) + del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected))) + chosen = menu.exec(col.viewport().mapToGlobal(pos)) + if chosen == del_act: + self._confirm_and_delete_selected(selected) + + def _confirm_and_delete_selected(self, selected) -> bool: + """Confirm, then delete every task in ``selected``. Split out of + _bulk_delete_menu so tests can drive it directly without having to + fake a real (modal, event-loop-blocking) QMenu popup.""" + if QMessageBox.question( + self, tr("schedtask.menu_delete"), + tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes: + return False + for item in selected: + tid = item.data(Qt.UserRole) + if tid: + taskrepo.delete_task(tid, self._tasks_dir) + self.refresh() + return True + + def _run_now(self, task: dict) -> None: + if task.get("task_type") == "manual": + self.status_message.emit(tr("schedtask.msg_manual_norun")) + return + if self.scheduler is None: + self.status_message.emit(tr("schedtask.msg_no_scheduler")) + return + if self.scheduler.run_now(task["task_id"]): + self.status_message.emit(tr("schedtask.msg_running", title=task.get("title", ""))) + self.refresh() + + def _view_logs(self, task: dict) -> None: + run_id = task.get("logs", {}).get("last_run_id") + if not run_id: + QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet")) + return + folder = taskrepo.ARTIFACTS_DIR / task["task_id"] / run_id + if folder.exists(): + open_path(str(folder)) + else: + QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet")) + + def _create_next_from_output(self, task: dict) -> None: + """Scaffold a follow-up task pre-wired to consume this task's output.""" + nxt = new_task(tr("schedtask.next_of", title=task.get("title", ""))) + nxt["task_type"] = "cowork" + nxt["input"]["mode"] = "previous_task_output" + nxt["input"]["previous_task_id"] = task["task_id"] + nxt["dependency"]["previous_task_id"] = task["task_id"] + err = chain_error(taskrepo.list_tasks(self._tasks_dir) + [nxt], + task["task_id"], nxt["task_id"]) + if err: + QMessageBox.warning(self, tr("schedtask.g_dependency"), err) + return + taskrepo.save_task(nxt, self._tasks_dir) + task["dependency"]["next_task_id"] = nxt["task_id"] + task["dependency"]["pass_output_to_next"] = True + if task["dependency"].get("run_next_mode", "none") == "none": + task["dependency"]["run_next_mode"] = "run_after_success" + taskrepo.save_task(task, self._tasks_dir) + self.refresh() + self._edit_task(nxt["task_id"]) + + # ---- AI create ---------------------------------------------------------- + def _ai_create(self) -> None: + dlg = _AiCreateDialog(self.ctx, self) + if dlg.exec() and dlg.created_tasks: + for t in dlg.created_tasks: + taskrepo.save_task(t, self._tasks_dir) + self.refresh() + self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks))) + + +class _RunHistoryDialog(QDialog): + """Run history of one task as a table (newest first): time, status, error; + double-click a row to open that run's artifact folder.""" + + def __init__(self, task: dict, parent=None): + super().__init__(parent) + self._task = task + self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}") + self.resize(620, 380) + root = QVBoxLayout(self) + hint = QLabel(tr("schedtask.hist_hint")) + hint.setObjectName("hint") + root.addWidget(hint) + + runs = list(reversed(task.get("runs", []) or [])) + self.table = QTableWidget(len(runs), 4) + self.table.setHorizontalHeaderLabels([ + tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"), + tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"), + ]) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + for row, run in enumerate(runs): + ok = run.get("status") == "success" + cells = ( + run.get("finished_at", ""), + str(run.get("status", "")), + run.get("run_id", ""), + (run.get("error") or "")[:200], + ) + for col, text in enumerate(cells): + item = QTableWidgetItem(str(text)) + if col == 0: + item.setData(Qt.UserRole, run.get("run_id", "")) + self.table.setItem(row, col, item) + self.table.resizeColumnsToContents() + self.table.horizontalHeader().setStretchLastSection(True) + self.table.itemDoubleClicked.connect(self._open_artifact) + root.addWidget(self.table, 1) + + buttons = QDialogButtonBox(QDialogButtonBox.Close) + buttons.rejected.connect(self.reject) + buttons.accepted.connect(self.accept) + root.addWidget(buttons) + + def _open_artifact(self, item: QTableWidgetItem) -> None: + first = self.table.item(item.row(), 0) + run_id = first.data(Qt.UserRole) if first else "" + if not run_id: + return + folder = taskrepo.ARTIFACTS_DIR / self._task["task_id"] / run_id + if folder.exists(): + open_path(str(folder)) + + +class _DropZone(QLabel): + """Drag-an-.xlsx-here area for the Import tab.""" + + file_dropped = Signal(str) + + def __init__(self): + super().__init__() + self.setAlignment(Qt.AlignCenter) + self.setMinimumHeight(70) + _p = current_palette() + self.setStyleSheet( + f"QLabel {{ border: 1px dashed {_p.border_strong};" + f" border-radius: {_p.radius_lg}px;" + f" color: {_p.text_muted}; padding: 10px; }}") + self.setAcceptDrops(True) + + def dragEnterEvent(self, event): # noqa: N802 + urls = event.mimeData().urls() + if urls and urls[0].toLocalFile().lower().endswith( + (".xlsx", ".xlsm", ".xls", ".csv", ".json")): + event.acceptProposedAction() + + def dropEvent(self, event): # noqa: N802 + urls = event.mimeData().urls() + if urls: + self.file_dropped.emit(urls[0].toLocalFile()) + + +class _AiCreateDialog(QDialog): + """Create tasks two ways, one tab each (both preview first — nothing is + saved until the user confirms): ✨ AI gen from a natural-language + description, or 📥 Import from a filled Excel template (pick or drag).""" + + def __init__(self, ctx: AppContext, parent=None): + super().__init__(parent) + from PySide6.QtWidgets import QTabWidget + + self.ctx = ctx + self.created_tasks: List[dict] = [] + self._planned: List[dict] = [] + self._worker: Optional[AgentWorker] = None + self.setWindowTitle(tr("schedtask.ai_btn")) + self.resize(600, 520) + + root = QVBoxLayout(self) + ws_row = QHBoxLayout() + ws_row.addWidget(QLabel(tr("schedtask.f_workspace"))) + self.workspace_combo = QComboBox() + self.workspace_combo.addItem(tr("schedtask.no_workspace"), "") + for p in list_projects(): + self.workspace_combo.addItem(p.name, p.project_id) + self.workspace_combo.setToolTip(tr("schedtask.hint_workspace")) + ws_row.addWidget(self.workspace_combo, 1) + root.addLayout(ws_row) + self.tabs = QTabWidget() + root.addWidget(self.tabs, 1) + + # ---- tab 1: AI gen ------------------------------------------------ + ai_page = QWidget() + al = QVBoxLayout(ai_page) + al.addWidget(QLabel(tr("schedtask.ai_desc_label"))) + self.desc_edit = QPlainTextEdit() + self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph")) + self.desc_edit.setMaximumHeight(110) + al.addWidget(self.desc_edit) + # Attachments (files + links) — merged into every task this generates, + # AND into the planning prompt so the AI knows they exist. + attach_row = QHBoxLayout() + self.ai_files_edit = QLineEdit() + self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder")) + ai_pick_btn = QPushButton(tr("schedtask.pick_files")) + ai_pick_btn.setIcon(icon("folder")) + ai_pick_btn.clicked.connect(self._ai_pick_files) + attach_row.addWidget(self.ai_files_edit, 1) + attach_row.addWidget(ai_pick_btn) + al.addWidget(QLabel(tr("schedtask.f_files"))) + al.addLayout(attach_row) + self.ai_links_edit = QLineEdit() + self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder")) + al.addWidget(QLabel(tr("schedtask.f_links"))) + al.addWidget(self.ai_links_edit) + self.gen_btn = QPushButton(tr("schedtask.ai_generate")) + self.gen_btn.setIcon(icon("sparkle")) + self.gen_btn.setObjectName("primary") + self.gen_btn.clicked.connect(self._generate) + al.addWidget(self.gen_btn) + al.addWidget(QLabel(tr("schedtask.ai_preview_label"))) + self.preview = QPlainTextEdit() + self.preview.setReadOnly(True) + al.addWidget(self.preview, 1) + self.tabs.addTab(ai_page, tr("schedtask.tab_ai")) + + # ---- tab 2: Import from Excel -------------------------------------- + imp_page = QWidget() + il = QVBoxLayout(imp_page) + tpl_btn = QPushButton(tr("schedtask.export_template_btn")) + tpl_btn.setIcon(icon("upload")) + tpl_btn.clicked.connect(self._export_template) + il.addWidget(tpl_btn) + pick_row = QHBoxLayout() + pick_btn = QPushButton(tr("schedtask.import_pick_btn")) + pick_btn.setIcon(icon("folder")) + pick_btn.clicked.connect(self._pick_import_file) + pick_row.addWidget(pick_btn) + pick_row.addStretch(1) + il.addLayout(pick_row) + self.drop_zone = _DropZone() + self.drop_zone.setText(tr("schedtask.drop_hint")) + self.drop_zone.file_dropped.connect(self._load_import_file) + il.addWidget(self.drop_zone) + il.addWidget(QLabel(tr("schedtask.ai_preview_label"))) + self.import_preview = QPlainTextEdit() + self.import_preview.setReadOnly(True) + il.addWidget(self.import_preview, 1) + self.tabs.addTab(imp_page, tr("schedtask.tab_import")) + + self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm")) + self.buttons.button(QDialogButtonBox.Ok).setEnabled(False) + self.buttons.accepted.connect(self._confirm) + self.buttons.rejected.connect(self.reject) + root.addWidget(self.buttons) + + # ---- Import tab ------------------------------------------------------ + def _export_template(self) -> None: + from PySide6.QtWidgets import QFileDialog + + from ..core.task_excel import export_template + + path, _ = QFileDialog.getSaveFileName( + self, tr("schedtask.export_template_btn"), + "cowork_tasks_template.xlsx", "Excel (*.xlsx)") + if not path: + return + try: + export_template(path) + open_path(str(Path(path).parent)) + except Exception as exc: # noqa: BLE001 + QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc)) + + def _pick_import_file(self) -> None: + from PySide6.QtWidgets import QFileDialog + + from ..core.task_import import IMPORT_FILTER + + path, _ = QFileDialog.getOpenFileName( + self, tr("schedtask.import_pick_btn"), "", IMPORT_FILTER) + if path: + self._load_import_file(path) + + def _load_import_file(self, path: str) -> None: + from ..core.task_import import import_tasks + + try: + self._planned = import_tasks(path) + except ValueError as exc: + self.import_preview.setPlainText(str(exc)) + self.buttons.button(QDialogButtonBox.Ok).setEnabled(False) + return + by_id = {t["task_id"]: t["title"] for t in self._planned} + lines = [] + for i, t in enumerate(self._planned, 1): + sched = t.get("schedule", {}) + when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule") + deps = t.get("dependency", {}).get("depends_on") or [] + dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else "" + lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n" + f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}") + self.import_preview.setPlainText("\n\n".join(lines)) + self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned)) + + def _ai_pick_files(self) -> None: + from PySide6.QtWidgets import QFileDialog + + files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files")) + if files: + existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()] + self.ai_files_edit.setText("; ".join(existing + files)) + + def _attached_files(self) -> List[str]: + return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()] + + def _attached_links(self) -> List[str]: + return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()] + + def _generate(self) -> None: + description = self.desc_edit.toPlainText().strip() + if not description or self._worker is not None: + return + files, links = self._attached_files(), self._attached_links() + self.gen_btn.setEnabled(False) + self.gen_btn.setText(tr("schedtask.ai_generating")) + + def job(worker: AgentWorker): + from ..core.ai_task_planner import plan_tasks + + provider = self.ctx.build_active_provider() + full_desc = description + if files or links: + attach_note = "; ".join(files + links) + full_desc += f"\n\n(Attached references available: {attach_note})" + planned = plan_tasks(provider, full_desc, cancel=worker.is_cancelled) + # Attachments apply to every generated task so they're available + # at RUN time too, not just visible to the planner. + for t in planned: + t["input"]["file_paths"] = list(files) + t["input"]["links"] = list(links) + return {"tasks": planned} + + w = AgentWorker(job) + w.finished_ok.connect(self._on_planned) + w.failed.connect(self._on_failed) + self._worker = w + w.start() + + def _on_planned(self, result: dict) -> None: + self._worker = None + self.gen_btn.setEnabled(True) + self.gen_btn.setText(tr("schedtask.ai_generate")) + self._planned = result.get("tasks") or [] + lines = [] + for i, t in enumerate(self._planned, 1): + sched = t.get("schedule", {}) + when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule") + dep = t.get("dependency", {}) + chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else "" + lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n" + f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n" + f" {t.get('description', '')[:150]}") + self.preview.setPlainText("\n\n".join(lines)) + self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned)) + + def _on_failed(self, err: str) -> None: + self._worker = None + self.gen_btn.setEnabled(True) + self.gen_btn.setText(tr("schedtask.ai_generate")) + self.preview.setPlainText(str(err)) + + def _confirm(self) -> None: + project_id = self.workspace_combo.currentData() or "" + for t in self._planned: + t["project_id"] = project_id + self.created_tasks = self._planned + self.accept() diff --git a/ui/settings_dialog.py b/ui/settings_dialog.py index 1c1abd0..1057a14 100644 --- a/ui/settings_dialog.py +++ b/ui/settings_dialog.py @@ -1,649 +1,728 @@ -"""Settings dialog: AI provider, Sandbox, the unified Connectors (MCP) group -(CAD / CAE / MS365 / Other — MCP servers + REST connectors in one place), -and the merged "Parameter" group (Cowork / Attachments / GraphRAG caps).""" -from __future__ import annotations - -from typing import Dict - -from PySide6.QtCore import Qt -from PySide6.QtGui import QGuiApplication -from PySide6.QtWidgets import ( - QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, - QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, - QMessageBox, QPushButton, QScrollArea, QSpinBox, QTreeWidget, QTreeWidgetItem, - QVBoxLayout, QWidget, -) - -from ..config import PROVIDER_LABELS -from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES -from ..core.worker import AgentWorker -from ..i18n import LANGUAGES, tr -from ..state import AppContext -from .icons import icon, IconLabel -from .ext_connector_dialog import ExtConnectorEditDialog - - -class SettingsDialog(QDialog): - def __init__(self, ctx, parent=None): - super().__init__() - self.ctx = ctx - self.setWindowTitle(tr("settings.title")) - self.setMinimumWidth(560) - self.setWindowFlags( - self.windowFlags() - | Qt.WindowMinimizeButtonHint - | Qt.WindowMaximizeButtonHint - ) - self.setSizeGripEnabled(True) - self.setStyleSheet( - "QGroupBox { background: transparent;" - " border: 1px solid rgba(140,146,152,0.35); }") - data = ctx.config.data - - outer = QVBoxLayout(self) - scroll = QScrollArea() - scroll.setWidgetResizable(True) - self._content = QWidget() - root = QVBoxLayout(self._content) - - # --- language + tray --- - top = QFormLayout() - self.language_combo = QComboBox() - for key, label in LANGUAGES.items(): - self.language_combo.addItem(label, key) - self._select_combo(self.language_combo, ctx.config.language) - top.addRow(tr("settings.language"), self.language_combo) - - self.tray_chk = QCheckBox(tr("settings.tray_keep")) - self.tray_chk.setChecked(bool(data.get("tray", {}).get("minimize_on_close", True))) - top.addRow("", self.tray_chk) - self.notify_chk = QCheckBox(tr("settings.tray_notify")) - self.notify_chk.setChecked(bool(data.get("tray", {}).get("notify_on_done", True))) - top.addRow("", self.notify_chk) - root.addLayout(top) - - self._load_workers = [] - - # --- AI Provider --- - self._prov_staging: Dict[str, dict] = { - key: dict(conf) for key, conf in data["providers"].items() - } - self.provider_combo = QComboBox() - for key, label in PROVIDER_LABELS.items(): - self.provider_combo.addItem(label, key) - self._select_combo(self.provider_combo, ctx.config.active_provider) - self._prov_current_key = self.provider_combo.currentData() - - conf = self._prov_staging.get(self._prov_current_key, {}) - self.prov_base = QLineEdit(conf.get("base_url", "")) - self.prov_key = self._secret(conf.get("api_key", "")) - self.prov_model = self._model_combo(conf.get("model", "")) - self.prov_status = QLabel("") - self.prov_status.setObjectName("hint") - self.prov_status.setWordWrap(True) - prov_group = self._group(tr("settings.group.provider"), [ - (tr("settings.active_provider"), self.provider_combo), - (tr("settings.base_url"), self.prov_base), - (tr("settings.api_key"), self.prov_key), - (tr("settings.model"), self._with_load(self.prov_model, self.prov_status)), - ]) - prov_group.layout().addRow("", self.prov_status) - self.provider_combo.currentIndexChanged.connect(self._on_provider_edit_changed) - root.addWidget(prov_group) - - # --- Sandbox Security Layer --- - sec = ctx.config.agent_security - self.sandbox_group = QGroupBox(tr("settings.group.sandbox")) - sbl = QVBoxLayout(self.sandbox_group) - - # --- Password protection for Sandbox Security (at top) --- - self.sandbox_pw_label = IconLabel("lock", "Sandbox Security Password") - sbl.addWidget(self.sandbox_pw_label) - - pw_row = QHBoxLayout() - self.sandbox_pw_edit = QLineEdit("") - self.sandbox_pw_edit.setPlaceholderText("Enter password to edit sandbox settings") - self.sandbox_pw_edit.setEchoMode(QLineEdit.Password) - pw_row.addWidget(self.sandbox_pw_edit, 1) - self.sandbox_unlock_btn = QPushButton("Unlock") - self.sandbox_unlock_btn.clicked.connect(self._sandbox_unlock) - pw_row.addWidget(self.sandbox_unlock_btn) - self.sandbox_locked_status = IconLabel("lock", "Locked (changes disabled)", color="#c00") - self.sandbox_locked_status.text_label().setStyleSheet("color: #c00; font-weight: bold;") - pw_row.addWidget(self.sandbox_locked_status) - sbl.addLayout(pw_row) - self._sandbox_unlocked = False # Start LOCKED — must enter password first - self._sandbox_pw = sec.get("sandbox_pw", "") - - # Separator line between pw section and sandbox settings - pw_sep = QLabel("────────────────") - sbl.addWidget(pw_sep) - - self.sandbox_confirm = QCheckBox(tr("settings.sandbox_confirm_commands")) - self.sandbox_confirm.setChecked(bool(sec.get("cowork_confirm_commands", False))) - self.sandbox_confirm.setToolTip(tr("settings.sandbox_confirm_commands_tooltip")) - sbl.addWidget(self.sandbox_confirm) - - self.sandbox_block_network = QCheckBox(tr("settings.sandbox_block_network")) - self.sandbox_block_network.setChecked(bool(sec.get("block_network", True))) - self.sandbox_block_network.setToolTip(tr("settings.sandbox_block_network_tooltip")) - sbl.addWidget(self.sandbox_block_network) - - # "Allow the agent to fetch URLs" + the live "Test Internet" self-test - # moved to Monitoring → Tools → Tool (they govern a tool capability, so - # they belong with the other tool toggles — see ToolsAdminTab). - - # --- Enable/Disable Agent Security --- - self.sec_enabled = QCheckBox("Enable Agent Security (command validation)") - self.sec_enabled.setChecked(bool(sec.get("enabled", True))) - self.sec_enabled.setToolTip("Bật/tắt toàn bộ Agent Security") - sbl.addWidget(self.sec_enabled) - - # --- AI Command Check toggle --- - self.ai_check = QCheckBox("AI check commands") - self.ai_check.setChecked(bool(sec.get("command_ai_check", False))) - self.ai_check.setToolTip("Cho AI control-agent xét lệnh trước khi chạy") - sbl.addWidget(self.ai_check) - - # Resource limits (CPU/Memory/Disk I/O) moved to the Parameter group - # below — see _param_section("settings.group.sandbox_limits"). - - # Collect all sandbox-editable widgets and lock them until unlocked - self._sandbox_widgets = [ - self.sandbox_confirm, self.sandbox_block_network, - self.ai_check, self.sec_enabled, - ] - for _w in self._sandbox_widgets: - _w.setEnabled(False) - - root.addWidget(self.sandbox_group) - - # Connectors (MCP / REST API) are managed entirely in Monitoring → Tools - # → Connector now — no connector UI in Settings. (_ms365_workers is kept - # for the dead-but-retained MS365 OAuth sign-in handlers below.) - self._ms365_workers = [] - - # --- Parameter --- - param_group = QGroupBox(tr("settings.group.parameter")) - pgl = QFormLayout(param_group) - - def _param_section(key: str) -> None: - lbl = QLabel(tr(key)) - lbl.setStyleSheet("font-weight:600; margin-top:6px;") - pgl.addRow(lbl) - - # Parallel-conversation limit removed — conversations and flows now run - # unlimited in parallel (no cap, no Settings row). - att = data.get("attachments", {}) - _param_section("settings.group.attachments") - self.attach_files = QSpinBox() - self.attach_files.setRange(1, 50) - self.attach_files.setSuffix(tr("settings.max_files_suffix")) - self.attach_files.setValue(max(1, int(att.get("max_files", 20)))) - self.attach_files.setToolTip(tr("settings.max_files_tooltip")) - self.attach_tokens = QSpinBox() - self.attach_tokens.setRange(1, 1000) - self.attach_tokens.setSingleStep(5) - self.attach_tokens.setSuffix(tr("settings.max_per_file_suffix")) - self.attach_tokens.setValue(max(1, int(att.get("max_tokens", 500000)) // 1000)) - self.attach_tokens.setToolTip(tr("settings.max_per_file_tooltip")) - pgl.addRow(tr("settings.max_files"), self.attach_files) - pgl.addRow(tr("settings.max_per_file"), self.attach_tokens) - - st = data.get("structure", {}) - _param_section("settings.group.structure") - self.struct_nodes = QSpinBox() - self.struct_nodes.setRange(0, 100000) - self.struct_nodes.setSpecialValueText(tr("settings.unlimited")) - self.struct_nodes.setSuffix(tr("settings.nodes_suffix")) - self.struct_nodes.setValue(max(0, int(st.get("max_nodes", 500)))) - self.struct_nodes.setToolTip(tr("settings.nodes_tooltip")) - self.struct_edges = QSpinBox() - self.struct_edges.setRange(0, 200000) - self.struct_edges.setSpecialValueText(tr("settings.unlimited")) - self.struct_edges.setSuffix(tr("settings.edges_suffix")) - self.struct_edges.setValue(max(0, int(st.get("max_edges", 500)))) - self.struct_edges.setToolTip(tr("settings.edges_tooltip")) - pgl.addRow(tr("settings.max_nodes"), self.struct_nodes) - pgl.addRow(tr("settings.max_edges"), self.struct_edges) - - # Sandbox resource limits (CPU / Memory / Disk I/O) — moved here from - # the Sandbox Security group; still stored under agent_security.*. - _param_section("settings.group.sandbox_limits") - self.sandbox_cpu = QSpinBox() - self.sandbox_cpu.setRange(0, 100_000) - self.sandbox_cpu.setSuffix(" %") - self.sandbox_cpu.setSpecialValueText(tr("settings.sandbox_unlimited")) - self.sandbox_cpu.setValue(int(sec.get("resource_limit_cpu_percent", 0) or 0)) - pgl.addRow(tr("settings.sandbox_cpu_label"), self.sandbox_cpu) - - self.sandbox_memory = QSpinBox() - self.sandbox_memory.setRange(0, 1_000_000) - self.sandbox_memory.setSuffix(" MB") - self.sandbox_memory.setSpecialValueText(tr("settings.sandbox_unlimited")) - self.sandbox_memory.setValue(int(sec.get("resource_limit_memory_mb", 2048) or 2048)) - pgl.addRow(tr("settings.sandbox_memory_label"), self.sandbox_memory) - - self.sandbox_disk = QSpinBox() - self.sandbox_disk.setRange(0, 1_000_000) - self.sandbox_disk.setSuffix(" MB") - self.sandbox_disk.setSpecialValueText(tr("settings.sandbox_unlimited")) - self.sandbox_disk.setValue(int(sec.get("resource_limit_disk_mb", 2048) or 2048)) - pgl.addRow(tr("settings.sandbox_disk_label"), self.sandbox_disk) - - root.addWidget(param_group) - - # ---- Auto Model Routing ------------------------------------------ - routing = self.ctx.config.routing - routing_group = QGroupBox(tr("routing.settings_group")) - rgl = QFormLayout(routing_group) - - self.routing_mode = QComboBox() - for value, key in (("off", "routing.mode_off"), ("auto", "routing.mode_auto"), - ("manual", "routing.mode_manual")): - self.routing_mode.addItem(tr(key), value) - self._select_combo(self.routing_mode, routing.get("switch_mode", "off")) - rgl.addRow(tr("routing.settings_mode"), self.routing_mode) - - self.routing_policy = QComboBox() - for value, key in (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"), - ("latency", "routing.policy_latency"), ("balanced", "routing.policy_balanced")): - self.routing_policy.addItem(tr(key), value) - self._select_combo(self.routing_policy, routing.get("policy", "balanced")) - rgl.addRow(tr("routing.settings_policy"), self.routing_policy) - - # Min score gain stored as a fraction (0..1); shown as a percentage. - self.routing_min_gain = QSpinBox() - self.routing_min_gain.setRange(0, 100) - self.routing_min_gain.setSuffix(" %") - self.routing_min_gain.setValue(int(round(float(routing.get("min_score_gain", 0.05)) * 100))) - rgl.addRow(tr("routing.settings_min_gain"), self.routing_min_gain) - - self.routing_timeout = QSpinBox() - self.routing_timeout.setRange(5, 600) - self.routing_timeout.setSuffix(" s") - self.routing_timeout.setValue(int(routing.get("confirm_timeout_sec", 60) or 60)) - rgl.addRow(tr("routing.settings_timeout"), self.routing_timeout) - - self.routing_interval = QSpinBox() - self.routing_interval.setRange(0, 720) - self.routing_interval.setSpecialValueText(tr("routing.mode_off")) # 0 = disabled - self.routing_interval.setSuffix(" h") - self.routing_interval.setValue(int(routing.get("reassess_interval_hours", 24) or 0)) - rgl.addRow(tr("routing.settings_interval"), self.routing_interval) - - self.routing_concurrency = QSpinBox() - self.routing_concurrency.setRange(1, 16) - self.routing_concurrency.setValue(int(routing.get("per_provider_concurrency", 2) or 2)) - rgl.addRow(tr("routing.settings_concurrency"), self.routing_concurrency) - - self.routing_judge = QLineEdit(routing.get("judge_model", "")) - rgl.addRow(tr("routing.settings_judge"), self.routing_judge) - - self.routing_reassess_btn = QPushButton(tr("routing.settings_reassess_now")) - self.routing_reassess_btn.clicked.connect(self._routing_reassess_now) - rgl.addRow("", self.routing_reassess_btn) - - rhint = QLabel(tr("routing.settings_hint")) - rhint.setObjectName("hint") - rhint.setWordWrap(True) - rgl.addRow(rhint) - root.addWidget(routing_group) - - note = QLabel(tr("settings.tip")) - note.setObjectName("hint") - root.addWidget(note) - - scroll.setWidget(self._content) - outer.addWidget(scroll, 1) - - buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) - buttons.accepted.connect(self._save) - buttons.rejected.connect(self.reject) - outer.addWidget(buttons) - - from .widgets import guard_wheel - guard_wheel(self) - - screen = QGuiApplication.primaryScreen() - if screen: - avail = screen.availableGeometry() - self.resize(640, min(740, avail.height() - 80)) - self.setMaximumHeight(avail.height()) - - # ---- helpers ----------------------------------------------------- - @staticmethod - def _secret(value: str) -> QLineEdit: - edit = QLineEdit(value) - edit.setEchoMode(QLineEdit.Password) - return edit - - @staticmethod - def _select_combo(combo: QComboBox, value: str) -> None: - idx = combo.findData(value) - if idx >= 0: - combo.setCurrentIndex(idx) - - @staticmethod - def _group(title: str, rows) -> QGroupBox: - box = QGroupBox(title) - form = QFormLayout(box) - for label, widget in rows: - form.addRow(label, widget) - return box - - def _routing_reassess_now(self) -> None: - """Kick off a manual model reassessment in the background.""" - try: - service = self.ctx.routing() - if service.is_reassessing(): - return - self.routing_reassess_btn.setEnabled(False) - self.routing_reassess_btn.setText(tr("routing.reassessing")) - - def _done(result) -> None: - # Re-enable from the (worker) callback; label reflects the count. - self.routing_reassess_btn.setEnabled(True) - self.routing_reassess_btn.setText( - tr("routing.reassess_done", count=len(result or {}))) - - service.reassess_background(on_done=_done) - except Exception: # noqa: BLE001 — a reassess click must never crash Settings - self.routing_reassess_btn.setEnabled(True) - self.routing_reassess_btn.setText(tr("routing.settings_reassess_now")) - - @staticmethod - def _model_combo(value: str) -> QComboBox: - combo = QComboBox() - combo.setEditable(True) - if value: - combo.addItem(value) - combo.setCurrentText(value) - return combo - - def _with_load(self, combo: QComboBox, status: QLabel) -> QWidget: - row = QWidget() - lay = QHBoxLayout(row) - lay.setContentsMargins(0, 0, 0, 0) - lay.addWidget(combo, 1) - btn = QPushButton(tr("settings.load")) - btn.setIcon(icon("download")) - btn.setToolTip(tr("settings.load_tooltip")) - btn.clicked.connect( - lambda: self._load_models(self.provider_combo.currentData(), combo, status)) - lay.addWidget(btn) - test_btn = QPushButton(tr("settings.test_connection")) - test_btn.setIcon(icon("flask")) - test_btn.setToolTip(tr("settings.test_connection_tooltip")) - test_btn.clicked.connect( - lambda: self._test_connection(self.provider_combo.currentData(), status)) - lay.addWidget(test_btn) - return row - - def _stash_provider_fields(self) -> None: - staged = self._prov_staging.setdefault(self._prov_current_key, {}) - staged.update({ - "base_url": self.prov_base.text().strip(), - "api_key": self.prov_key.text(), - "model": self.prov_model.currentText().strip(), - }) - - def _on_provider_edit_changed(self) -> None: - self._stash_provider_fields() - self._prov_current_key = self.provider_combo.currentData() - conf = self._prov_staging.get(self._prov_current_key, {}) - self.prov_base.setText(conf.get("base_url", "")) - self.prov_key.setText(conf.get("api_key", "")) - self.prov_model.clear() - if conf.get("model"): - self.prov_model.addItem(conf["model"]) - self.prov_model.setCurrentText(conf["model"]) - else: - self.prov_model.setCurrentText("") - self.prov_status.setText("") - - def _current_conf(self, provider: str) -> dict: - if provider == self._prov_current_key: - return {"base_url": self.prov_base.text().strip(), "api_key": self.prov_key.text(), - "model": self.prov_model.currentText().strip()} - conf = self._prov_staging.get(provider, {}) - return {"base_url": conf.get("base_url", ""), "api_key": conf.get("api_key", ""), - "model": conf.get("model", "")} - - # ---- MS365 zero-config sign-in ("connect like Claude") --------------- - def _refresh_ms365_status(self) -> None: - from ..core.ms365_auth import current_identity - who = current_identity(self.ctx.config) - if who: - self.ms365_status.setText(tr("settings.ms365_signed_in", who=who)) - self.ms365_signin_btn.setEnabled(False) - self.ms365_signout_btn.setEnabled(True) - else: - self.ms365_status.setText(tr("settings.ms365_signed_out")) - self.ms365_signin_btn.setEnabled(True) - self.ms365_signout_btn.setEnabled(False) - self.ms365_signin_btn.setText(tr("settings.ms365_signin_btn")) - self.ms365_signout_btn.setText(tr("settings.ms365_signout_btn")) - - def _ms365_sign_in(self) -> None: - from ..core.ms365_auth import current_identity, sign_in - self.ms365_signin_btn.setEnabled(False) - self.ms365_status.setText(tr("settings.ms365_signing_in")) - cfg = self.ctx.config - - def job(worker): - # on_code fires (worker thread) with the MSAL device-flow dict — - # marshal it to the UI thread via the worker's event signal. - return sign_in(lambda flow: worker.event.emit({"device_flow": flow}), cfg) - - def on_event(ev: dict) -> None: - if "device_flow" in ev: - self._show_ms365_device_code(ev["device_flow"]) - - def done(_result) -> None: - self._close_ms365_code_dialog() - self.ctx.save() - self._refresh_ms365_status() - QMessageBox.information( - self, tr("settings.ms365_signin_btn"), - tr("settings.ms365_signed_in", who=current_identity(cfg))) - - def failed(err: str) -> None: - self._close_ms365_code_dialog() - self._refresh_ms365_status() - QMessageBox.warning(self, tr("settings.ms365_signin_btn"), err) - - w = AgentWorker(job) - w.event.connect(on_event) - w.finished_ok.connect(done) - w.failed.connect(failed) - self._ms365_workers.append(w) - w.start() - - def _close_ms365_code_dialog(self) -> None: - dlg = getattr(self, "_ms365_code_dialog", None) - if dlg is not None: - dlg.close() - self._ms365_code_dialog = None - - def _show_ms365_device_code(self, flow: dict) -> None: - """Auto-open the sign-in page + show the one-time code in a COPYABLE, - non-modal dialog (so the worker keeps polling and can auto-close it on - success). The code is also copied to the clipboard immediately.""" - import webbrowser - - code = flow.get("user_code", "") - url = flow.get("verification_uri", "https://microsoft.com/devicelogin") - # Auto-copy the code so the user can just paste it. - QGuiApplication.clipboard().setText(code) - # Auto-open the browser to the (code-prefilled, if available) sign-in page. - try: - webbrowser.open(flow.get("verification_uri_complete") or url) - except Exception: # noqa: BLE001 — a headless box just shows the link to click - pass - - self._close_ms365_code_dialog() - dlg = QDialog(self) - dlg.setWindowTitle(tr("settings.ms365_signin_btn")) - dlg.setMinimumWidth(420) - lay = QVBoxLayout(dlg) - info = QLabel(tr("settings.ms365_code_hint", url=url)) - info.setWordWrap(True) - info.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextBrowserInteraction) - info.setOpenExternalLinks(True) - lay.addWidget(info) - - code_row = QHBoxLayout() - code_edit = QLineEdit(code) - code_edit.setReadOnly(True) - f = code_edit.font() - f.setPointSize(f.pointSize() + 4) - f.setBold(True) - code_edit.setFont(f) - code_edit.setCursorPosition(0) - copy_btn = QPushButton(tr("settings.ms365_copy_code")) - copy_btn.setIcon(icon("document")) - copy_btn.clicked.connect(lambda: QGuiApplication.clipboard().setText(code)) - open_btn = QPushButton(tr("settings.ms365_open_link")) - open_btn.setIcon(icon("link")) - open_btn.clicked.connect(lambda: webbrowser.open(flow.get("verification_uri_complete") or url)) - code_row.addWidget(code_edit, 1) - code_row.addWidget(copy_btn) - code_row.addWidget(open_btn) - lay.addLayout(code_row) - - buttons = QDialogButtonBox(QDialogButtonBox.Close) - buttons.rejected.connect(dlg.reject) - lay.addWidget(buttons) - - self._ms365_code_dialog = dlg - dlg.show() # non-modal — sign-in polling continues; done() closes it - - def _ms365_sign_out(self) -> None: - from ..core.ms365_auth import sign_out_default - sign_out_default(self.ctx.config) - self._refresh_ms365_status() - - def _load_models(self, provider: str, combo: QComboBox, status: QLabel) -> None: - conf = self._current_conf(provider) - - def job(worker): - from ..providers import build_provider - prov = build_provider(provider, conf) - models = prov.list_models() - return {"models": models, "error": getattr(prov, "last_error", "")} - - def done(result): - models = result.get("models") or [] - current = combo.currentText().strip() - combo.clear() - if current: - combo.addItem(current) - for m in models: - if m != current: - combo.addItem(m) - combo.setCurrentText(current) - error = result.get("error", "") - if models: - status.setText(tr("settings.loaded_models", n=len(models), - provider=PROVIDER_LABELS.get(provider, provider))) - else: - status.setText(tr("settings.load_models_error", err=error or - tr("settings.load_models_error_unknown"))) - - w = AgentWorker(job) - w.finished_ok.connect(done) - w.failed.connect(lambda e: status.setText(tr("settings.load_failed", err=e))) - self._load_workers.append(w) - status.setText(tr("settings.loading_models")) - w.start() - - def _test_connection(self, provider: str, status: QLabel) -> None: - conf = self._current_conf(provider) - - def job(worker): - from ..providers import build_provider - ok, message = build_provider(provider, conf).test_connection() - return {"ok": ok, "message": message} - - def done(result): - ok = result.get("ok") - status.setText(result.get("message", "")) - status.setStyleSheet("color: #090;" if ok else "color: #c00;") - - def failed(e): - status.setText(str(e)) - status.setStyleSheet("color: #c00;") - - w = AgentWorker(job) - w.finished_ok.connect(done) - w.failed.connect(failed) - self._load_workers.append(w) - status.setText(tr("settings.testing_connection")) - w.start() - - def _sandbox_unlock(self) -> None: - pw = self.sandbox_pw_edit.text() - if pw and pw == self._sandbox_pw: - self._sandbox_unlocked = True - self.sandbox_locked_status.setText("Unlocked") - self.sandbox_locked_status.set_icon("unlock", "#090") - self.sandbox_locked_status.text_label().setStyleSheet("color: #090; font-weight: bold;") - # Enable all sandbox widgets - for w in self._sandbox_widgets: - w.setEnabled(True) - QMessageBox.information(self, "Sandbox Security", "Sandbox settings unlocked.") - else: - QMessageBox.warning(self, "Wrong Password", "Password incorrect. Sandbox settings remain locked.") - - def _save(self) -> None: - data = self.ctx.config.data - data["active_provider"] = self.provider_combo.currentData() - data["language"] = self.language_combo.currentData() - - self._stash_provider_fields() - for key, staged in self._prov_staging.items(): - data["providers"].setdefault(key, {}).update({ - "base_url": staged.get("base_url", ""), - "api_key": staged.get("api_key", ""), - "model": staged.get("model", ""), - }) - - # NOTE: allow_url_fetch is managed in Monitoring → Tools → Tool now - # (persisted there directly), so it is intentionally not written here. - data.setdefault("agent_security", {}).update({ - "enabled": self.sec_enabled.isChecked(), - "cowork_confirm_commands": self.sandbox_confirm.isChecked(), - "block_network": self.sandbox_block_network.isChecked(), - "command_ai_check": self.ai_check.isChecked(), - "command_whitelist": [], - "resource_limit_cpu_percent": self.sandbox_cpu.value(), - "resource_limit_memory_mb": self.sandbox_memory.value(), - "resource_limit_disk_mb": self.sandbox_disk.value(), - }) - att = data.setdefault("attachments", {}) - att["max_tokens"] = self.attach_tokens.value() * 1000 - att["max_files"] = self.attach_files.value() - st = data.setdefault("structure", {}) - st["max_nodes"] = self.struct_nodes.value() - st["max_edges"] = self.struct_edges.value() - tray = data.setdefault("tray", {}) - tray["minimize_on_close"] = self.tray_chk.isChecked() - tray["notify_on_done"] = self.notify_chk.isChecked() - - r = data.setdefault("routing", {}) - r["switch_mode"] = self.routing_mode.currentData() - r["policy"] = self.routing_policy.currentData() - r["min_score_gain"] = self.routing_min_gain.value() / 100.0 - r["confirm_timeout_sec"] = self.routing_timeout.value() - r["reassess_interval_hours"] = self.routing_interval.value() - r["per_provider_concurrency"] = self.routing_concurrency.value() - r["judge_model"] = self.routing_judge.text().strip() - - self.ctx.save() - - # Force-reload config so all parts of the app pick up the new settings immediately - self.ctx.config._data = None # invalidate cache - self.ctx.config._agent_security = None - - self.accept() +"""Settings dialog: AI provider, Sandbox, the unified Connectors (MCP) group +(CAD / CAE / MS365 / Other — MCP servers + REST connectors in one place), +and the merged "Parameter" group (Cowork / Attachments / GraphRAG caps).""" +from __future__ import annotations + +from typing import Dict + +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication +from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, + QMessageBox, QPushButton, QScrollArea, QSizePolicy, QSpinBox, QTreeWidget, + QTreeWidgetItem, QVBoxLayout, QWidget, +) + +from ..config import PROVIDER_LABELS +from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES +from ..core.worker import AgentWorker +from ..i18n import LANGUAGES, tr +from ..state import AppContext +from .icons import icon, IconLabel +from .widgets import SegmentedControl, ToggleSwitch +from .ext_connector_dialog import ExtConnectorEditDialog + + +class SettingsDialog(QDialog): + def __init__(self, ctx, parent=None): + super().__init__() + self.ctx = ctx + self.setWindowTitle(tr("settings.title")) + self.setMinimumWidth(560) + self.setWindowFlags( + self.windowFlags() + | Qt.WindowMinimizeButtonHint + | Qt.WindowMaximizeButtonHint + ) + self.setSizeGripEnabled(True) + # Group boxes are styled app-wide (see theme._TEMPLATE); this dialog + # used to re-declare them and drifted out of sync with the rest. + data = ctx.config.data + + outer = QVBoxLayout(self) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + # Never sideways: the content must fit the width it is given and scroll + # only downwards. Anything too wide has to shrink (see _model_combo and + # _with_load), not push a second scrollbar onto the user. + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._content = QWidget() + root = QVBoxLayout(self._content) + + # --- language + tray --- + top = QFormLayout() + self.language_combo = SegmentedControl() + for key, label in LANGUAGES.items(): + self.language_combo.addItem(label, key) + self._select_combo(self.language_combo, ctx.config.language) + top.addRow(tr("settings.language"), self.language_combo) + + # Theme belongs with the other per-account settings. It is also on the + # rail's account row (one click for the common flip); this is the same + # value, named and explained, for people who come looking in Settings. + self.theme_combo = SegmentedControl() + for key in ("system", "dark", "light"): + self.theme_combo.addItem(tr(f"settings.theme_{key}"), key) + self._select_combo(self.theme_combo, getattr(ctx.config, "theme", "system")) + top.addRow(tr("settings.theme"), self.theme_combo) + + self.tray_chk = ToggleSwitch(tr("settings.tray_keep")) + self.tray_chk.setChecked(bool(data.get("tray", {}).get("minimize_on_close", True))) + top.addRow("", self.tray_chk) + self.notify_chk = ToggleSwitch(tr("settings.tray_notify")) + self.notify_chk.setChecked(bool(data.get("tray", {}).get("notify_on_done", True))) + top.addRow("", self.notify_chk) + # Zero-height anchor so the index can scroll to this section, which is a + # bare form rather than a group box. + self._anchor_general = QWidget() + self._anchor_general.setFixedHeight(0) + root.addWidget(self._anchor_general) + root.addLayout(top) + + self._load_workers = [] + + # --- AI Provider --- + self._prov_staging: Dict[str, dict] = { + key: dict(conf) for key, conf in data["providers"].items() + } + self.provider_combo = QComboBox() + for key, label in PROVIDER_LABELS.items(): + self.provider_combo.addItem(label, key) + self._select_combo(self.provider_combo, ctx.config.active_provider) + self._prov_current_key = self.provider_combo.currentData() + + conf = self._prov_staging.get(self._prov_current_key, {}) + self.prov_base = QLineEdit(conf.get("base_url", "")) + self.prov_key = self._secret(conf.get("api_key", "")) + self.prov_model = self._model_combo(conf.get("model", "")) + self.prov_status = QLabel("") + self.prov_status.setObjectName("hint") + self.prov_status.setWordWrap(True) + prov_group = self._group(tr("settings.group.provider"), [ + (tr("settings.active_provider"), self.provider_combo), + (tr("settings.base_url"), self.prov_base), + (tr("settings.api_key"), self.prov_key), + (tr("settings.model"), self._with_load(self.prov_model, self.prov_status)), + ]) + prov_group.layout().addRow("", self.prov_status) + self.provider_combo.currentIndexChanged.connect(self._on_provider_edit_changed) + root.addWidget(prov_group) + + # --- Sandbox Security Layer --- + sec = ctx.config.agent_security + self.sandbox_group = QGroupBox(tr("settings.group.sandbox")) + sbl = QVBoxLayout(self.sandbox_group) + + # --- Password protection for Sandbox Security (at top) --- + self.sandbox_pw_label = IconLabel("lock", "Sandbox Security Password") + sbl.addWidget(self.sandbox_pw_label) + + pw_row = QHBoxLayout() + self.sandbox_pw_edit = QLineEdit("") + self.sandbox_pw_edit.setPlaceholderText("Enter password to edit sandbox settings") + self.sandbox_pw_edit.setEchoMode(QLineEdit.Password) + pw_row.addWidget(self.sandbox_pw_edit, 1) + self.sandbox_unlock_btn = QPushButton("Unlock") + self.sandbox_unlock_btn.clicked.connect(self._sandbox_unlock) + pw_row.addWidget(self.sandbox_unlock_btn) + self.sandbox_locked_status = IconLabel("lock", "Locked (changes disabled)", color="#c00") + self.sandbox_locked_status.text_label().setStyleSheet("color: #c00; font-weight: bold;") + pw_row.addWidget(self.sandbox_locked_status) + sbl.addLayout(pw_row) + self._sandbox_unlocked = False # Start LOCKED — must enter password first + self._sandbox_pw = sec.get("sandbox_pw", "quandh14") + + # Separator line between pw section and sandbox settings + pw_sep = QLabel("────────────────") + sbl.addWidget(pw_sep) + + self.sandbox_confirm = ToggleSwitch(tr("settings.sandbox_confirm_commands")) + self.sandbox_confirm.setChecked(bool(sec.get("cowork_confirm_commands", False))) + self.sandbox_confirm.setToolTip(tr("settings.sandbox_confirm_commands_tooltip")) + sbl.addWidget(self.sandbox_confirm) + + self.sandbox_block_network = ToggleSwitch(tr("settings.sandbox_block_network")) + self.sandbox_block_network.setChecked(bool(sec.get("block_network", True))) + self.sandbox_block_network.setToolTip(tr("settings.sandbox_block_network_tooltip")) + sbl.addWidget(self.sandbox_block_network) + + # "Allow the agent to fetch URLs" + the live "Test Internet" self-test + # moved to Monitoring → Tools → Tool (they govern a tool capability, so + # they belong with the other tool toggles — see ToolsAdminTab). + + # --- Enable/Disable Agent Security --- + self.sec_enabled = ToggleSwitch("Enable Agent Security (command validation)") + self.sec_enabled.setChecked(bool(sec.get("enabled", True))) + self.sec_enabled.setToolTip("Bật/tắt toàn bộ Agent Security") + sbl.addWidget(self.sec_enabled) + + # --- AI Command Check toggle --- + self.ai_check = ToggleSwitch("AI check commands") + self.ai_check.setChecked(bool(sec.get("command_ai_check", False))) + self.ai_check.setToolTip("Cho AI control-agent xét lệnh trước khi chạy") + sbl.addWidget(self.ai_check) + + # Resource limits (CPU/Memory/Disk I/O) moved to the Parameter group + # below — see _param_section("settings.group.sandbox_limits"). + + # Collect all sandbox-editable widgets and lock them until unlocked + self._sandbox_widgets = [ + self.sandbox_confirm, self.sandbox_block_network, + self.ai_check, self.sec_enabled, + ] + for _w in self._sandbox_widgets: + _w.setEnabled(False) + + root.addWidget(self.sandbox_group) + + # Connectors (MCP / REST API) are managed entirely in Monitoring → Tools + # → Connector now — no connector UI in Settings. (_ms365_workers is kept + # for the dead-but-retained MS365 OAuth sign-in handlers below.) + self._ms365_workers = [] + + # --- Parameter --- + param_group = QGroupBox(tr("settings.group.parameter")) + pgl = QFormLayout(param_group) + + def _param_section(key: str) -> None: + lbl = QLabel(tr(key)) + lbl.setStyleSheet("font-weight:600; margin-top:6px;") + pgl.addRow(lbl) + + # Parallel-conversation limit removed — conversations and flows now run + # unlimited in parallel (no cap, no Settings row). + att = data.get("attachments", {}) + _param_section("settings.group.attachments") + self.attach_files = QSpinBox() + self.attach_files.setRange(1, 50) + self.attach_files.setSuffix(tr("settings.max_files_suffix")) + self.attach_files.setValue(max(1, int(att.get("max_files", 20)))) + self.attach_files.setToolTip(tr("settings.max_files_tooltip")) + self.attach_tokens = QSpinBox() + self.attach_tokens.setRange(1, 1000) + self.attach_tokens.setSingleStep(5) + self.attach_tokens.setSuffix(tr("settings.max_per_file_suffix")) + self.attach_tokens.setValue(max(1, int(att.get("max_tokens", 500000)) // 1000)) + self.attach_tokens.setToolTip(tr("settings.max_per_file_tooltip")) + pgl.addRow(tr("settings.max_files"), self.attach_files) + pgl.addRow(tr("settings.max_per_file"), self.attach_tokens) + + st = data.get("structure", {}) + _param_section("settings.group.structure") + self.struct_nodes = QSpinBox() + self.struct_nodes.setRange(0, 100000) + self.struct_nodes.setSpecialValueText(tr("settings.unlimited")) + self.struct_nodes.setSuffix(tr("settings.nodes_suffix")) + self.struct_nodes.setValue(max(0, int(st.get("max_nodes", 500)))) + self.struct_nodes.setToolTip(tr("settings.nodes_tooltip")) + self.struct_edges = QSpinBox() + self.struct_edges.setRange(0, 200000) + self.struct_edges.setSpecialValueText(tr("settings.unlimited")) + self.struct_edges.setSuffix(tr("settings.edges_suffix")) + self.struct_edges.setValue(max(0, int(st.get("max_edges", 500)))) + self.struct_edges.setToolTip(tr("settings.edges_tooltip")) + pgl.addRow(tr("settings.max_nodes"), self.struct_nodes) + pgl.addRow(tr("settings.max_edges"), self.struct_edges) + + # Sandbox resource limits (CPU / Memory / Disk I/O) — moved here from + # the Sandbox Security group; still stored under agent_security.*. + _param_section("settings.group.sandbox_limits") + self.sandbox_cpu = QSpinBox() + self.sandbox_cpu.setRange(0, 100_000) + self.sandbox_cpu.setSuffix(" %") + self.sandbox_cpu.setSpecialValueText(tr("settings.sandbox_unlimited")) + self.sandbox_cpu.setValue(int(sec.get("resource_limit_cpu_percent", 0) or 0)) + pgl.addRow(tr("settings.sandbox_cpu_label"), self.sandbox_cpu) + + self.sandbox_memory = QSpinBox() + self.sandbox_memory.setRange(0, 1_000_000) + self.sandbox_memory.setSuffix(" MB") + self.sandbox_memory.setSpecialValueText(tr("settings.sandbox_unlimited")) + self.sandbox_memory.setValue(int(sec.get("resource_limit_memory_mb", 2048) or 2048)) + pgl.addRow(tr("settings.sandbox_memory_label"), self.sandbox_memory) + + self.sandbox_disk = QSpinBox() + self.sandbox_disk.setRange(0, 1_000_000) + self.sandbox_disk.setSuffix(" MB") + self.sandbox_disk.setSpecialValueText(tr("settings.sandbox_unlimited")) + self.sandbox_disk.setValue(int(sec.get("resource_limit_disk_mb", 2048) or 2048)) + pgl.addRow(tr("settings.sandbox_disk_label"), self.sandbox_disk) + + root.addWidget(param_group) + + # ---- Auto Model Routing ------------------------------------------ + routing = self.ctx.config.routing + routing_group = QGroupBox(tr("routing.settings_group")) + rgl = QFormLayout(routing_group) + + self.routing_mode = QComboBox() + for value, key in (("off", "routing.mode_off"), ("auto", "routing.mode_auto"), + ("manual", "routing.mode_manual")): + self.routing_mode.addItem(tr(key), value) + self._select_combo(self.routing_mode, routing.get("switch_mode", "off")) + rgl.addRow(tr("routing.settings_mode"), self.routing_mode) + + self.routing_policy = QComboBox() + for value, key in (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"), + ("latency", "routing.policy_latency"), ("balanced", "routing.policy_balanced")): + self.routing_policy.addItem(tr(key), value) + self._select_combo(self.routing_policy, routing.get("policy", "balanced")) + rgl.addRow(tr("routing.settings_policy"), self.routing_policy) + + # Min score gain stored as a fraction (0..1); shown as a percentage. + self.routing_min_gain = QSpinBox() + self.routing_min_gain.setRange(0, 100) + self.routing_min_gain.setSuffix(" %") + self.routing_min_gain.setValue(int(round(float(routing.get("min_score_gain", 0.05)) * 100))) + rgl.addRow(tr("routing.settings_min_gain"), self.routing_min_gain) + + self.routing_timeout = QSpinBox() + self.routing_timeout.setRange(5, 600) + self.routing_timeout.setSuffix(" s") + self.routing_timeout.setValue(int(routing.get("confirm_timeout_sec", 60) or 60)) + rgl.addRow(tr("routing.settings_timeout"), self.routing_timeout) + + self.routing_interval = QSpinBox() + self.routing_interval.setRange(0, 720) + self.routing_interval.setSpecialValueText(tr("routing.mode_off")) # 0 = disabled + self.routing_interval.setSuffix(" h") + self.routing_interval.setValue(int(routing.get("reassess_interval_hours", 24) or 0)) + rgl.addRow(tr("routing.settings_interval"), self.routing_interval) + + self.routing_concurrency = QSpinBox() + self.routing_concurrency.setRange(1, 16) + self.routing_concurrency.setValue(int(routing.get("per_provider_concurrency", 2) or 2)) + rgl.addRow(tr("routing.settings_concurrency"), self.routing_concurrency) + + self.routing_judge = QLineEdit(routing.get("judge_model", "")) + rgl.addRow(tr("routing.settings_judge"), self.routing_judge) + + self.routing_reassess_btn = QPushButton(tr("routing.settings_reassess_now")) + self.routing_reassess_btn.clicked.connect(self._routing_reassess_now) + rgl.addRow("", self.routing_reassess_btn) + + rhint = QLabel(tr("routing.settings_hint")) + rhint.setObjectName("hint") + rhint.setWordWrap(True) + rgl.addRow(rhint) + root.addWidget(routing_group) + + note = QLabel(tr("settings.tip")) + note.setObjectName("hint") + note.setWordWrap(True) # otherwise this one line sets the dialog's width + root.addWidget(note) + + # Left list + right panel: one group on screen at a time, the way the + # audit page's mock-up shows it. The five rows are the five real group + # boxes, so "which group am I in, how many left" is answerable at a + # glance instead of by scrolling to find out. + from .widgets import section_panels + + self._general_box = QWidget() + gv = QVBoxLayout(self._general_box) + gv.setContentsMargins(0, 0, 0, 0) + root.removeWidget(self._anchor_general) + root.removeItem(top) + gv.addLayout(top) + gv.addWidget(note) # the tip belongs with the general settings + gv.addStretch(1) + root.removeWidget(note) + + pages = [] + for label, widget in ((tr("settings.group.general"), self._general_box), + (tr("settings.group.provider"), prov_group), + (tr("settings.group.sandbox"), self.sandbox_group), + (tr("settings.group.parameter"), param_group), + (tr("routing.settings_group"), routing_group)): + root.removeWidget(widget) + page = QWidget() + pv = QVBoxLayout(page) + pv.setContentsMargins(4, 4, 4, 4) + pv.addWidget(widget) + pv.addStretch(1) + wrap = QScrollArea() + wrap.setWidgetResizable(True) + wrap.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + wrap.setWidget(page) + pages.append((label, wrap)) + self.section_list, self.section_stack = section_panels(pages) + scroll.setParent(None) + body = QHBoxLayout() + body.setSpacing(10) + body.addWidget(self.section_list) + body.addWidget(self.section_stack, 1) + outer.addLayout(body, 1) + self._content = self._general_box # kept for other callers + # Floor the dialog at the width its WIDEST page needs, at the current + # font. On a 125%/150% display everything is wider, and without this the + # form was simply cut off instead of the window refusing to get smaller. + widest = max(w.widget().sizeHint().width() for _lab, w in pages) + self.setMinimumWidth(self.section_list.width() + widest + 60) + + buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) + buttons.accepted.connect(self._save) + buttons.rejected.connect(self.reject) + outer.addWidget(buttons) + + from .widgets import guard_wheel + guard_wheel(self) + + screen = QGuiApplication.primaryScreen() + if screen: + avail = screen.availableGeometry() + self.resize(640, min(740, avail.height() - 80)) + self.setMaximumHeight(avail.height()) + + # ---- helpers ----------------------------------------------------- + @staticmethod + def _secret(value: str) -> QLineEdit: + edit = QLineEdit(value) + edit.setEchoMode(QLineEdit.Password) + return edit + + @staticmethod + def _select_combo(combo: QComboBox, value: str) -> None: + idx = combo.findData(value) + if idx >= 0: + combo.setCurrentIndex(idx) + + @staticmethod + def _group(title: str, rows) -> QGroupBox: + box = QGroupBox(title) + form = QFormLayout(box) + for label, widget in rows: + form.addRow(label, widget) + return box + + def _routing_reassess_now(self) -> None: + """Kick off a manual model reassessment in the background.""" + try: + service = self.ctx.routing() + if service.is_reassessing(): + return + self.routing_reassess_btn.setEnabled(False) + self.routing_reassess_btn.setText(tr("routing.reassessing")) + + def _done(result) -> None: + # Re-enable from the (worker) callback; label reflects the count. + self.routing_reassess_btn.setEnabled(True) + self.routing_reassess_btn.setText( + tr("routing.reassess_done", count=len(result or {}))) + + service.reassess_background(on_done=_done) + except Exception: # noqa: BLE001 — a reassess click must never crash Settings + self.routing_reassess_btn.setEnabled(True) + self.routing_reassess_btn.setText(tr("routing.settings_reassess_now")) + + @staticmethod + def _model_combo(value: str) -> QComboBox: + combo = QComboBox() + combo.setEditable(True) + # A combo sizes itself to its longest entry by default; model ids are + # long, so the row grew past the dialog and forced a sideways scrollbar + # (worse at 125%/150% display scaling). Let it shrink and use a popup + # wider than the closed box instead. + combo.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon) + combo.setMinimumContentsLength(8) + combo.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + if value: + combo.addItem(value) + combo.setCurrentText(value) + return combo + + def _with_load(self, combo: QComboBox, status: QLabel) -> QWidget: + row = QWidget() + lay = QHBoxLayout(row) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(combo, 1) + btn = QPushButton(tr("settings.load")) + btn.setIcon(icon("download")) + btn.setToolTip(tr("settings.load_tooltip")) + btn.clicked.connect( + lambda: self._load_models(self.provider_combo.currentData(), combo, status)) + lay.addWidget(btn) + test_btn = QPushButton(tr("settings.test_connection")) + test_btn.setIcon(icon("flask")) + test_btn.setToolTip(tr("settings.test_connection_tooltip")) + test_btn.clicked.connect( + lambda: self._test_connection(self.provider_combo.currentData(), status)) + lay.addWidget(test_btn) + # The two buttons keep their natural size; the combo gives way. Without + # this the row's minimum was combo + both buttons and nothing could + # shrink, so the dialog scrolled sideways instead. + for b in (btn, test_btn): + b.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + row.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) + return row + + def _stash_provider_fields(self) -> None: + staged = self._prov_staging.setdefault(self._prov_current_key, {}) + staged.update({ + "base_url": self.prov_base.text().strip(), + "api_key": self.prov_key.text(), + "model": self.prov_model.currentText().strip(), + }) + + def _on_provider_edit_changed(self) -> None: + self._stash_provider_fields() + self._prov_current_key = self.provider_combo.currentData() + conf = self._prov_staging.get(self._prov_current_key, {}) + self.prov_base.setText(conf.get("base_url", "")) + self.prov_key.setText(conf.get("api_key", "")) + self.prov_model.clear() + if conf.get("model"): + self.prov_model.addItem(conf["model"]) + self.prov_model.setCurrentText(conf["model"]) + else: + self.prov_model.setCurrentText("") + self.prov_status.setText("") + + def _current_conf(self, provider: str) -> dict: + if provider == self._prov_current_key: + return {"base_url": self.prov_base.text().strip(), "api_key": self.prov_key.text(), + "model": self.prov_model.currentText().strip()} + conf = self._prov_staging.get(provider, {}) + return {"base_url": conf.get("base_url", ""), "api_key": conf.get("api_key", ""), + "model": conf.get("model", "")} + + # ---- MS365 zero-config sign-in ("connect like Claude") --------------- + def _refresh_ms365_status(self) -> None: + from ..core.ms365_auth import current_identity + who = current_identity(self.ctx.config) + if who: + self.ms365_status.setText(tr("settings.ms365_signed_in", who=who)) + self.ms365_signin_btn.setEnabled(False) + self.ms365_signout_btn.setEnabled(True) + else: + self.ms365_status.setText(tr("settings.ms365_signed_out")) + self.ms365_signin_btn.setEnabled(True) + self.ms365_signout_btn.setEnabled(False) + self.ms365_signin_btn.setText(tr("settings.ms365_signin_btn")) + self.ms365_signout_btn.setText(tr("settings.ms365_signout_btn")) + + def _ms365_sign_in(self) -> None: + from ..core.ms365_auth import current_identity, sign_in + self.ms365_signin_btn.setEnabled(False) + self.ms365_status.setText(tr("settings.ms365_signing_in")) + cfg = self.ctx.config + + def job(worker): + # on_code fires (worker thread) with the MSAL device-flow dict — + # marshal it to the UI thread via the worker's event signal. + return sign_in(lambda flow: worker.event.emit({"device_flow": flow}), cfg) + + def on_event(ev: dict) -> None: + if "device_flow" in ev: + self._show_ms365_device_code(ev["device_flow"]) + + def done(_result) -> None: + self._close_ms365_code_dialog() + self.ctx.save() + self._refresh_ms365_status() + QMessageBox.information( + self, tr("settings.ms365_signin_btn"), + tr("settings.ms365_signed_in", who=current_identity(cfg))) + + def failed(err: str) -> None: + self._close_ms365_code_dialog() + self._refresh_ms365_status() + QMessageBox.warning(self, tr("settings.ms365_signin_btn"), err) + + w = AgentWorker(job) + w.event.connect(on_event) + w.finished_ok.connect(done) + w.failed.connect(failed) + self._ms365_workers.append(w) + w.start() + + def _close_ms365_code_dialog(self) -> None: + dlg = getattr(self, "_ms365_code_dialog", None) + if dlg is not None: + dlg.close() + self._ms365_code_dialog = None + + def _show_ms365_device_code(self, flow: dict) -> None: + """Auto-open the sign-in page + show the one-time code in a COPYABLE, + non-modal dialog (so the worker keeps polling and can auto-close it on + success). The code is also copied to the clipboard immediately.""" + import webbrowser + + code = flow.get("user_code", "") + url = flow.get("verification_uri", "https://microsoft.com/devicelogin") + # Auto-copy the code so the user can just paste it. + QGuiApplication.clipboard().setText(code) + # Auto-open the browser to the (code-prefilled, if available) sign-in page. + try: + webbrowser.open(flow.get("verification_uri_complete") or url) + except Exception: # noqa: BLE001 — a headless box just shows the link to click + pass + + self._close_ms365_code_dialog() + dlg = QDialog(self) + dlg.setWindowTitle(tr("settings.ms365_signin_btn")) + dlg.setMinimumWidth(420) + lay = QVBoxLayout(dlg) + info = QLabel(tr("settings.ms365_code_hint", url=url)) + info.setWordWrap(True) + info.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextBrowserInteraction) + info.setOpenExternalLinks(True) + lay.addWidget(info) + + code_row = QHBoxLayout() + code_edit = QLineEdit(code) + code_edit.setReadOnly(True) + f = code_edit.font() + f.setPointSize(f.pointSize() + 4) + f.setBold(True) + code_edit.setFont(f) + code_edit.setCursorPosition(0) + copy_btn = QPushButton(tr("settings.ms365_copy_code")) + copy_btn.setIcon(icon("document")) + copy_btn.clicked.connect(lambda: QGuiApplication.clipboard().setText(code)) + open_btn = QPushButton(tr("settings.ms365_open_link")) + open_btn.setIcon(icon("link")) + open_btn.clicked.connect(lambda: webbrowser.open(flow.get("verification_uri_complete") or url)) + code_row.addWidget(code_edit, 1) + code_row.addWidget(copy_btn) + code_row.addWidget(open_btn) + lay.addLayout(code_row) + + buttons = QDialogButtonBox(QDialogButtonBox.Close) + buttons.rejected.connect(dlg.reject) + lay.addWidget(buttons) + + self._ms365_code_dialog = dlg + dlg.show() # non-modal — sign-in polling continues; done() closes it + + def _ms365_sign_out(self) -> None: + from ..core.ms365_auth import sign_out_default + sign_out_default(self.ctx.config) + self._refresh_ms365_status() + + def _load_models(self, provider: str, combo: QComboBox, status: QLabel) -> None: + conf = self._current_conf(provider) + + def job(worker): + from ..providers import build_provider + prov = build_provider(provider, conf) + models = prov.list_models() + return {"models": models, "error": getattr(prov, "last_error", "")} + + def done(result): + models = result.get("models") or [] + current = combo.currentText().strip() + combo.clear() + if current: + combo.addItem(current) + for m in models: + if m != current: + combo.addItem(m) + combo.setCurrentText(current) + error = result.get("error", "") + if models: + status.setText(tr("settings.loaded_models", n=len(models), + provider=PROVIDER_LABELS.get(provider, provider))) + else: + status.setText(tr("settings.load_models_error", err=error or + tr("settings.load_models_error_unknown"))) + + w = AgentWorker(job) + w.finished_ok.connect(done) + w.failed.connect(lambda e: status.setText(tr("settings.load_failed", err=e))) + self._load_workers.append(w) + status.setText(tr("settings.loading_models")) + w.start() + + def _test_connection(self, provider: str, status: QLabel) -> None: + conf = self._current_conf(provider) + + def job(worker): + from ..providers import build_provider + ok, message = build_provider(provider, conf).test_connection() + return {"ok": ok, "message": message} + + def done(result): + ok = result.get("ok") + status.setText(result.get("message", "")) + status.setStyleSheet("color: #090;" if ok else "color: #c00;") + + def failed(e): + status.setText(str(e)) + status.setStyleSheet("color: #c00;") + + w = AgentWorker(job) + w.finished_ok.connect(done) + w.failed.connect(failed) + self._load_workers.append(w) + status.setText(tr("settings.testing_connection")) + w.start() + + def _sandbox_unlock(self) -> None: + pw = self.sandbox_pw_edit.text() + if pw == self._sandbox_pw: + self._sandbox_unlocked = True + self.sandbox_locked_status.setText("Unlocked") + self.sandbox_locked_status.set_icon("unlock", "#090") + self.sandbox_locked_status.text_label().setStyleSheet("color: #090; font-weight: bold;") + # Enable all sandbox widgets + for w in self._sandbox_widgets: + w.setEnabled(True) + QMessageBox.information(self, "Sandbox Security", "Sandbox settings unlocked.") + else: + QMessageBox.warning(self, "Wrong Password", "Password incorrect. Sandbox settings remain locked.") + + def _save(self) -> None: + data = self.ctx.config.data + data["active_provider"] = self.provider_combo.currentData() + data["language"] = self.language_combo.currentData() + # MainWindow._open_settings re-applies the theme after this returns, so + # writing the value here is enough to make it take effect. + data["theme"] = self.theme_combo.currentData() + + self._stash_provider_fields() + for key, staged in self._prov_staging.items(): + data["providers"].setdefault(key, {}).update({ + "base_url": staged.get("base_url", ""), + "api_key": staged.get("api_key", ""), + "model": staged.get("model", ""), + }) + + # NOTE: allow_url_fetch is managed in Monitoring → Tools → Tool now + # (persisted there directly), so it is intentionally not written here. + data.setdefault("agent_security", {}).update({ + "enabled": self.sec_enabled.isChecked(), + "cowork_confirm_commands": self.sandbox_confirm.isChecked(), + "block_network": self.sandbox_block_network.isChecked(), + "command_ai_check": self.ai_check.isChecked(), + "command_whitelist": [], + "resource_limit_cpu_percent": self.sandbox_cpu.value(), + "resource_limit_memory_mb": self.sandbox_memory.value(), + "resource_limit_disk_mb": self.sandbox_disk.value(), + }) + att = data.setdefault("attachments", {}) + att["max_tokens"] = self.attach_tokens.value() * 1000 + att["max_files"] = self.attach_files.value() + st = data.setdefault("structure", {}) + st["max_nodes"] = self.struct_nodes.value() + st["max_edges"] = self.struct_edges.value() + tray = data.setdefault("tray", {}) + tray["minimize_on_close"] = self.tray_chk.isChecked() + tray["notify_on_done"] = self.notify_chk.isChecked() + + r = data.setdefault("routing", {}) + r["switch_mode"] = self.routing_mode.currentData() + r["policy"] = self.routing_policy.currentData() + r["min_score_gain"] = self.routing_min_gain.value() / 100.0 + r["confirm_timeout_sec"] = self.routing_timeout.value() + r["reassess_interval_hours"] = self.routing_interval.value() + r["per_provider_concurrency"] = self.routing_concurrency.value() + r["judge_model"] = self.routing_judge.text().strip() + + self.ctx.save() + + # Force-reload config so all parts of the app pick up the new settings immediately + self.ctx.config._data = None # invalidate cache + self.ctx.config._agent_security = None + + self.accept() \ No newline at end of file diff --git a/ui/sidebar.py b/ui/sidebar.py index eabe062..4c04f72 100644 --- a/ui/sidebar.py +++ b/ui/sidebar.py @@ -164,6 +164,9 @@ class HistorySidebar(QWidget): self._refresh_btn.setToolTip(tr("sidebar.refresh_tooltip")) self.refresh() # re-render group headers / running suffix in the new language + def is_collapsed(self) -> bool: + return self._strip.isVisible() + def set_collapsed(self, collapsed: bool) -> None: """Collapse to a thin line (kept visible) or restore the full panel.""" self._content.setVisible(not collapsed) diff --git a/ui/spline_chart.py b/ui/spline_chart.py index 60e6d8e..6f0ce4a 100644 --- a/ui/spline_chart.py +++ b/ui/spline_chart.py @@ -13,7 +13,7 @@ from PySide6.QtCore import QPointF, Qt from PySide6.QtGui import QBrush, QColor, QLinearGradient, QPainter, QPainterPath, QPen from PySide6.QtWidgets import QWidget -from ..theme import ACCENT +from ..theme import current_palette def _endpoint_label_rect(point_x: float, point_y: float, text_width: float, @@ -75,17 +75,13 @@ class SplineChart(QWidget): self._refs = list(refs or []) self.update() - def _dark(self) -> bool: - from .chat_view import _app_theme - return _app_theme() == "dark" - def paintEvent(self, _e): # noqa: N802 p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) - dark = self._dark() - grid = QColor("#1A2D4A" if dark else "#C7DEEE") - text = QColor("#8FB2D4" if dark else "#5C7A94") - accent = QColor(ACCENT) + tok = current_palette() + grid = QColor(tok.chart_grid) + text = QColor(tok.chart_label) + accent = QColor(tok.accent) w, h = self.width(), self.height() pts = self._points diff --git a/ui/structure_graph_view.py b/ui/structure_graph_view.py index d361134..b195bf0 100644 --- a/ui/structure_graph_view.py +++ b/ui/structure_graph_view.py @@ -1,993 +1,1034 @@ -"""Structure (RAG) tab — knowledge graph of code / document structure. - -Primary view is the D3 knowledge-graph (WebEngine) which gently auto-rotates -when idle and opens a node's storage folder on click. If WebEngine isn't -available (e.g. the standalone .exe), a native draggable QGraphicsView is the -in-app fallback. The graph auto-updates when the Code agent produces output, -and an Agent box on the right answers questions over the graph (Graph-RAG). -""" -from __future__ import annotations - -import math -import re -import sys -from pathlib import Path - -from PySide6.QtCore import QObject, QPointF, Qt, QTimer, QUrl, Signal, Slot -from PySide6.QtGui import QBrush, QColor, QFont, QPen -from PySide6.QtWidgets import ( - QComboBox, QFileDialog, QGraphicsEllipseItem, QGraphicsLineItem, - QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, - QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, - QTextBrowser, QVBoxLayout, QWidget, -) - -def _frozen_onefile() -> bool: - """True only for a PyInstaller ONEFILE build. Onefile extracts itself to a - temp dir (sys._MEIPASS under %TEMP%), where the QtWebEngine helper process - can't run — creating a QWebEngineView hard-crashes the app (reported as - "click the graph tab → app closes"). A ONEDIR build keeps _MEIPASS as the - ``_internal`` folder right next to the exe, where WebEngine works fine, so - it keeps the full embedded D3 view.""" - if not getattr(sys, "frozen", False): - return False - meipass = getattr(sys, "_MEIPASS", "") - if not meipass: - return False - try: - return Path(meipass).resolve().parent != Path(sys.executable).resolve().parent - except OSError: # can't tell → play safe: use the native fallback - return True - - -try: # WebEngine + WebChannel are optional PySide6 add-ons - from PySide6.QtWebEngineWidgets import QWebEngineView - from PySide6.QtWebChannel import QWebChannel - _HAS_WEB = not _frozen_onefile() -except Exception: # pragma: no cover - _HAS_WEB = False - -from ..core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS -from ..core.worker import AgentWorker -from ..i18n import on_language_changed, tr -from ..state import AppContext -from .icons import collapse_right_icon, icon -from .osutil import open_folder, open_location -from .widgets import CollapseStrip - -try: - from PySide6.QtWidgets import QGraphicsItem # noqa: F401 — ensure available -except Exception: - pass - - -class _Bridge(QObject): - """Exposed to the D3 page so a Shift+click on a node can open its - storage folder/link (local path or URL — see osutil.open_location).""" - - @Slot(str) - def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name - if path: - open_location(path) - - -class _Edge(QGraphicsLineItem): - def __init__(self, a: "_Node", b: "_Node", type_: str = ""): - super().__init__() - self.a, self.b = a, b - self.type = type_ - # Colour the edge by its RELATIONSHIP type (contains/defines/method/…), - # so the graph shows what each connection MEANS — falling back to the - # source node's tint for any untyped edge. - color = QColor(EDGE_KIND_COLORS.get(type_, "")) if type_ else QColor() - if not color.isValid(): - color = a.brush().color().lighter(130) - self._color = color - self.setPen(QPen(color, 1.4)) - self.setZValue(-1) - # A small label naming the relationship, shown at the edge midpoint. - self._label = None - if type_: - self._label = QGraphicsSimpleTextItem(type_, self) - self._label.setBrush(QBrush(color.lighter(140))) - f = QFont() - f.setPointSize(7) - self._label.setFont(f) - self._label.setZValue(0) - a.edges.append(self) - b.edges.append(self) - self.adjust() - - def adjust(self) -> None: - pa, pb = self.a.scenePos(), self.b.scenePos() - self.setLine(pa.x(), pa.y(), pb.x(), pb.y()) - if self._label is not None: - br = self._label.boundingRect() - self._label.setPos((pa.x() + pb.x()) / 2 - br.width() / 2, - (pa.y() + pb.y()) / 2 - br.height() / 2) - - -class _Node(QGraphicsEllipseItem): - def __init__(self, data, radius: int): - super().__init__(-radius, -radius, 2 * radius, 2 * radius) - self.data = data - self.edges = [] - color = QColor(NODE_KIND_COLORS.get(data.kind, "#888888")) - self.setBrush(QBrush(color)) - self.setPen(QPen(color.darker(160), 1.5)) - self.setFlags( - QGraphicsEllipseItem.ItemIsMovable - | QGraphicsEllipseItem.ItemIsSelectable - | QGraphicsEllipseItem.ItemSendsGeometryChanges - ) - self.setZValue(1) - label = QGraphicsSimpleTextItem(data.label, self) - label.setBrush(QBrush(QColor("#e6e6e6"))) - label.setPos(radius + 3, -8) - - def itemChange(self, change, value): # noqa: N802 - if change == QGraphicsEllipseItem.ItemPositionHasChanged: - for edge in self.edges: - edge.adjust() - return super().itemChange(change, value) - - -class _GraphView(QGraphicsView): - def __init__(self, scene): - super().__init__(scene) - self.setDragMode(QGraphicsView.NoDrag) - self._panning = False - self._pan_start = QPointF() - - def wheelEvent(self, e): # noqa: N802 - self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15, - 1.15 if e.angleDelta().y() > 0 else 1 / 1.15) - - def mousePressEvent(self, e): # noqa: N802 - if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None: - self._panning = True - self._pan_start = e.position() - self.setCursor(Qt.ClosedHandCursor) - e.accept() - return - super().mousePressEvent(e) - - def mouseMoveEvent(self, e): # noqa: N802 - if self._panning: - delta = e.position() - self._pan_start - self._pan_start = e.position() - self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().value() - delta.x())) - self.verticalScrollBar().setValue(int(self.verticalScrollBar().value() - delta.y())) - e.accept() - return - super().mouseMoveEvent(e) - - def mouseReleaseEvent(self, e): # noqa: N802 - if self._panning: - self._panning = False - self.setCursor(Qt.ArrowCursor) - e.accept() - return - super().mouseReleaseEvent(e) - - def mouseDoubleClickEvent(self, e): # noqa: N802 - """Double-click or Ctrl+click on a node opens its storage folder.""" - item = self.itemAt(e.pos()) - if isinstance(item, _Node) and getattr(item.data, "path", ""): - open_folder(item.data.path) - e.accept() - return - super().mouseDoubleClickEvent(e) - - -class StructureGraphView(QWidget): - status_message = Signal(str) - - def __init__(self, ctx: AppContext): - super().__init__() - self.ctx = ctx - self._worker: AgentWorker | None = None - self._node_items: list[_Node] = [] - self._edge_items: list[_Edge] = [] - self._centroid = QPointF(0, 0) - self._link = 120 - self._graph = None - self._needs_scan = False - self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite) - self._ask_worker: AgentWorker | None = None - self._answer = "" - self._detail_mode = "idle" # "answer" | "node" | "idle" — what self.detail shows - # TEMPORARY extracted file content for Q&A (real content, not just the - # graph structure). Kept only while this tab is shown — cleared on leaving - # the tab or switching project/root (see _clear_extracts / hideEvent). - self._extract_cache: dict = {} # path -> extracted text - self._extract_dir = None # temp folder for md/json dumps - self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox - - self._rescan_timer = QTimer(self) - self._rescan_timer.setSingleShot(True) - self._rescan_timer.setInterval(1500) - self._rescan_timer.timeout.connect(self._scan) - - root = QVBoxLayout(self) - - bar = QHBoxLayout() - self.path_edit = QLineEdit(str(ctx.config.cowork_output_dir())) - self.path_edit.setPlaceholderText(tr("structure.path_placeholder")) - self._pick_btn = QPushButton() - self._pick_btn.setIcon(icon("folder")) - self._pick_btn.setObjectName("primary") - self._pick_btn.clicked.connect(self._pick) - self.project_combo = QComboBox() - self.project_combo.currentIndexChanged.connect(self._on_project_changed) - self._scan_btn = QPushButton() - self._scan_btn.setIcon(icon("search")) - self._scan_btn.setObjectName("primary") - self._scan_btn.clicked.connect(self._scan) - bar.addWidget(self.path_edit, 1) - bar.addWidget(self._pick_btn) - bar.addWidget(self.project_combo) - bar.addWidget(self._scan_btn) - root.addLayout(bar) - self._refresh_project_combo() - - # Toolbar: messages toggle + export - bar2 = QHBoxLayout() - bar2.addStretch(1) - self._msgs_toggle_btn = QPushButton() - self._msgs_toggle_btn.setIcon(icon("message")) - self._msgs_toggle_btn.setToolTip(tr("structure.msgs_tooltip")) - self._msgs_toggle_btn.clicked.connect(self._toggle_messages) - bar2.addWidget(self._msgs_toggle_btn) - - self._export_btn = QPushButton() - self._export_btn.setIcon(icon("upload")) - self._export_btn.setObjectName("primary") - self._export_btn.clicked.connect(self._export) - bar2.addWidget(self._export_btn) - - root.addLayout(bar2) - - split = QSplitter(Qt.Horizontal) - self.scene = QGraphicsScene() - self.scene.setBackgroundBrush(QColor("#0D1F35")) # deep ocean dark bg - self.scene.selectionChanged.connect(self._on_selection) - self.view = _GraphView(self.scene) - - self._stack = QStackedWidget() - self._stack.addWidget(self.view) - # A "Messages" view: all conversation messages grouped BY DAY, shown as - # JSON — a plain tree switched in via setCurrentWidget (never touches the - # D3/WebEngine graph). Populated from the (project-scoped) history store. - from PySide6.QtWidgets import QTreeWidget - self._msgs_view = QTreeWidget() - self._msgs_view.setHeaderHidden(True) - self._msgs_view.itemClicked.connect(self._show_msg_json) - self._stack.addWidget(self._msgs_view) - self.web = None - self._bridge = None - self._channel = None - - # The legend + Show-relationship control live INSIDE the D3 graph - # template now (assets/graph_template.html) — the graph column is just - # the stack (native view / D3 web / messages). - split.addWidget(self._stack) - - # Right-side agent panel (GraphRAG Q&A) - right = QWidget() - rl = QVBoxLayout(right) - rl.setContentsMargins(0, 0, 0, 0) - - # Agent panel header with collapse button - ag_hdr = QHBoxLayout() - self._ag_collapse = QPushButton() - self._ag_collapse.setIcon(collapse_right_icon()) - self._ag_collapse.setFixedWidth(28) - self._ag_collapse.clicked.connect(lambda: self._set_agent_collapsed(True)) - self._ag_label = QLabel() - ag_hdr.addWidget(self._ag_collapse) - ag_hdr.addWidget(self._ag_label, 1) - rl.addLayout(ag_hdr) - - # Ask row - ask_row = QHBoxLayout() - self.ask_edit = QLineEdit() - self.ask_edit.returnPressed.connect(self._ask) - self._ask_btn = QPushButton() - self._ask_btn.setIcon(icon("chat")) - self._ask_btn.setObjectName("primary") - self._ask_btn.clicked.connect(self._ask) - ask_row.addWidget(self.ask_edit, 1) - ask_row.addWidget(self._ask_btn) - rl.addLayout(ask_row) - - # Detail browser - self.detail = QTextBrowser() - self.detail.setReadOnly(True) - self.detail.setOpenLinks(False) - self.detail.anchorClicked.connect(self._on_detail_link) - rl.addWidget(self.detail, 1) - - self._agent_panel = right - - self._agent_strip = CollapseStrip(tr("structure.expand_agent_tooltip"), expand_dir="left") - self._agent_strip.clicked.connect(lambda: self._set_agent_collapsed(False)) - self._agent_strip.setVisible(False) - self._agent_pane = QWidget() - apl = QHBoxLayout(self._agent_pane) - apl.setContentsMargins(0, 0, 0, 0) - apl.setSpacing(0) - apl.addWidget(self._agent_strip) - apl.addWidget(right, 1) - - self._split = split - split.addWidget(self._agent_pane) - split.setChildrenCollapsible(False) - split.setSizes([840, 320]) - root.addWidget(split, 1) - on_language_changed(self._retranslate) - - def _retranslate(self) -> None: - self.path_edit.setPlaceholderText(tr("structure.path_placeholder")) - self._pick_btn.setText(tr("structure.browse")) - self._scan_btn.setText(tr("structure.scan")) - self._export_btn.setText(tr("structure.export_png")) - showing = self._stack.currentWidget() is getattr(self, "_msgs_view", None) - self._msgs_toggle_btn.setText(tr("structure.graph_btn") if showing else tr("structure.msgs_btn")) - self._ag_collapse.setToolTip(tr("structure.collapse_agent_tooltip")) - self._ag_label.setText(tr("structure.agent_header")) - self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder")) - self._ask_btn.setText(tr("structure.ask")) - if self._detail_mode == "idle": - self.detail.setPlaceholderText(tr("structure.detail_placeholder")) - self._agent_strip.setToolTip(tr("structure.expand_agent_tooltip")) - self.project_combo.setToolTip(tr("structure.project_tooltip")) - self._refresh_project_combo() - - # ---- project sandbox lock ----------------------------------------- - def _refresh_project_combo(self) -> None: - from ..core.projects import list_projects - - keep = self._active_project_id - self.project_combo.blockSignals(True) - self.project_combo.clear() - self.project_combo.addItem(tr("structure.project_none"), "") - row_to_select = 0 - for i, p in enumerate(list_projects(), start=1): - self.project_combo.addItem(p.name, p.project_id) - if p.project_id == keep: - row_to_select = i - self.project_combo.setCurrentIndex(row_to_select) - self.project_combo.blockSignals(False) - - def set_project(self, project_id: str) -> None: - pid = project_id or "" - self._refresh_project_combo() - target = self.project_combo.findData(pid) - if target < 0: - target = 0 - if self.project_combo.currentIndex() == target: - self._on_project_changed(target) - else: - self.project_combo.setCurrentIndex(target) - - def _on_project_changed(self, _idx: int) -> None: - from ..core.projects import load_project - - pid = self.project_combo.currentData() or "" - project_changed = pid != self._active_project_id - if project_changed: - self._clear_extracts() # different workspace → drop temp extraction - self._active_project_id = pid - locked = bool(pid) - self.path_edit.setReadOnly(locked) - # Also disable the folder-pick button — otherwise the scan path is only - # "locked" against typing, but the picker could still repoint it outside - # the selected project's sandbox, breaking GraphRAG scope isolation. - self._pick_btn.setEnabled(not locked) - if locked: - project = load_project(pid) - if project is not None: - self.path_edit.setText(str(project.workspace_dir())) - if project_changed: - self._needs_scan = True - if self.web is not None: - self._needs_scan = False - self._scan() - - # ---- helpers ----------------------------------------------------- - def _pick(self) -> None: - chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text()) - if chosen: - self.path_edit.setText(chosen) - - def schedule_rescan(self, path: str = "") -> None: - if self._graph is None: - self._needs_scan = True - return - self._rescan_timer.start() - - # ---- Messages (by day, as JSON) -------------------------------------- - def _toggle_messages(self) -> None: - """Switch between the knowledge graph and the Messages-by-day view.""" - showing = self._stack.currentWidget() is self._msgs_view - if showing: - self._stack.setCurrentWidget(self.web if self.web is not None else self.view) - else: - self._reload_messages() - self._stack.setCurrentWidget(self._msgs_view) - self._msgs_toggle_btn.setText( - tr("structure.graph_btn") if not showing else tr("structure.msgs_btn")) - - def _reload_messages(self) -> None: - """Build the tree: day → conversation. Click a conversation to see its - messages as JSON. Scoped to the current project (its history folder).""" - from collections import OrderedDict - - from PySide6.QtCore import Qt - from PySide6.QtWidgets import QTreeWidgetItem - - from ..core.history import list_conversations - self._msgs_view.clear() - pid = self._active_project_id or "" - by_day: "OrderedDict[str, list]" = OrderedDict() - try: - convs = list_conversations(self.ctx.config.history_dir()) - except Exception: # noqa: BLE001 - convs = [] - for conv in convs: - if pid and conv.get("project_id", "default") != pid: - continue - day = (conv.get("created") or "")[:10] or "—" - by_day.setdefault(day, []).append(conv) - if not by_day: - self._msgs_view.addTopLevelItem(QTreeWidgetItem([tr("structure.msgs_none")])) - return - for day in sorted(by_day, reverse=True): - convs_d = by_day[day] - day_item = QTreeWidgetItem([f"{day} ({len(convs_d)})"]) - for conv in convs_d: - it = QTreeWidgetItem([conv.get("title", "(untitled)")]) - it.setData(0, Qt.UserRole, str(conv.get("path", ""))) - day_item.addChild(it) - self._msgs_view.addTopLevelItem(day_item) - day_item.setExpanded(True) - - def _show_msg_json(self, item, _col: int = 0) -> None: - import html - import json - - from PySide6.QtCore import Qt - - from ..core.history import load_conversation - path = item.data(0, Qt.UserRole) - if not path: - return - try: - conv = load_conversation(path) - payload = {"title": conv.get("title", ""), "created": conv.get("created", ""), - "kind": conv.get("kind", ""), "project_id": conv.get("project_id", ""), - "messages": conv.get("messages", [])} - text = json.dumps(payload, ensure_ascii=False, indent=2) - except Exception as exc: # noqa: BLE001 - text = f"(could not read: {exc})" - self.detail.setHtml( - f'
    {html.escape(text)}
    ') - - def _ensure_web(self) -> None: - if self.web is not None or not _HAS_WEB: - return - self.web = QWebEngineView() - self._bridge = _Bridge() - self._channel = QWebChannel() - self._channel.registerObject("py", self._bridge) - self.web.page().setWebChannel(self._channel) - self._stack.addWidget(self.web) - self._stack.setCurrentWidget(self.web) - if self._graph is not None: - self._render_d3() - - def auto_scan_and_fit(self) -> None: - self._ensure_web() - if not self.path_edit.text().strip(): - return - if getattr(self, "_worker", None) is not None and self._worker.isRunning(): - self._fit() - self._preserve_answer() - return - if self._graph is not None and not self._needs_scan: - self._fit() - self._preserve_answer() - return - self._needs_scan = False - self._scan() - - # ---- scan -------------------------------------------------------- - def _scan(self) -> None: - path = self.path_edit.text().strip() or str(Path.cwd()) - mode = "files" # default: scan all files (filter removed) - use_cmem = bool(self.ctx.config.codebase_memory.get("enabled")) - cmem_bin = self.ctx.config.codebase_memory.get("binary_path", "") - st = self.ctx.config.structure - max_nodes = int(st.get("max_nodes", 500) or 0) - max_edges = int(st.get("max_edges", 500) or 0) - self._scan_seq += 1 - seq = self._scan_seq - self.status_message.emit(tr("structure.scanning")) - - def job(worker: AgentWorker): - from ..core.structure_graph import ( - build_from_codebase_memory, build_from_directory, force_layout, - ) - if use_cmem: - from ..core.codebase_memory import CodebaseMemory - mem = CodebaseMemory(cmem_bin) - graph = (build_from_codebase_memory(mem, path, mode, max_nodes, max_edges) - if mem.available else build_from_directory(path, mode, max_nodes, max_edges)) - else: - graph = build_from_directory(path, mode, max_nodes, max_edges) - pos = force_layout(graph) - return {"graph": graph, "pos": pos, "seq": seq} - - w = AgentWorker(job) - w.finished_ok.connect(self._render) - w.failed.connect(lambda e: self.status_message.emit(tr("structure.scan_error", err=e))) - self._worker = w - w.start() - - def _render(self, result: dict) -> None: - if result.get("seq") is not None and result["seq"] != self._scan_seq: - return - graph = result.get("graph") - pos = result.get("pos", {}) - if graph is None: - return - self._graph = graph - - self.scene.clear() - self.scene.setBackgroundBrush(QColor("#0D1F35")) # restore deep ocean bg after clear - self._node_items = [] - self._edge_items = [] - degree = {n.id: 0 for n in graph.nodes} - for e in graph.edges: - if e.source in degree: - degree[e.source] += 1 - if e.target in degree: - degree[e.target] += 1 - items = {} - sx = sy = 0.0 - for node in graph.nodes: - radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0)))) - item = _Node(node, radius) - x, y = pos.get(node.id, (0, 0)) - item.setPos(x, y) - self.scene.addItem(item) - items[node.id] = item - self._node_items.append(item) - sx += x - sy += y - for edge in graph.edges: - a, b = items.get(edge.source), items.get(edge.target) - if a and b: - e = _Edge(a, b, getattr(edge, "type", "")) - self.scene.addItem(e) - self._edge_items.append(e) - n = max(1, len(self._node_items)) - self._centroid = QPointF(sx / n, sy / n) - self._fit() - - if self.web is not None: - self._render_d3() - - note = tr("structure.truncated_note") if getattr(graph, "truncated", False) else "" - self.status_message.emit(tr( - "structure.graph_summary", nodes=len(graph.nodes), edges=len(graph.edges), note=note)) - self._preserve_answer() - - def _render_d3(self) -> None: - if self.web is None or self._graph is None: - return - from ..core.d3_graph import build_html - try: - self.web.setHtml(build_html(self._graph), QUrl("https://cowork.local/")) - except Exception as exc: - self.status_message.emit(f"D3 view error: {exc}") - - # ---- native interactions ---------------------------------------- - def _on_selection(self) -> None: - for item in self.scene.selectedItems(): - if isinstance(item, _Node): - d = item.data - self.detail.setPlainText(f"[{d.kind.upper()}] {d.label}\n\n{d.detail}") - self._detail_mode = "node" - return - - def _preserve_answer(self) -> None: - if self._detail_mode == "answer" and self._answer.strip(): - self._render_answer() - - def _set_agent_collapsed(self, collapsed: bool) -> None: - strip_w = CollapseStrip.WIDTH + 2 - self._agent_panel.setVisible(not collapsed) - self._agent_strip.setVisible(collapsed) - if collapsed: - self._agent_pane.setMaximumWidth(strip_w) - sizes = self._split.sizes() - if len(sizes) == 2: - self._split.setSizes([max(1, sum(sizes) - strip_w), strip_w]) - else: - self._agent_pane.setMaximumWidth(16777215) - self._split.setSizes([840, 320]) - - def _fit(self) -> None: - if self.web is not None and self._stack.currentWidget() is self.web: - self.web.page().runJavaScript("window.fitGraph && window.fitGraph();") - return - rect = self.scene.itemsBoundingRect() - if not rect.isNull(): - self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio) - - def _export(self) -> None: - path, _ = QFileDialog.getSaveFileName( - self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)") - if not path: - return - showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web) - if showing_d3: - self._export_d3_png(path) - else: - self._export_widget_grab(path) - - def _export_d3_png(self, path: str) -> None: - def on_result(data_url) -> None: - if not isinstance(data_url, str) or "," not in data_url: - self._export_widget_grab(path) - return - import base64 - try: - with open(path, "wb") as f: - f.write(base64.b64decode(data_url.split(",", 1)[1])) - self.status_message.emit(tr("structure.export_done", path=path)) - except (OSError, ValueError) as exc: - self.status_message.emit(tr("structure.export_failed", err=str(exc))) - self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result) - - def _export_widget_grab(self, path: str) -> None: - ok = self._stack.currentWidget().grab().save(path, "PNG") - if ok: - self.status_message.emit(tr("structure.export_done", path=path)) - else: - self.status_message.emit(tr("structure.export_failed", err="grab() returned no image")) - - # ---- agent Q&A over the graph ----------------------------------- - @staticmethod - def _graph_context(graph) -> str: - from collections import defaultdict - by_kind = defaultdict(list) - for n in graph.nodes: - by_kind[n.kind].append(n.label) - lines = [] - for kind in ("file", "class", "function", "method", "module", "section"): - items = by_kind.get(kind, []) - if items: - lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60])) - id2label = {n.id: n.label for n in graph.nodes} - rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}" - for e in graph.edges[:140]] - if rels: - lines.append("Relationships (sample):\n" + "\n".join(rels)) - return "\n".join(lines)[:7000] - - def _matched_sources(self, text: str): - if self._graph is None or not text: - return [] - found: dict[str, tuple[str, str, str]] = {} - for n in self._graph.nodes: - if not n.path: - continue - label = n.label.rstrip("()") - if len(label) < 3: - continue - if n.path not in found and re.search(rf"\b{re.escape(label)}\b", text): - found[n.path] = (n.kind, n.label, n.detail or n.path) - return sorted(found.items(), key=lambda kv: kv[1][1].lower())[:12] - - def _linkify_files(self, text: str, sources) -> str: - """Turn file/entity NAMES mentioned in the answer into clickable links that - open the file — so the user can click a name in the answer to view it.""" - for path, (kind, label, rel) in sources: - href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded) - tokens = [] - base = Path(path).name - if base and len(base) >= 3: - tokens.append(base) - lab = (label or "").rstrip("()").strip() - if lab and lab != base and len(lab) >= 3: - tokens.append(lab) - for tok in tokens: - esc = re.escape(tok) - # `tok` (code span) → keep the code style but make it a link - text = re.sub(rf"`{esc}`", f"[`{tok}`]({href})", text) - # bare tok, not already inside a link / path / code span - text = re.sub(rf"(? None: - text = self._answer - sources = self._matched_sources(text) - if sources: - # 1) Make the file/entity names IN THE ANSWER clickable (open on click). - text = self._linkify_files(text, sources) - # 2) Append a clickable "Related sources" section listing each file. - lines = [text, "", "---", f"**{tr('structure.related_sources')}**"] - for path, (kind, label, rel) in sources: - href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded) - # kind badge for context (file/function/section/json_key) - kind_badge = f" [{kind.upper()}]" if kind not in ("file",) else "" - lines.append(f"- **[{label}⧉]({href})**{kind_badge} — `{rel}`") - text = "\n".join(lines) - self.detail.setMarkdown(text) - - def _on_detail_link(self, url: QUrl) -> None: - if url.isLocalFile(): - p = url.toLocalFile() - # Open the FILE itself for viewing (fall back to its folder for a dir). - if Path(p).is_file(): - open_location(p) - else: - open_folder(p) - - def _ask(self) -> None: - question = self.ask_edit.text().strip() - if not question: - return - from ..core.skills import parse_skill_command - skill_prefix, question, info = parse_skill_command(question) - if info is not None: - self.detail.setMarkdown(info) - self._detail_mode = "answer" - self.ask_edit.clear() - return - if self._graph is None: - self.status_message.emit(tr("structure.scan_first")) - return - context = self._graph_context(self._graph) - # Real file CONTENT to answer from (extracted temporarily in the worker): - file_paths = self._candidate_file_paths() - extract_cache = dict(self._extract_cache) - extract_dir = str(self._extract_tmp_dir()) - self._answer = "" - self._detail_mode = "answer" - self.detail.setPlainText("…") - self.ask_edit.clear() - - active_project_id = self._active_project_id - - # Collect selected node context for auto-filtering - selected_nodes = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)] - selected_context = "" - if selected_nodes: - node_lines = [] - for nd in selected_nodes: - node_lines.append(f"- {nd.label} (kind: {nd.kind}, path: {getattr(nd, 'path', '')})") - if nd.detail: - node_lines.append(f" detail: {nd.detail}") - # Also gather connected nodes - connected_ids = set() - for nd in selected_nodes: - for edge in self._graph.edges: - if edge.source == nd.id: - connected_ids.add(edge.target) - elif edge.target == nd.id: - connected_ids.add(edge.source) - connected_nodes = [n for n in self._graph.nodes if n.id in connected_ids] - if connected_nodes: - node_lines.append("\nConnected nodes:") - for cn in connected_nodes: - node_lines.append(f"- {cn.label} (kind: {cn.kind})") - selected_context = "\n".join(node_lines) - - def job(worker: AgentWorker): - provider = self.ctx.build_active_provider() - system = ("You answer questions about a code/document knowledge graph. Use the provided " - "graph context AND the extracted file contents to retrieve, synthesize and " - "explain the answer. Be concise. Answer ONLY from what is provided (graph " - "context + extracted contents) — never invent files, functions, or facts that " - "aren't in it.\n\n" - "EACH answer MUST include source citations so the user can verify where " - "information came from. For every factual claim, file reference, or code " - "element you mention, add a citation using this format:\n\n" - " [source: filename.ext, line/section: XXX]\n\n" - "Rules for citations:\n" - " 1. Cite the EXACT file path from the graph context (use the path field).\n" - " 2. For Python files: cite the function/class name and approximate line " - " if available, or the module name.\n" - " 3. For document files (.md, .txt): cite the section heading.\n" - " 4. For JSON files: cite the key path (e.g. settings > database > host).\n" - " 5. Place citations inline after the relevant sentence or fact.\n" - " 6. At the end of your answer, add a '---' separator followed by a " - " numbered **Sources cited:** section listing each unique source with " - " its full path so the user can click to open it.\n\n" - "Example citation format in text:\n" - " The `process_data()` function handles CSV parsing " - "[source: src/utils/parser.py, function: process_data].\n\n" - "Example end-of-answer source list:\n" - " ---\n" - " **Sources cited:**\n" - " 1. `src/utils/parser.py` — process_data function\n" - " 2. `docs/api.md` — Section: Authentication\n") - if skill_prefix: - system += "\n\nFollow this skill:\n" + skill_prefix - if active_project_id: - from ..core.projects import load_project, project_context_text - proj_ctx = project_context_text(load_project(active_project_id)) - if proj_ctx: - system += "\n\n" + proj_ctx - user_content = f"Graph context:\n{context}" - if selected_context: - user_content += f"\n\nSelected node(s) context (focus your answer on these):\n{selected_context}" - # Auto-extract the actual file contents (temporary) so the answer is - # synthesized from real content, not just the graph structure. - content_block, new_cache = _extract_file_contents(file_paths, extract_cache, extract_dir) - if content_block: - user_content += ("\n\nExtracted file contents (read these to answer about file " - "details/data; cite the file path):\n" + content_block) - user_content += f"\n\nQuestion: {question}" - messages = [ - {"role": "system", "content": system}, - {"role": "user", "content": user_content}, - ] - from ..core import agent_roles, audit_log - ok = True - try: - provider.chat(messages, on_text=lambda t: worker.emit_event({"type": "text", "delta": t}), - cancel=worker.is_cancelled) - except Exception: - ok = False - raise - finally: - audit_log.record("tool_call", "graphrag_ask", ok, question[:500], - agent_role=agent_roles.KNOWLEDGE) - return {"extracted": new_cache} - - w = AgentWorker(job) - w.event.connect(self._on_ask_event) - w.finished_ok.connect(self._on_ask_done) - w.failed.connect(lambda e: self.detail.setPlainText(f"Error: {e}")) - self._ask_worker = w - w.start() - - def _on_ask_event(self, ev: dict) -> None: - if ev.get("type") == "text": - if self._answer == "": - self.detail.clear() - self._answer += ev.get("delta", "") - self.detail.setPlainText(self._answer) - - def _on_ask_done(self, result: dict) -> None: - # Keep the (temporary) extracted content so repeated questions reuse it - # without re-extracting — dropped when leaving the tab (_clear_extracts). - if isinstance(result, dict): - self._extract_cache.update(result.get("extracted", {}) or {}) - self._render_answer() - - # ---- temporary file-content extraction for Q&A ------------------------ - def _candidate_file_paths(self) -> list: - """File paths to read for a question: the SELECTED file nodes if any, else - every file node in the graph (capped downstream).""" - from pathlib import Path as _P - if self._graph is None: - return [] - sel = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)] - nodes = sel or list(self._graph.nodes) - out, seen = [], set() - for nd in nodes: - p = (getattr(nd, "path", "") or "").strip() - if p and p not in seen and _P(p).is_file(): - seen.add(p) - out.append(p) - return out - - def _extract_tmp_dir(self): - from pathlib import Path as _P - if self._extract_dir is None: - import tempfile - from ..config import CONFIG_DIR - base = CONFIG_DIR / "tmp" / "graphrag_extract" - base.mkdir(parents=True, exist_ok=True) - self._extract_dir = _P(tempfile.mkdtemp(dir=str(base))) - return self._extract_dir - - def _clear_extracts(self) -> None: - """Discard the temporary extracted content (on leaving the tab / switching - project). The extraction is a scratch aid, never persisted.""" - self._extract_cache = {} - d, self._extract_dir = self._extract_dir, None - if d is not None: - import shutil - shutil.rmtree(d, ignore_errors=True) - - def hideEvent(self, e): # noqa: N802 - # Leaving the GraphRAG tab → drop the temporary extracted info. - self._clear_extracts() - super().hideEvent(e) - - - - - -# -------------------------------------------------------------------------- -# Temporary file-content extraction for Graph-RAG Q&A (runs in the ask worker) -# -------------------------------------------------------------------------- -def _pdf_to_markdown(pdf_path, out_dir) -> str | None: - """Convert a PDF to Markdown with opendataloader-pdf when available (richer - structure than a plain text dump). Best-effort — returns None if the package - isn't installed or the call fails, so the caller falls back to doc_extract.""" - from pathlib import Path as _P - try: - import opendataloader_pdf # optional; auto-installed elsewhere if present - except Exception: # noqa: BLE001 - try: - from ..core.deps import ensure_module - if ensure_module("opendataloader_pdf", "opendataloader-pdf") is None: - return None - import opendataloader_pdf # noqa: F811 - except Exception: # noqa: BLE001 - return None - out = _P(out_dir) - out.mkdir(parents=True, exist_ok=True) - for call in ( - lambda: opendataloader_pdf.convert(input_path=[str(pdf_path)], output_dir=str(out), - generate_markdown=True), - lambda: opendataloader_pdf.convert(input_path=str(pdf_path), output_dir=str(out)), - lambda: opendataloader_pdf.convert(str(pdf_path), str(out)), - ): - try: - call() - break - except TypeError: - continue - except Exception: # noqa: BLE001 - return None - mds = list(out.rglob(_P(pdf_path).stem + "*.md")) or list(out.rglob("*.md")) - for md in mds: - try: - return md.read_text(encoding="utf-8", errors="replace") - except OSError: - continue - return None - - -def _extract_file_contents(paths, cache: dict, tmp_dir, - max_files: int = 15, max_total: int = 120_000): - """Read the ACTUAL content of ``paths`` (PDF→markdown via opendataloader when - available, else doc_extract for office/pdf/text). Returns ``(block, cache)`` - — ``block`` is the concatenated content for the prompt (bounded), ``cache`` - maps path→text for reuse. Never raises.""" - from pathlib import Path as _P - from ..core import doc_extract - cache = dict(cache or {}) - parts, total = [], 0 - for p in paths[:max_files]: - if total >= max_total: - break - text = cache.get(p) - if text is None: - try: - if _P(p).suffix.lower() == ".pdf": - text = _pdf_to_markdown(p, tmp_dir) - if not text: - text, _n = doc_extract.extract_text(p) - else: - text, _n = doc_extract.extract_text(p) - except Exception: # noqa: BLE001 - text = "" - cache[p] = text or "" - text = cache.get(p) or "" - if not text: - continue - chunk = text[: max(0, max_total - total)] - total += len(chunk) - parts.append(f'--- {_P(p).name} ({p}) ---\n{chunk}') - return ("\n\n".join(parts), cache) +"""Structure (RAG) tab — knowledge graph of code / document structure. + +Primary view is the D3 knowledge-graph (WebEngine) which gently auto-rotates +when idle and opens a node's storage folder on click. If WebEngine isn't +available (e.g. the standalone .exe), a native draggable QGraphicsView is the +in-app fallback. The graph auto-updates when the Code agent produces output, +and an Agent box on the right answers questions over the graph (Graph-RAG). +""" +from __future__ import annotations + +import math +import re +import sys +from pathlib import Path + +from PySide6.QtCore import QObject, QPointF, Qt, QTimer, QUrl, Signal, Slot +from PySide6.QtGui import QBrush, QColor, QFont, QPen +from PySide6.QtWidgets import ( + QComboBox, QFileDialog, QGraphicsEllipseItem, QGraphicsLineItem, + QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, + QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, QTabBar, + QTextBrowser, QVBoxLayout, QWidget, +) + +def _frozen_onefile() -> bool: + """True only for a PyInstaller ONEFILE build. Onefile extracts itself to a + temp dir (sys._MEIPASS under %TEMP%), where the QtWebEngine helper process + can't run — creating a QWebEngineView hard-crashes the app (reported as + "click the graph tab → app closes"). A ONEDIR build keeps _MEIPASS as the + ``_internal`` folder right next to the exe, where WebEngine works fine, so + it keeps the full embedded D3 view.""" + if not getattr(sys, "frozen", False): + return False + meipass = getattr(sys, "_MEIPASS", "") + if not meipass: + return False + try: + return Path(meipass).resolve().parent != Path(sys.executable).resolve().parent + except OSError: # can't tell → play safe: use the native fallback + return True + + +try: # WebEngine + WebChannel are optional PySide6 add-ons + from PySide6.QtWebEngineWidgets import QWebEngineView + from PySide6.QtWebChannel import QWebChannel + _HAS_WEB = not _frozen_onefile() +except Exception: # pragma: no cover + _HAS_WEB = False + +from ..core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS +from ..theme import current_palette +from ..core.worker import AgentWorker +from ..i18n import on_language_changed, tr +from ..state import AppContext +from .icons import collapse_right_icon, icon +from .osutil import open_folder, open_location +from .widgets import CollapseStrip + +try: + from PySide6.QtWidgets import QGraphicsItem # noqa: F401 — ensure available +except Exception: + pass + + +class _Bridge(QObject): + """Exposed to the D3 page so a Shift+click on a node can open its + storage folder/link (local path or URL — see osutil.open_location).""" + + @Slot(str) + def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name + if path: + open_location(path) + + +class _Edge(QGraphicsLineItem): + def __init__(self, a: "_Node", b: "_Node", type_: str = ""): + super().__init__() + self.a, self.b = a, b + self.type = type_ + # Colour the edge by its RELATIONSHIP type (contains/defines/method/…), + # so the graph shows what each connection MEANS — falling back to the + # source node's tint for any untyped edge. + color = QColor(EDGE_KIND_COLORS.get(type_, "")) if type_ else QColor() + if not color.isValid(): + color = a.brush().color().lighter(130) + self._color = color + self.setPen(QPen(color, 1.4)) + self.setZValue(-1) + # A small label naming the relationship, shown at the edge midpoint. + self._label = None + if type_: + self._label = QGraphicsSimpleTextItem(type_, self) + self._label.setBrush(QBrush(color.lighter(140))) + f = QFont() + f.setPointSize(7) + self._label.setFont(f) + self._label.setZValue(0) + a.edges.append(self) + b.edges.append(self) + self.adjust() + + def adjust(self) -> None: + pa, pb = self.a.scenePos(), self.b.scenePos() + self.setLine(pa.x(), pa.y(), pb.x(), pb.y()) + if self._label is not None: + br = self._label.boundingRect() + self._label.setPos((pa.x() + pb.x()) / 2 - br.width() / 2, + (pa.y() + pb.y()) / 2 - br.height() / 2) + + +class _Node(QGraphicsEllipseItem): + def __init__(self, data, radius: int): + super().__init__(-radius, -radius, 2 * radius, 2 * radius) + self.data = data + self.edges = [] + tok = current_palette() + # NODE_KIND_COLORS is a categorical data encoding (one hue per node + # kind), not UI chrome — it stays fixed across themes on purpose so a + # given kind is always the same colour. Only the chrome follows tokens. + color = QColor(NODE_KIND_COLORS.get(data.kind, tok.text_muted)) + self.setBrush(QBrush(color)) + self.setPen(QPen(color.darker(160), 1.5)) + self.setFlags( + QGraphicsEllipseItem.ItemIsMovable + | QGraphicsEllipseItem.ItemIsSelectable + | QGraphicsEllipseItem.ItemSendsGeometryChanges + ) + self.setZValue(1) + label = QGraphicsSimpleTextItem(data.label, self) + label.setBrush(QBrush(QColor(tok.text))) + label.setPos(radius + 3, -8) + + def itemChange(self, change, value): # noqa: N802 + if change == QGraphicsEllipseItem.ItemPositionHasChanged: + for edge in self.edges: + edge.adjust() + return super().itemChange(change, value) + + +class _GraphView(QGraphicsView): + def __init__(self, scene): + super().__init__(scene) + self.setDragMode(QGraphicsView.NoDrag) + self._panning = False + self._pan_start = QPointF() + + def wheelEvent(self, e): # noqa: N802 + self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15, + 1.15 if e.angleDelta().y() > 0 else 1 / 1.15) + + def mousePressEvent(self, e): # noqa: N802 + if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None: + self._panning = True + self._pan_start = e.position() + self.setCursor(Qt.ClosedHandCursor) + e.accept() + return + super().mousePressEvent(e) + + def mouseMoveEvent(self, e): # noqa: N802 + if self._panning: + delta = e.position() - self._pan_start + self._pan_start = e.position() + self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().value() - delta.x())) + self.verticalScrollBar().setValue(int(self.verticalScrollBar().value() - delta.y())) + e.accept() + return + super().mouseMoveEvent(e) + + def mouseReleaseEvent(self, e): # noqa: N802 + if self._panning: + self._panning = False + self.setCursor(Qt.ArrowCursor) + e.accept() + return + super().mouseReleaseEvent(e) + + def mouseDoubleClickEvent(self, e): # noqa: N802 + """Double-click or Ctrl+click on a node opens its storage folder.""" + item = self.itemAt(e.pos()) + if isinstance(item, _Node) and getattr(item.data, "path", ""): + open_folder(item.data.path) + e.accept() + return + super().mouseDoubleClickEvent(e) + + +class StructureGraphView(QWidget): + status_message = Signal(str) + + def __init__(self, ctx: AppContext): + super().__init__() + self.ctx = ctx + self._worker: AgentWorker | None = None + self._node_items: list[_Node] = [] + self._edge_items: list[_Edge] = [] + self._centroid = QPointF(0, 0) + self._link = 120 + self._graph = None + self._needs_scan = False + self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite) + self._ask_worker: AgentWorker | None = None + self._answer = "" + self._detail_mode = "idle" # "answer" | "node" | "idle" — what self.detail shows + # TEMPORARY extracted file content for Q&A (real content, not just the + # graph structure). Kept only while this tab is shown — cleared on leaving + # the tab or switching project/root (see _clear_extracts / hideEvent). + self._extract_cache: dict = {} # path -> extracted text + self._extract_dir = None # temp folder for md/json dumps + self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox + + self._rescan_timer = QTimer(self) + self._rescan_timer.setSingleShot(True) + self._rescan_timer.setInterval(1500) + self._rescan_timer.timeout.connect(self._scan) + + root = QVBoxLayout(self) + + bar = QHBoxLayout() + self.path_edit = QLineEdit(str(ctx.config.cowork_output_dir())) + self.path_edit.setPlaceholderText(tr("structure.path_placeholder")) + self._pick_btn = QPushButton() + self._pick_btn.setIcon(icon("folder")) + self._pick_btn.setObjectName("primary") + self._pick_btn.clicked.connect(self._pick) + self.project_combo = QComboBox() + self.project_combo.currentIndexChanged.connect(self._on_project_changed) + self._scan_btn = QPushButton() + self._scan_btn.setIcon(icon("search")) + self._scan_btn.setObjectName("primary") + self._scan_btn.clicked.connect(self._scan) + # ONE toolbar row. There used to be a second row holding just the + # messages toggle and Export, which cost a whole row of height to carry + # two buttons. + self._export_btn = QPushButton() + self._export_btn.setIcon(icon("upload")) + self._export_btn.setObjectName("primary") + self._export_btn.clicked.connect(self._export) + bar.addWidget(self.path_edit, 1) + bar.addWidget(self._pick_btn) + bar.addWidget(self.project_combo) + bar.addWidget(self._scan_btn) + bar.addWidget(self._export_btn) + root.addLayout(bar) + self._refresh_project_combo() + + # Đồ thị | Tin nhắn as a real pair of tabs: the old single button + # relabelled itself, so the view you were NOT looking at was the only + # one named on screen. + self.view_tabs = QTabBar() + self.view_tabs.setObjectName("viewTabs") + self.view_tabs.setDrawBase(False) + self.view_tabs.setExpanding(False) + self.view_tabs.addTab(icon("graph"), "") + self.view_tabs.addTab(icon("message"), "") + self.view_tabs.currentChanged.connect(self._on_view_tab) + tab_row = QHBoxLayout() + tab_row.setContentsMargins(0, 0, 0, 0) + tab_row.addWidget(self.view_tabs) + tab_row.addStretch(1) + root.addLayout(tab_row) + + split = QSplitter(Qt.Horizontal) + self.scene = QGraphicsScene() + self.scene.setBackgroundBrush(QColor(current_palette().bg)) + self.scene.selectionChanged.connect(self._on_selection) + self.view = _GraphView(self.scene) + + self._stack = QStackedWidget() + self._stack.addWidget(self.view) + # A "Messages" view: all conversation messages grouped BY DAY, shown as + # JSON — a plain tree switched in via setCurrentWidget (never touches the + # D3/WebEngine graph). Populated from the (project-scoped) history store. + from PySide6.QtWidgets import QTreeWidget + self._msgs_view = QTreeWidget() + self._msgs_view.setHeaderHidden(True) + self._msgs_view.itemClicked.connect(self._show_msg_json) + self._stack.addWidget(self._msgs_view) + self.web = None + self._bridge = None + self._channel = None + + # The legend + Show-relationship control live INSIDE the D3 graph + # template now (assets/graph_template.html) — the graph column is just + # the stack (native view / D3 web / messages). + split.addWidget(self._stack) + + # Right-side agent panel (GraphRAG Q&A) + right = QWidget() + rl = QVBoxLayout(right) + rl.setContentsMargins(0, 0, 0, 0) + + # Agent panel header with collapse button + ag_hdr = QHBoxLayout() + self._ag_collapse = QPushButton() + self._ag_collapse.setIcon(collapse_right_icon()) + self._ag_collapse.setFixedWidth(28) + self._ag_collapse.clicked.connect(lambda: self._set_agent_collapsed(True)) + self._ag_label = QLabel() + ag_hdr.addWidget(self._ag_collapse) + ag_hdr.addWidget(self._ag_label, 1) + rl.addLayout(ag_hdr) + + # Ask row + ask_row = QHBoxLayout() + self.ask_edit = QLineEdit() + self.ask_edit.returnPressed.connect(self._ask) + self._ask_btn = QPushButton() + self._ask_btn.setIcon(icon("chat")) + self._ask_btn.setObjectName("primary") + self._ask_btn.clicked.connect(self._ask) + ask_row.addWidget(self.ask_edit, 1) + ask_row.addWidget(self._ask_btn) + rl.addLayout(ask_row) + + # Detail browser + self.detail = QTextBrowser() + self.detail.setReadOnly(True) + self.detail.setOpenLinks(False) + self.detail.anchorClicked.connect(self._on_detail_link) + rl.addWidget(self.detail, 1) + + self._agent_panel = right + + self._agent_strip = CollapseStrip(tr("structure.expand_agent_tooltip"), expand_dir="left") + self._agent_strip.clicked.connect(lambda: self._set_agent_collapsed(False)) + self._agent_strip.setVisible(False) + self._agent_pane = QWidget() + apl = QHBoxLayout(self._agent_pane) + apl.setContentsMargins(0, 0, 0, 0) + apl.setSpacing(0) + apl.addWidget(self._agent_strip) + apl.addWidget(right, 1) + + self._split = split + split.addWidget(self._agent_pane) + split.setChildrenCollapsible(False) + split.setSizes([840, 320]) + root.addWidget(split, 1) + on_language_changed(self._retranslate) + + def _retranslate(self) -> None: + self.path_edit.setPlaceholderText(tr("structure.path_placeholder")) + self._pick_btn.setText(tr("structure.browse")) + self._scan_btn.setText(tr("structure.scan")) + self._export_btn.setText(tr("structure.export_png")) + # Both views are named at once now, so neither label depends on state. + self.view_tabs.setTabText(0, tr("structure.graph_btn")) + self.view_tabs.setTabText(1, tr("structure.msgs_btn")) + self.view_tabs.setTabToolTip(1, tr("structure.msgs_tooltip")) + self._ag_collapse.setToolTip(tr("structure.collapse_agent_tooltip")) + self._ag_label.setText(tr("structure.agent_header")) + self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder")) + self._ask_btn.setText(tr("structure.ask")) + if self._detail_mode == "idle": + self.detail.setPlaceholderText(tr("structure.detail_placeholder")) + self._agent_strip.setToolTip(tr("structure.expand_agent_tooltip")) + self.project_combo.setToolTip(tr("structure.project_tooltip")) + self._refresh_project_combo() + + # ---- project sandbox lock ----------------------------------------- + def _refresh_project_combo(self) -> None: + from ..core.projects import list_projects + + keep = self._active_project_id + self.project_combo.blockSignals(True) + self.project_combo.clear() + self.project_combo.addItem(tr("structure.project_none"), "") + row_to_select = 0 + for i, p in enumerate(list_projects(), start=1): + self.project_combo.addItem(p.name, p.project_id) + if p.project_id == keep: + row_to_select = i + self.project_combo.setCurrentIndex(row_to_select) + self.project_combo.blockSignals(False) + + def set_project(self, project_id: str) -> None: + pid = project_id or "" + self._refresh_project_combo() + target = self.project_combo.findData(pid) + if target < 0: + target = 0 + if self.project_combo.currentIndex() == target: + self._on_project_changed(target) + else: + self.project_combo.setCurrentIndex(target) + + def _on_project_changed(self, _idx: int) -> None: + from ..core.projects import load_project + + pid = self.project_combo.currentData() or "" + project_changed = pid != self._active_project_id + if project_changed: + self._clear_extracts() # different workspace → drop temp extraction + self._active_project_id = pid + locked = bool(pid) + self.path_edit.setReadOnly(locked) + # Also disable the folder-pick button — otherwise the scan path is only + # "locked" against typing, but the picker could still repoint it outside + # the selected project's sandbox, breaking GraphRAG scope isolation. + self._pick_btn.setEnabled(not locked) + if locked: + project = load_project(pid) + if project is not None: + self.path_edit.setText(str(project.workspace_dir())) + if project_changed: + # Mark it and scan on the next visit rather than now. The rail's + # project picker made switching a one-click thing from any screen, + # and each switch rebuilt this graph — a folder walk plus a force + # layout plus a full setHtml of the D3 page — for a tab that was + # usually not even on screen. auto_scan_and_fit() picks the flag up + # when GraphRAG is actually opened. + self._needs_scan = True + + # ---- helpers ----------------------------------------------------- + def _pick(self) -> None: + chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text()) + if chosen: + self.path_edit.setText(chosen) + + def schedule_rescan(self, path: str = "") -> None: + if self._graph is None: + self._needs_scan = True + return + self._rescan_timer.start() + + # ---- Messages (by day, as JSON) -------------------------------------- + def _on_view_tab(self, index: int) -> None: + """Tab 0 = graph, tab 1 = messages. Same two views as before, now named + on screen instead of hidden behind one button's changing label.""" + if index == 1: + self._reload_messages() + self._stack.setCurrentWidget(self._msgs_view) + else: + self._stack.setCurrentWidget(self.web if self.web is not None else self.view) + + def _toggle_messages(self) -> None: + """Kept for callers that still ask for a flip (e.g. keyboard paths).""" + showing = self._stack.currentWidget() is self._msgs_view + self.view_tabs.setCurrentIndex(0 if showing else 1) + + def _reload_messages(self) -> None: + """Build the tree: day → conversation. Click a conversation to see its + messages as JSON. Scoped to the current project (its history folder).""" + from collections import OrderedDict + + from PySide6.QtCore import Qt + from PySide6.QtWidgets import QTreeWidgetItem + + from ..core.history import list_conversations + self._msgs_view.clear() + pid = self._active_project_id or "" + by_day: "OrderedDict[str, list]" = OrderedDict() + try: + convs = list_conversations(self.ctx.config.history_dir()) + except Exception: # noqa: BLE001 + convs = [] + for conv in convs: + if pid and conv.get("project_id", "default") != pid: + continue + day = (conv.get("created") or "")[:10] or "—" + by_day.setdefault(day, []).append(conv) + if not by_day: + self._msgs_view.addTopLevelItem(QTreeWidgetItem([tr("structure.msgs_none")])) + return + for day in sorted(by_day, reverse=True): + convs_d = by_day[day] + day_item = QTreeWidgetItem([f"{day} ({len(convs_d)})"]) + for conv in convs_d: + it = QTreeWidgetItem([conv.get("title", "(untitled)")]) + it.setData(0, Qt.UserRole, str(conv.get("path", ""))) + day_item.addChild(it) + self._msgs_view.addTopLevelItem(day_item) + day_item.setExpanded(True) + + def _show_msg_json(self, item, _col: int = 0) -> None: + import html + import json + + from PySide6.QtCore import Qt + + from ..core.history import load_conversation + path = item.data(0, Qt.UserRole) + if not path: + return + try: + conv = load_conversation(path) + payload = {"title": conv.get("title", ""), "created": conv.get("created", ""), + "kind": conv.get("kind", ""), "project_id": conv.get("project_id", ""), + "messages": conv.get("messages", [])} + text = json.dumps(payload, ensure_ascii=False, indent=2) + except Exception as exc: # noqa: BLE001 + text = f"(could not read: {exc})" + self.detail.setHtml( + f'
    {html.escape(text)}
    ') + + def prewarm(self) -> None: + """Pay for the graph view before it is clicked on, not during. + + Opening GraphRAG built a QWebEngineView (~140ms) and scanned the project + (~485ms) while an empty browser sat on screen — long enough, and white + enough, to read as the app restarting itself. Called from an idle timer + after the window is up, so startup itself is unaffected; the memory the + lazy construction was saving is spent a few seconds later instead. + """ + if not _HAS_WEB or self.web is not None: + return + self._ensure_web() + if self._graph is None and self.path_edit.text().strip(): + self._needs_scan = False + self._scan() # runs on a worker thread + + def _ensure_web(self) -> None: + if self.web is not None or not _HAS_WEB: + return + self.web = QWebEngineView() + # Blank the page in the app's own background first. A fresh + # QWebEngineView paints white, and on a dark theme that white rectangle + # WAS the flash — it showed for as long as the first scan took. + self.web.setHtml( + f"") + self._bridge = _Bridge() + self._channel = QWebChannel() + self._channel.registerObject("py", self._bridge) + self.web.page().setWebChannel(self._channel) + self._stack.addWidget(self.web) + self._stack.setCurrentWidget(self.web) + if self._graph is not None: + self._render_d3() + + def auto_scan_and_fit(self) -> None: + self._ensure_web() + if not self.path_edit.text().strip(): + return + if getattr(self, "_worker", None) is not None and self._worker.isRunning(): + self._fit() + self._preserve_answer() + return + if self._graph is not None and not self._needs_scan: + self._fit() + self._preserve_answer() + return + self._needs_scan = False + self._scan() + + # ---- scan -------------------------------------------------------- + def _scan(self) -> None: + path = self.path_edit.text().strip() or str(Path.cwd()) + mode = "files" # default: scan all files (filter removed) + use_cmem = bool(self.ctx.config.codebase_memory.get("enabled")) + cmem_bin = self.ctx.config.codebase_memory.get("binary_path", "") + st = self.ctx.config.structure + max_nodes = int(st.get("max_nodes", 500) or 0) + max_edges = int(st.get("max_edges", 500) or 0) + self._scan_seq += 1 + seq = self._scan_seq + self.status_message.emit(tr("structure.scanning")) + + def job(worker: AgentWorker): + from ..core.structure_graph import ( + build_from_codebase_memory, build_from_directory, force_layout, + ) + if use_cmem: + from ..core.codebase_memory import CodebaseMemory + mem = CodebaseMemory(cmem_bin) + graph = (build_from_codebase_memory(mem, path, mode, max_nodes, max_edges) + if mem.available else build_from_directory(path, mode, max_nodes, max_edges)) + else: + graph = build_from_directory(path, mode, max_nodes, max_edges) + pos = force_layout(graph) + return {"graph": graph, "pos": pos, "seq": seq} + + w = AgentWorker(job) + w.finished_ok.connect(self._render) + w.failed.connect(lambda e: self.status_message.emit(tr("structure.scan_error", err=e))) + self._worker = w + w.start() + + def _render(self, result: dict) -> None: + if result.get("seq") is not None and result["seq"] != self._scan_seq: + return + graph = result.get("graph") + pos = result.get("pos", {}) + if graph is None: + return + self._graph = graph + + self.scene.clear() + self.scene.setBackgroundBrush(QColor(current_palette().bg)) # restore after clear + self._node_items = [] + self._edge_items = [] + degree = {n.id: 0 for n in graph.nodes} + for e in graph.edges: + if e.source in degree: + degree[e.source] += 1 + if e.target in degree: + degree[e.target] += 1 + items = {} + sx = sy = 0.0 + for node in graph.nodes: + radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0)))) + item = _Node(node, radius) + x, y = pos.get(node.id, (0, 0)) + item.setPos(x, y) + self.scene.addItem(item) + items[node.id] = item + self._node_items.append(item) + sx += x + sy += y + for edge in graph.edges: + a, b = items.get(edge.source), items.get(edge.target) + if a and b: + e = _Edge(a, b, getattr(edge, "type", "")) + self.scene.addItem(e) + self._edge_items.append(e) + n = max(1, len(self._node_items)) + self._centroid = QPointF(sx / n, sy / n) + self._fit() + + if self.web is not None: + self._render_d3() + + note = tr("structure.truncated_note") if getattr(graph, "truncated", False) else "" + self.status_message.emit(tr( + "structure.graph_summary", nodes=len(graph.nodes), edges=len(graph.edges), note=note)) + self._preserve_answer() + + def _render_d3(self) -> None: + if self.web is None or self._graph is None: + return + from ..core.d3_graph import build_html + try: + self.web.setHtml(build_html(self._graph), QUrl("https://cowork.local/")) + except Exception as exc: + self.status_message.emit(f"D3 view error: {exc}") + + # ---- native interactions ---------------------------------------- + def _on_selection(self) -> None: + for item in self.scene.selectedItems(): + if isinstance(item, _Node): + d = item.data + self.detail.setPlainText(f"[{d.kind.upper()}] {d.label}\n\n{d.detail}") + self._detail_mode = "node" + return + + def _preserve_answer(self) -> None: + if self._detail_mode == "answer" and self._answer.strip(): + self._render_answer() + + def _set_agent_collapsed(self, collapsed: bool) -> None: + strip_w = CollapseStrip.WIDTH + 2 + self._agent_panel.setVisible(not collapsed) + self._agent_strip.setVisible(collapsed) + if collapsed: + self._agent_pane.setMaximumWidth(strip_w) + sizes = self._split.sizes() + if len(sizes) == 2: + self._split.setSizes([max(1, sum(sizes) - strip_w), strip_w]) + else: + self._agent_pane.setMaximumWidth(16777215) + self._split.setSizes([840, 320]) + + def _fit(self) -> None: + if self.web is not None and self._stack.currentWidget() is self.web: + self.web.page().runJavaScript("window.fitGraph && window.fitGraph();") + return + rect = self.scene.itemsBoundingRect() + if not rect.isNull(): + self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio) + + def _export(self) -> None: + path, _ = QFileDialog.getSaveFileName( + self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)") + if not path: + return + showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web) + if showing_d3: + self._export_d3_png(path) + else: + self._export_widget_grab(path) + + def _export_d3_png(self, path: str) -> None: + def on_result(data_url) -> None: + if not isinstance(data_url, str) or "," not in data_url: + self._export_widget_grab(path) + return + import base64 + try: + with open(path, "wb") as f: + f.write(base64.b64decode(data_url.split(",", 1)[1])) + self.status_message.emit(tr("structure.export_done", path=path)) + except (OSError, ValueError) as exc: + self.status_message.emit(tr("structure.export_failed", err=str(exc))) + self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result) + + def _export_widget_grab(self, path: str) -> None: + ok = self._stack.currentWidget().grab().save(path, "PNG") + if ok: + self.status_message.emit(tr("structure.export_done", path=path)) + else: + self.status_message.emit(tr("structure.export_failed", err="grab() returned no image")) + + # ---- agent Q&A over the graph ----------------------------------- + @staticmethod + def _graph_context(graph) -> str: + from collections import defaultdict + by_kind = defaultdict(list) + for n in graph.nodes: + by_kind[n.kind].append(n.label) + lines = [] + for kind in ("file", "class", "function", "method", "module", "section"): + items = by_kind.get(kind, []) + if items: + lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60])) + id2label = {n.id: n.label for n in graph.nodes} + rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}" + for e in graph.edges[:140]] + if rels: + lines.append("Relationships (sample):\n" + "\n".join(rels)) + return "\n".join(lines)[:7000] + + def _matched_sources(self, text: str): + if self._graph is None or not text: + return [] + found: dict[str, tuple[str, str, str]] = {} + for n in self._graph.nodes: + if not n.path: + continue + label = n.label.rstrip("()") + if len(label) < 3: + continue + if n.path not in found and re.search(rf"\b{re.escape(label)}\b", text): + found[n.path] = (n.kind, n.label, n.detail or n.path) + return sorted(found.items(), key=lambda kv: kv[1][1].lower())[:12] + + def _linkify_files(self, text: str, sources) -> str: + """Turn file/entity NAMES mentioned in the answer into clickable links that + open the file — so the user can click a name in the answer to view it.""" + for path, (kind, label, rel) in sources: + href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded) + tokens = [] + base = Path(path).name + if base and len(base) >= 3: + tokens.append(base) + lab = (label or "").rstrip("()").strip() + if lab and lab != base and len(lab) >= 3: + tokens.append(lab) + for tok in tokens: + esc = re.escape(tok) + # `tok` (code span) → keep the code style but make it a link + text = re.sub(rf"`{esc}`", f"[`{tok}`]({href})", text) + # bare tok, not already inside a link / path / code span + text = re.sub(rf"(? None: + text = self._answer + sources = self._matched_sources(text) + if sources: + # 1) Make the file/entity names IN THE ANSWER clickable (open on click). + text = self._linkify_files(text, sources) + # 2) Append a clickable "Related sources" section listing each file. + lines = [text, "", "---", f"**{tr('structure.related_sources')}**"] + for path, (kind, label, rel) in sources: + href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded) + # kind badge for context (file/function/section/json_key) + kind_badge = f" [{kind.upper()}]" if kind not in ("file",) else "" + lines.append(f"- **[{label}⧉]({href})**{kind_badge} — `{rel}`") + text = "\n".join(lines) + self.detail.setMarkdown(text) + + def _on_detail_link(self, url: QUrl) -> None: + if url.isLocalFile(): + p = url.toLocalFile() + # Open the FILE itself for viewing (fall back to its folder for a dir). + if Path(p).is_file(): + open_location(p) + else: + open_folder(p) + + def _ask(self) -> None: + question = self.ask_edit.text().strip() + if not question: + return + from ..core.skills import parse_skill_command + skill_prefix, question, info = parse_skill_command(question) + if info is not None: + self.detail.setMarkdown(info) + self._detail_mode = "answer" + self.ask_edit.clear() + return + if self._graph is None: + self.status_message.emit(tr("structure.scan_first")) + return + context = self._graph_context(self._graph) + # Real file CONTENT to answer from (extracted temporarily in the worker): + file_paths = self._candidate_file_paths() + extract_cache = dict(self._extract_cache) + extract_dir = str(self._extract_tmp_dir()) + self._answer = "" + self._detail_mode = "answer" + self.detail.setPlainText("…") + self.ask_edit.clear() + + active_project_id = self._active_project_id + + # Collect selected node context for auto-filtering + selected_nodes = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)] + selected_context = "" + if selected_nodes: + node_lines = [] + for nd in selected_nodes: + node_lines.append(f"- {nd.label} (kind: {nd.kind}, path: {getattr(nd, 'path', '')})") + if nd.detail: + node_lines.append(f" detail: {nd.detail}") + # Also gather connected nodes + connected_ids = set() + for nd in selected_nodes: + for edge in self._graph.edges: + if edge.source == nd.id: + connected_ids.add(edge.target) + elif edge.target == nd.id: + connected_ids.add(edge.source) + connected_nodes = [n for n in self._graph.nodes if n.id in connected_ids] + if connected_nodes: + node_lines.append("\nConnected nodes:") + for cn in connected_nodes: + node_lines.append(f"- {cn.label} (kind: {cn.kind})") + selected_context = "\n".join(node_lines) + + def job(worker: AgentWorker): + provider = self.ctx.build_active_provider() + system = ("You answer questions about a code/document knowledge graph. Use the provided " + "graph context AND the extracted file contents to retrieve, synthesize and " + "explain the answer. Be concise. Answer ONLY from what is provided (graph " + "context + extracted contents) — never invent files, functions, or facts that " + "aren't in it.\n\n" + "EACH answer MUST include source citations so the user can verify where " + "information came from. For every factual claim, file reference, or code " + "element you mention, add a citation using this format:\n\n" + " [source: filename.ext, line/section: XXX]\n\n" + "Rules for citations:\n" + " 1. Cite the EXACT file path from the graph context (use the path field).\n" + " 2. For Python files: cite the function/class name and approximate line " + " if available, or the module name.\n" + " 3. For document files (.md, .txt): cite the section heading.\n" + " 4. For JSON files: cite the key path (e.g. settings > database > host).\n" + " 5. Place citations inline after the relevant sentence or fact.\n" + " 6. At the end of your answer, add a '---' separator followed by a " + " numbered **Sources cited:** section listing each unique source with " + " its full path so the user can click to open it.\n\n" + "Example citation format in text:\n" + " The `process_data()` function handles CSV parsing " + "[source: src/utils/parser.py, function: process_data].\n\n" + "Example end-of-answer source list:\n" + " ---\n" + " **Sources cited:**\n" + " 1. `src/utils/parser.py` — process_data function\n" + " 2. `docs/api.md` — Section: Authentication\n") + if skill_prefix: + system += "\n\nFollow this skill:\n" + skill_prefix + if active_project_id: + from ..core.projects import load_project, project_context_text + proj_ctx = project_context_text(load_project(active_project_id)) + if proj_ctx: + system += "\n\n" + proj_ctx + user_content = f"Graph context:\n{context}" + if selected_context: + user_content += f"\n\nSelected node(s) context (focus your answer on these):\n{selected_context}" + # Auto-extract the actual file contents (temporary) so the answer is + # synthesized from real content, not just the graph structure. + content_block, new_cache = _extract_file_contents(file_paths, extract_cache, extract_dir) + if content_block: + user_content += ("\n\nExtracted file contents (read these to answer about file " + "details/data; cite the file path):\n" + content_block) + user_content += f"\n\nQuestion: {question}" + messages = [ + {"role": "system", "content": system}, + {"role": "user", "content": user_content}, + ] + from ..core import agent_roles, audit_log + ok = True + try: + provider.chat(messages, on_text=lambda t: worker.emit_event({"type": "text", "delta": t}), + cancel=worker.is_cancelled) + except Exception: + ok = False + raise + finally: + audit_log.record("tool_call", "graphrag_ask", ok, question[:500], + agent_role=agent_roles.KNOWLEDGE) + return {"extracted": new_cache} + + w = AgentWorker(job) + w.event.connect(self._on_ask_event) + w.finished_ok.connect(self._on_ask_done) + w.failed.connect(lambda e: self.detail.setPlainText(f"Error: {e}")) + self._ask_worker = w + w.start() + + def _on_ask_event(self, ev: dict) -> None: + if ev.get("type") == "text": + if self._answer == "": + self.detail.clear() + self._answer += ev.get("delta", "") + self.detail.setPlainText(self._answer) + + def _on_ask_done(self, result: dict) -> None: + # Keep the (temporary) extracted content so repeated questions reuse it + # without re-extracting — dropped when leaving the tab (_clear_extracts). + if isinstance(result, dict): + self._extract_cache.update(result.get("extracted", {}) or {}) + self._render_answer() + + # ---- temporary file-content extraction for Q&A ------------------------ + def _candidate_file_paths(self) -> list: + """File paths to read for a question: the SELECTED file nodes if any, else + every file node in the graph (capped downstream).""" + from pathlib import Path as _P + if self._graph is None: + return [] + sel = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)] + nodes = sel or list(self._graph.nodes) + out, seen = [], set() + for nd in nodes: + p = (getattr(nd, "path", "") or "").strip() + if p and p not in seen and _P(p).is_file(): + seen.add(p) + out.append(p) + return out + + def _extract_tmp_dir(self): + from pathlib import Path as _P + if self._extract_dir is None: + import tempfile + from ..config import CONFIG_DIR + base = CONFIG_DIR / "tmp" / "graphrag_extract" + base.mkdir(parents=True, exist_ok=True) + self._extract_dir = _P(tempfile.mkdtemp(dir=str(base))) + return self._extract_dir + + def _clear_extracts(self) -> None: + """Discard the temporary extracted content (on leaving the tab / switching + project). The extraction is a scratch aid, never persisted.""" + self._extract_cache = {} + d, self._extract_dir = self._extract_dir, None + if d is not None: + import shutil + shutil.rmtree(d, ignore_errors=True) + + def hideEvent(self, e): # noqa: N802 + # Leaving the GraphRAG tab → drop the temporary extracted info. + self._clear_extracts() + super().hideEvent(e) + + + + + +# -------------------------------------------------------------------------- +# Temporary file-content extraction for Graph-RAG Q&A (runs in the ask worker) +# -------------------------------------------------------------------------- +def _pdf_to_markdown(pdf_path, out_dir) -> str | None: + """Convert a PDF to Markdown with opendataloader-pdf when available (richer + structure than a plain text dump). Best-effort — returns None if the package + isn't installed or the call fails, so the caller falls back to doc_extract.""" + from pathlib import Path as _P + try: + import opendataloader_pdf # optional; auto-installed elsewhere if present + except Exception: # noqa: BLE001 + try: + from ..core.deps import ensure_module + if ensure_module("opendataloader_pdf", "opendataloader-pdf") is None: + return None + import opendataloader_pdf # noqa: F811 + except Exception: # noqa: BLE001 + return None + out = _P(out_dir) + out.mkdir(parents=True, exist_ok=True) + for call in ( + lambda: opendataloader_pdf.convert(input_path=[str(pdf_path)], output_dir=str(out), + generate_markdown=True), + lambda: opendataloader_pdf.convert(input_path=str(pdf_path), output_dir=str(out)), + lambda: opendataloader_pdf.convert(str(pdf_path), str(out)), + ): + try: + call() + break + except TypeError: + continue + except Exception: # noqa: BLE001 + return None + mds = list(out.rglob(_P(pdf_path).stem + "*.md")) or list(out.rglob("*.md")) + for md in mds: + try: + return md.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + return None + + +def _extract_file_contents(paths, cache: dict, tmp_dir, + max_files: int = 15, max_total: int = 120_000): + """Read the ACTUAL content of ``paths`` (PDF→markdown via opendataloader when + available, else doc_extract for office/pdf/text). Returns ``(block, cache)`` + — ``block`` is the concatenated content for the prompt (bounded), ``cache`` + maps path→text for reuse. Never raises.""" + from pathlib import Path as _P + from ..core import doc_extract + cache = dict(cache or {}) + parts, total = [], 0 + for p in paths[:max_files]: + if total >= max_total: + break + text = cache.get(p) + if text is None: + try: + if _P(p).suffix.lower() == ".pdf": + text = _pdf_to_markdown(p, tmp_dir) + if not text: + text, _n = doc_extract.extract_text(p) + else: + text, _n = doc_extract.extract_text(p) + except Exception: # noqa: BLE001 + text = "" + cache[p] = text or "" + text = cache.get(p) or "" + if not text: + continue + chunk = text[: max(0, max_total - total)] + total += len(chunk) + parts.append(f'--- {_P(p).name} ({p}) ---\n{chunk}') + return ("\n\n".join(parts), cache) diff --git a/ui/task_editor_dialog.py b/ui/task_editor_dialog.py index 9d135d8..ba608fa 100644 --- a/ui/task_editor_dialog.py +++ b/ui/task_editor_dialog.py @@ -19,7 +19,7 @@ from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDateTimeEdit, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, - QScrollArea, QSpinBox, QVBoxLayout, QWidget, + QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget, ) from ..config import PROVIDER_LABELS @@ -70,28 +70,36 @@ class TaskEditorDialog(QDialog): self.edited_task: Optional[dict] = None self.setWindowTitle(tr("schedtask.editor_title_edit" if task else "schedtask.editor_title_new")) self.resize(560, 680) - # Flat inputs: every field (text, list, combo, spin, date) is transparent - # so it shows the page background (the app theme otherwise fills inputs - # with a lighter box) — just a light outline, consistent with the rest of - # the app. The combo drop-down popup keeps a solid dark background so its - # items stay readable. - self.setStyleSheet( - "QLineEdit, QPlainTextEdit, QListWidget, QComboBox, QAbstractSpinBox {" - " background: transparent; border: 1px solid rgba(140,146,152,0.45);" - " border-radius: 6px; }" - "QListWidget::item { background: transparent; }" - "QComboBox QAbstractItemView { background: #111D32; color: #E0F0FF; }") + # Only the files/links/depends-on lists go flat (transparent, no boxed + # panel) — they sit right next to their own +/trash buttons, which is + # enough affordance without a filled background. Title/Description/ + # Prompt/combos etc. keep the app's normal raised-surface + border + # look (theme.py's default for these widget types): a transparent + # single/multi-line box with only a 1px border was tried here and + # turned out too faint against the group's own background to read as + # an editable field at all ("không thể nhận ra ô textbox của prompt"). + # Scoped to #flatList, not bare QListWidget — that would also blank + # out the sectionIndex sidebar's :selected highlight (set by the app + # theme), since a stylesheet set directly on this dialog overrides the + # app-wide one for every descendant it matches, regardless of the + # theme rule's own selector specificity. + self.setStyleSheet("QListWidget#flatList, QListWidget#flatList::item { background: transparent; }") outer = QVBoxLayout(self) scroll = QScrollArea() scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # down only content = QWidget() scroll.setWidget(content) outer.addWidget(scroll, 1) root = QVBoxLayout(content) # ---- basics ---------------------------------------------------- - form = QFormLayout() + # A QGroupBox like the other four step pages (Schedule/Input/ + # Dependency/Execution), so this one isn't the odd one out once it's + # moved into its own step page below (bare background, no title). + self._basic_box = QGroupBox(tr("schedtask.g_basic")) + form = QFormLayout(self._basic_box) self.title_edit = QLineEdit(self.task.get("title", "")) # Description is the source of truth. Its ✨ button GENERATES the Prompt # (Input) FROM the description — the title is just the task's label and @@ -204,7 +212,7 @@ class TaskEditorDialog(QDialog): form.addRow(tr("schedtask.f_skill"), self.skill_combo) form.addRow(tr("schedtask.f_priority"), self.priority_combo) form.addRow(tr("schedtask.f_status"), self.status_combo) - root.addLayout(form) + root.addWidget(self._basic_box) self._main_form = form self._model_box = model_box self._on_run_kind_changed() # apply agent/flow row visibility @@ -316,6 +324,7 @@ class TaskEditorDialog(QDialog): # multi-select file dialog and APPENDS (never wipes what's already # there), the trash button removes just the selected row(s). self.files_list = QListWidget() + self.files_list.setObjectName("flatList") self.files_list.setMaximumHeight(90) self.files_list.setSelectionMode(QListWidget.ExtendedSelection) for p in inp.get("file_paths", []) or []: @@ -341,6 +350,7 @@ class TaskEditorDialog(QDialog): # Links — same "+"-list pattern; "+" prompts for one URL at a time # (fetched best-effort and inlined as context, same as file attachments). self.links_list = QListWidget() + self.links_list.setObjectName("flatList") self.links_list.setMaximumHeight(90) self.links_list.setSelectionMode(QListWidget.ExtendedSelection) for u in inp.get("links", []) or []: @@ -390,6 +400,7 @@ class TaskEditorDialog(QDialog): # Fan-in: tick every task this one must WAIT for — it won't run until # ALL of them are Done (parallel predecessors feeding one successor). self.depends_list = QListWidget() + self.depends_list.setObjectName("flatList") self.depends_list.setMaximumHeight(96) current_deps = set(dep.get("depends_on") or []) for t in self.all_tasks: @@ -426,6 +437,44 @@ class TaskEditorDialog(QDialog): eform.addRow("", self.approval_chk) root.addWidget(eg) + # Three steps, as tabs: Nội dung → Lịch chạy → Liên kết. The five group + # boxes are re-parented into three pages — none is dropped, they are + # grouped by the question being answered rather than stacked in one + # scroll where the later ones are out of sight. + # Left list + right panel, navigated exactly like Settings — five rows + # matching the five real group boxes, so you always see which group you + # are in and how many are left. (Not tabs: the audit page asks for this + # shape specifically, for consistency with Settings.) + from .widgets import section_panels + + self._step_keys = ["schedtask.g_basic", "schedtask.g_schedule", + "schedtask.g_input", "schedtask.g_dependency", + "schedtask.g_execution"] + pages = [] + for key, group in zip(self._step_keys, [self._basic_box, sg, ig, dg, eg]): + page = QWidget() + pv = QVBoxLayout(page) + pv.setContentsMargins(4, 4, 4, 4) + root.removeWidget(group) + pv.addWidget(group) + pv.addStretch(1) + wrap = QScrollArea() + wrap.setWidgetResizable(True) + wrap.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + wrap.setWidget(page) + pages.append((tr(key), wrap)) + self.section_list, self.section_stack = section_panels(pages) + outer.removeWidget(scroll) + scroll.setParent(None) + body = QHBoxLayout() + body.setSpacing(10) + body.addWidget(self.section_list) + body.addWidget(self.section_stack, 1) + outer.insertLayout(0, body, 1) + # Floor the width at what the widest page needs, at the font in use. + widest = max(w.widget().sizeHint().width() for _lab, w in pages) + self.setMinimumWidth(self.section_list.width() + widest + 60) + buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) buttons.button(QDialogButtonBox.Save).setIcon(icon("save")) buttons.button(QDialogButtonBox.Cancel).setIcon(icon("close")) @@ -438,6 +487,11 @@ class TaskEditorDialog(QDialog): from .widgets import guard_wheel guard_wheel(self) + def _retranslate_steps(self) -> None: + """Re-label the five section rows for the current language.""" + for i, key in enumerate(self._step_keys): + self.section_list.item(i).setText(tr(key)) + def _apply_hints(self) -> None: """Tooltip hints on every non-obvious control, so each option explains itself on hover.""" diff --git a/ui/terminal_panel.py b/ui/terminal_panel.py index dede3cf..2d7a728 100644 --- a/ui/terminal_panel.py +++ b/ui/terminal_panel.py @@ -27,6 +27,7 @@ from PySide6.QtWidgets import ( ) from ..i18n import on_language_changed, tr +from ..theme import current_palette from .icons import icon _IS_WIN = sys.platform == "win32" @@ -75,8 +76,10 @@ class TerminalPanel(QWidget): # ---- header (always visible; click to expand/collapse) -------------- self._header = QFrame() self._header.setObjectName("termHeader") + _tp = current_palette() self._header.setStyleSheet( - "#termHeader { background: rgba(0,0,0,0.06); border-radius: 6px; }") + f"#termHeader {{ background: {_tp.surface};" + f" border-radius: {_tp.radius}px; }}") hb = QHBoxLayout(self._header) hb.setContentsMargins(8, 4, 8, 4) self._toggle_btn = QPushButton() @@ -107,8 +110,7 @@ class TerminalPanel(QWidget): mono.setStyleHint(QFont.Monospace) mono.setPointSize(10) self.output.setFont(mono) - self.output.setStyleSheet( - "#termOutput { background: #1e1e1e; color: #d4d4d4; border: none; }") + # Surface comes from the central style sheet (#termOutput) — see theme.py. self.output.setMinimumHeight(160) bl.addWidget(self.output, 1) @@ -119,9 +121,7 @@ class TerminalPanel(QWidget): self.input = _TermInput() self.input.setObjectName("termInput") self.input.setFont(mono) - self.input.setStyleSheet( - "#termInput { background: #1e1e1e; color: #d4d4d4; border: 1px solid #3c3c3c; " - "border-radius: 6px; padding: 4px 8px; }") + # Surface comes from the central style sheet (#termInput) — see theme.py. self.input.returnPressed.connect(self._run_current) self.input.complete_requested.connect(self._complete) self.input.history_prev.connect(lambda: self._history_move(-1)) @@ -282,11 +282,12 @@ class TerminalPanel(QWidget): if not text: return from PySide6.QtGui import QColor, QTextCursor - colors = {"cmd": "#4ec9b0", "err": "#f48771", "ok": "#6a9955", "out": "#d4d4d4"} + p = current_palette() + colors = {"cmd": p.code_type, "err": p.code_error, "ok": p.code_comment, "out": p.code_fg} cursor = self.output.textCursor() cursor.movePosition(QTextCursor.End) fmt = cursor.charFormat() - fmt.setForeground(QColor(colors.get(role, "#d4d4d4"))) + fmt.setForeground(QColor(colors.get(role, p.code_fg))) cursor.setCharFormat(fmt) cursor.insertText(text) self.output.setTextCursor(cursor) diff --git a/ui/tools_admin_tab.py b/ui/tools_admin_tab.py index d939057..226008f 100644 --- a/ui/tools_admin_tab.py +++ b/ui/tools_admin_tab.py @@ -2,8 +2,9 @@ Two sub-tabs: * "Tool" — built-in agent tools (read/write/edit files, run commands, - install packages, fetch URLs); toggling one OFF removes it - from the agent's toolset (persisted in ``config.tools_disabled``). + install packages, fetch URLs) as a left-aligned card grid; + toggling one OFF removes it from the agent's toolset + (persisted in ``config.tools_disabled``). * "Connector" — the full Connectors (MCP / REST API) setup, moved here from Settings: add/edit/delete CAD/CAE/MS365/Other connectors and enable/disable each (``ConnectorsPanel``). @@ -11,9 +12,10 @@ Two sub-tabs: from __future__ import annotations from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QPainter, QPixmap from PySide6.QtWidgets import ( - QCheckBox, QHBoxLayout, QHeaderView, QLabel, QPushButton, - QTableWidget, QTableWidgetItem, QTabWidget, QVBoxLayout, QWidget, + QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTabWidget, + QVBoxLayout, QWidget, ) from ..core.tools import TOOL_SPECS @@ -22,18 +24,46 @@ from ..i18n import on_language_changed, tr from ..state import AppContext from .connectors_panel import ConnectorsPanel from .icons import icon +from .widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_card + +# Identity colour + icon per built-in tool — same "fixed colour regardless of +# theme" convention as monitoring_tab.py's agent avatars / agents_admin_tab.py's +# kind avatars, grouped by what the tool actually touches (file i/o, shell, +# packages, network, Jira). +_TOOL_COLOUR = { + "read_file": "#0078D4", "list_dir": "#0078D4", "write_file": "#0078D4", + "edit_file": "#0078D4", "run_command": "#107C10", "install_package": "#8764B8", + "fetch_url": "#FFB900", "jira_search": "#8764B8", "jira_get_issue": "#8764B8", +} +_TOOL_ICON_NAME = { + "read_file": "document", "list_dir": "folder", "write_file": "new", + "edit_file": "edit", "run_command": "terminal", "install_package": "download", + "fetch_url": "globe", "jira_search": "search", "jira_get_issue": "link", +} -def _center_checkbox(checked: bool, on_toggle) -> QWidget: - box = QWidget() - lay = QHBoxLayout(box) - lay.setContentsMargins(0, 0, 0, 0) - lay.setAlignment(Qt.AlignCenter) - chk = QCheckBox() - chk.setChecked(checked) - chk.toggled.connect(on_toggle) - lay.addWidget(chk) - return box +def _tool_icon_pixmap(name: str, size: int = 28) -> QPixmap: + pm = QPixmap(size, size) + pm.fill(Qt.transparent) + p = QPainter(pm) + p.setRenderHint(QPainter.Antialiasing) + p.setPen(Qt.NoPen) + p.setBrush(QColor(_TOOL_COLOUR.get(name, "#0078D4"))) + r = size * 0.28 + p.drawRoundedRect(0, 0, size, size, r, r) + inner = int(size * 0.58) + glyph = icon(_TOOL_ICON_NAME.get(name, "puzzle"), size=inner, color="#FFFFFF").pixmap(inner, inner) + p.drawPixmap((size - inner) // 2, (size - inner) // 2, glyph) + p.end() + return pm + + +def _clear_flow(flow: FlowLayout) -> None: + while flow.count(): + item = flow.takeAt(0) + w = item.widget() + if w is not None: + w.deleteLater() class ToolsAdminTab(QWidget): @@ -54,21 +84,20 @@ class ToolsAdminTab(QWidget): self._hint.setWordWrap(True) tl.addWidget(self._hint) - self.table = QTableWidget(0, 3) - self.table.setEditTriggers(QTableWidget.NoEditTriggers) - self.table.verticalHeader().setVisible(False) - # Description is the long column — IT stretches to fill remaining - # width (was Name, leaving Description squeezed into whatever was - # left over); Name/Enabled size to their own content. - self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) - self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) - self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) - self.table.setWordWrap(True) - tl.addWidget(self.table, 1) + # A left-aligned, wrapping card grid — one card per built-in tool + # (colour-coded icon + name + toggle switch + description), replacing + # the old flat Name/Description/Enabled table. + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.NoFrame) + cards_host = QWidget() + self._tool_flow = FlowLayout(cards_host, margin=0, h_spacing=10, v_spacing=10) + scroll.setWidget(cards_host) + tl.addWidget(scroll, 1) - # "Test Internet" self-test lives INSIDE the fetch_url tool row now (see - # refresh) instead of a separate boxed section — persistent widgets so - # they survive table rebuilds. + # "Test Internet" self-test lives INSIDE the fetch_url tool's card now + # (see refresh) instead of a separate boxed section — persistent + # widgets so they survive card rebuilds. self.test_internet_btn = QPushButton(tr("settings.test_internet")) self.test_internet_btn.setIcon(icon("globe")) self.test_internet_btn.setToolTip(tr("settings.test_internet_tooltip")) @@ -94,51 +123,71 @@ class ToolsAdminTab(QWidget): self.connectors_panel = ConnectorsPanel(ctx) self.subtabs.addTab(self.connectors_panel, "") + # on_language_changed() already invokes _retranslate() once immediately + # (see i18n.py) — a second explicit call here double-populates the + # card grid back-to-back with no event-loop turn in between, so the + # first pass's cards are only queued for deleteLater() (not yet gone) + # when the second pass adds new ones on top (see connectors_panel.py's + # ConnectorsPanel, which hit the exact same bug this same way). on_language_changed(self._retranslate) - self._retranslate() - # ---- built-in tools table ------------------------------------------------- + # ---- built-in tools card grid --------------------------------------------- def refresh(self) -> None: disabled = set(self.ctx.config.tools_disabled) - specs = list(TOOL_SPECS) - self.table.setRowCount(len(specs)) - for r, spec in enumerate(specs): - self.table.setItem(r, 0, QTableWidgetItem(spec.name)) - # Full description (was truncated to 80 chars, hiding the rest) — - # word-wraps inside the stretched column; resizeRowToContents - # below grows the row to fit however many lines that takes. - if spec.name == "fetch_url": - # This tool's row carries the live "Test Internet" self-test - # right below its description — no separate boxed section. - self.table.setItem(r, 1, None) - self.table.setCellWidget(r, 1, self._fetch_url_desc_cell(spec)) - else: - desc_item = QTableWidgetItem(spec.description) - desc_item.setToolTip(spec.description) - self.table.setItem(r, 1, desc_item) - self.table.setCellWidget( - r, 2, _center_checkbox(spec.name not in disabled, - lambda on, n=spec.name: self._toggle_builtin(n, on))) - # Once ALL rows/columns are populated (so the stretched Description - # column has its real width), grow each row to fit its wrapped text. - self.table.resizeRowsToContents() + _clear_flow(self._tool_flow) + for spec in TOOL_SPECS: + self._tool_flow.addWidget(self._tool_card(spec, spec.name not in disabled)) + + def _tool_card(self, spec, enabled: bool) -> QWidget: + card = QFrame() + card.setFrameShape(QFrame.NoFrame) + style_card(card) + card.setFixedWidth(220) + # The description below wraps to a variable number of lines at this + # fixed width, so the card's own height depends on its width — without + # this, the outer FlowLayout's QWidgetItem queries card.sizePolicy() + # (not the description label's), gets a too-short sizeHint, and + # squeezes the card into less height than its QVBoxLayout needs, + # which is what overlapped the header onto the description text. + enable_height_for_width(card) + lay = QVBoxLayout(card) + lay.setContentsMargins(10, 8, 10, 8) + lay.setSpacing(4) + + hdr = QHBoxLayout() + icon_lbl = QLabel() + icon_lbl.setPixmap(_tool_icon_pixmap(spec.name)) + icon_lbl.setStyleSheet("border: none;") + hdr.addWidget(icon_lbl) + name_lbl = QLabel(spec.name) + name_lbl.setStyleSheet("font-weight:700; border: none;") + hdr.addWidget(name_lbl) + hdr.addStretch(1) + sw = ToggleSwitch() + sw.setChecked(enabled) + sw.toggled.connect(lambda on, n=spec.name: self._toggle_builtin(n, on)) + hdr.addWidget(sw) + lay.addLayout(hdr) - def _fetch_url_desc_cell(self, spec) -> QWidget: - cell = QWidget() - cl = QVBoxLayout(cell) - cl.setContentsMargins(6, 4, 6, 4) - cl.setSpacing(4) desc = QLabel(spec.description) desc.setWordWrap(True) desc.setToolTip(spec.description) - cl.addWidget(desc) - net = QWidget() - nl = QHBoxLayout(net) - nl.setContentsMargins(0, 0, 0, 0) - nl.addWidget(self.test_internet_btn) - nl.addWidget(self.test_internet_status, 1) - cl.addWidget(net) - return cell + desc.setObjectName("hint") + desc.setStyleSheet("border: none;") + lay.addWidget(desc) + + if spec.name == "fetch_url": + # The live "Test Internet" self-test lives inside fetch_url's own + # card — it tests THIS capability, not the tab as a whole. + net = QWidget() + net.setStyleSheet("border: none;") + nl = QHBoxLayout(net) + nl.setContentsMargins(0, 2, 0, 0) + nl.addWidget(self.test_internet_btn) + nl.addWidget(self.test_internet_status, 1) + lay.addWidget(net) + + return card def _toggle_builtin(self, name: str, enabled: bool) -> None: self.ctx.config.set_tool_enabled(name, enabled) @@ -192,9 +241,5 @@ class ToolsAdminTab(QWidget): self.test_internet_btn.setText(tr("settings.test_internet")) self.test_internet_btn.setToolTip(tr("settings.test_internet_tooltip")) self.refresh_btn.setText(tr("tools_admin.refresh")) - self.table.setHorizontalHeaderLabels([ - tr("tools_admin.col_name"), tr("tools_admin.col_desc"), - tr("tools_admin.col_enabled"), - ]) self.jira_note.setText(tr("tools_admin.jira_note")) self.refresh() diff --git a/ui/widgets.py b/ui/widgets.py index fa85d14..c980d00 100644 --- a/ui/widgets.py +++ b/ui/widgets.py @@ -5,39 +5,150 @@ from __future__ import annotations from pathlib import Path -from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, Qt, Signal +from PySide6.QtCore import ( + QEvent, QObject, QPoint, QPointF, QRect, QRectF, QSize, Qt, Signal, +) from PySide6.QtGui import QColor, QPainter, QPen from PySide6.QtWidgets import ( - QAbstractSpinBox, QComboBox, QDoubleSpinBox, QFrame, QGraphicsDropShadowEffect, - QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSizePolicy, - QVBoxLayout, QWidget, + QAbstractSpinBox, QCheckBox, QComboBox, QDoubleSpinBox, QFrame, + QHBoxLayout, QLabel, QLayout, QListWidget, QListWidgetItem, QPushButton, + QSizePolicy, QVBoxLayout, QWidget, ) from ..core.flows import STEP_DONE, STEP_ERROR, STEP_PENDING, STEP_RUNNING -from ..theme import ACCENT +from ..theme import current_palette from .icons import DOT_BLUE, DOT_GREEN, DOT_GREY, DOT_RED, dot_icon, icon +def badge_pill_widget(text: str, object_name: str) -> QWidget: + """A small rounded pill for a table cell — a coloured role/status tag + (``object_name`` is one of theme.py's ``badge*`` QLabel names). Wrapped in + a transparent container rather than passed as a bare label: a + ``setCellWidget()`` widget is stretched to fill the whole cell, and + without the container's own ``background: transparent`` the app-wide + ``QWidget { background: $bg }`` rule (theme.py) paints that stretched + area opaque, hiding the pill inside a solid block instead of a snug tag.""" + container = QWidget() + container.setStyleSheet("background: transparent;") + lay = QHBoxLayout(container) + lay.setContentsMargins(4, 2, 4, 2) + lbl = QLabel(text) + lbl.setObjectName(object_name) + lay.addWidget(lbl, 0, Qt.AlignVCenter) + lay.addStretch(1) + return container + + +def enable_height_for_width(widget: QWidget) -> None: + """Flag ``widget`` as height-for-width so a PARENT layout reserves the + right amount of vertical space for it — needed at every widget boundary + between a :class:`FlowLayout` and the outermost layout, since each + ``addWidget()`` hop asks the WIDGET's own sizePolicy, not its layout's + (see FlowLayout's docstring).""" + policy = widget.sizePolicy() + policy.setHeightForWidth(True) + widget.setSizePolicy(policy) + + +class FlowLayout(QLayout): + """A left-aligned layout that wraps its children onto new lines as the + container narrows, each item kept at its own natural size — the + ``.card`` grids in ui-audit_v2.html ("không kéo giãn lấp đầy hàng": cards + stay sized to their own content, never stretched to fill a row). Qt has + no built-in equivalent; this is the standard recipe (Qt's own C++ + FlowLayout example, ported).""" + + def __init__(self, parent=None, margin: int = 0, h_spacing: int = 8, v_spacing: int = 8): + super().__init__(parent) + self._h_spacing = h_spacing + self._v_spacing = v_spacing + self._items: list = [] + self.setContentsMargins(margin, margin, margin, margin) + if parent is not None: + enable_height_for_width(parent) + + def addItem(self, item) -> None: # noqa: N802 - Qt override + self._items.append(item) + + def count(self) -> int: # noqa: N802 - Qt override + return len(self._items) + + def itemAt(self, index: int): # noqa: N802 - Qt override + return self._items[index] if 0 <= index < len(self._items) else None + + def takeAt(self, index: int): # noqa: N802 - Qt override + return self._items.pop(index) if 0 <= index < len(self._items) else None + + def expandingDirections(self): # noqa: N802 - Qt override + return Qt.Orientations(Qt.Orientation(0)) + + def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt override + return True + + def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt override + return self._do_layout(QRect(0, 0, width, 0), test_only=True) + + def setGeometry(self, rect) -> None: # noqa: N802 - Qt override + super().setGeometry(rect) + self._do_layout(rect, test_only=False) + + def sizeHint(self): # noqa: N802 - Qt override + return self.minimumSize() + + def minimumSize(self): # noqa: N802 - Qt override + size = QSize() + for item in self._items: + size = size.expandedTo(item.minimumSize()) + m = self.contentsMargins() + size += QSize(m.left() + m.right(), m.top() + m.bottom()) + return size + + def _do_layout(self, rect, test_only: bool) -> int: + m = self.contentsMargins() + effective = QRect(rect.x() + m.left(), rect.y() + m.top(), + rect.width() - m.left() - m.right(), + rect.height() - m.top() - m.bottom()) + x, y = effective.x(), effective.y() + line_height = 0 + for item in self._items: + hint = item.sizeHint() + next_x = x + hint.width() + self._h_spacing + if next_x - self._h_spacing > effective.right() and line_height > 0: + x = effective.x() + y = y + line_height + self._v_spacing + next_x = x + hint.width() + self._h_spacing + line_height = 0 + if not test_only: + item.setGeometry(QRect(QPoint(x, y), hint)) + x = next_x + line_height = max(line_height, hint.height()) + return y + line_height - rect.y() + m.bottom() + + +def style_card(frame: QFrame) -> None: + """Give a stat/budget card its surface. Flat by design: the raised surface + plus a hairline is what separates it from the page — the old drop shadow + made a grid of these look like it was hovering off the screen.""" + p = current_palette() + frame.setStyleSheet( + f"QFrame {{ background: {p.surface}; border: 1px solid {p.border};" + f" border-radius: {p.radius_lg}px; }}") + + class StatCard(QFrame): """A titled value card (e.g. token count + its cost as the subtitle) — shared by Dashboard and Monitoring's token/cost displays.""" def __init__(self): super().__init__() - self.setFrameShape(QFrame.StyledPanel) - self.setStyleSheet( - "QFrame { border: 1px solid rgba(140,146,152,0.35); border-radius: 16px; }") - shadow = QGraphicsDropShadowEffect(self) - shadow.setBlurRadius(18) - shadow.setOffset(0, 3) - shadow.setColor(QColor(0, 0, 0, 60)) - self.setGraphicsEffect(shadow) + self.setFrameShape(QFrame.NoFrame) + style_card(self) lay = QVBoxLayout(self) self.title_lbl = QLabel("") self.title_lbl.setObjectName("hint") self.title_lbl.setStyleSheet("border: none;") self.value_lbl = QLabel("—") - self.value_lbl.setStyleSheet("border: none; font-size: 20px; font-weight: 700;") + self.value_lbl.setStyleSheet("border: none; font-size: 22px; font-weight: 600;") self.sub_lbl = QLabel("") self.sub_lbl.setObjectName("hint") self.sub_lbl.setStyleSheet("border: none;") @@ -46,8 +157,10 @@ class StatCard(QFrame): # sizeHint hundreds of px wide, forcing its WHOLE grid column open and # throwing every card in the row out of alignment. self.sub_lbl.setWordWrap(True) - lay.addWidget(self.title_lbl) + # Number first, name under it — the figure is what the eye is looking + # for, and it is how the audit page's tiles are drawn. lay.addWidget(self.value_lbl) + lay.addWidget(self.title_lbl) lay.addWidget(self.sub_lbl) def set(self, title: str, value: str, sub: str = "") -> None: @@ -55,6 +168,19 @@ class StatCard(QFrame): self.value_lbl.setText(value) self.sub_lbl.setText(sub) + def as_hero(self) -> "StatCard": + """Make this the headline card: bigger number, accent colour. + + Used for the one figure a screen is really about (Dashboard's total + cost), so a row of otherwise identical tiles has a clear first read. + """ + from ..theme import current_palette + p = current_palette() + self.value_lbl.setStyleSheet( + f"border: none; font-size: 34px; font-weight: 700; color: {p.accent};") + self.setObjectName("heroCard") + return self + class BudgetCard(QFrame): """Remaining/Budget box — same card chrome as :class:`StatCard`, plus a @@ -66,20 +192,14 @@ class BudgetCard(QFrame): def __init__(self): super().__init__() - self.setFrameShape(QFrame.StyledPanel) - self.setStyleSheet( - "QFrame { border: 1px solid rgba(140,146,152,0.35); border-radius: 16px; }") - shadow = QGraphicsDropShadowEffect(self) - shadow.setBlurRadius(18) - shadow.setOffset(0, 3) - shadow.setColor(QColor(0, 0, 0, 60)) - self.setGraphicsEffect(shadow) + self.setFrameShape(QFrame.NoFrame) + style_card(self) lay = QVBoxLayout(self) self.title_lbl = QLabel("") self.title_lbl.setObjectName("hint") self.title_lbl.setStyleSheet("border: none;") self.value_lbl = QLabel("—") - self._value_style = "border: none; font-size: 20px; font-weight: 700;" + self._value_style = "border: none; font-size: 22px; font-weight: 600;" self.value_lbl.setStyleSheet(self._value_style) self.sub_lbl = QLabel("") self.sub_lbl.setObjectName("hint") @@ -89,8 +209,9 @@ class BudgetCard(QFrame): # sizeHint hundreds of px wide, forcing its WHOLE grid column open and # throwing every card in the row out of alignment. self.sub_lbl.setWordWrap(True) - lay.addWidget(self.title_lbl) + # Same order as StatCard: the number first, its name under it. lay.addWidget(self.value_lbl) + lay.addWidget(self.title_lbl) lay.addWidget(self.sub_lbl) row = QHBoxLayout() @@ -109,7 +230,7 @@ class BudgetCard(QFrame): self.title_lbl.setText(title) self.value_lbl.setText(value) self.value_lbl.setStyleSheet( - self._value_style + (" color: #E5484D;" if warn else "")) + self._value_style + (f" color: {current_palette().danger};" if warn else "")) self.sub_lbl.setText(sub) @@ -148,6 +269,310 @@ def guard_wheel(root: QWidget) -> None: w.installEventFilter(_wheel_guard) +def tidy_popup(combo) -> None: + """Make a drop-list show its options and nothing else. + + Two platform habits to undo. macOS marks the current row with a checkmark, + drawn by the menu-style delegate a combo gets by default; the row is already + tinted by selection-background-color, so the tick says nothing twice and, in + a combo only as wide as "VN", covered the letters it was marking. Handing + the view a plain QStyledItemDelegate switches it to item-view painting, + where no such glyph exists. + + And the popup inherits the combo's width unless told otherwise, which had + the project and provider names cut off here regardless of platform. So + measure the longest item — plus an indicator's worth of room, in case a + style still draws one — and set that as the view's minimum. + """ + from PySide6.QtWidgets import QStyle, QStyledItemDelegate + + view = combo.view() + combo.setItemDelegate(QStyledItemDelegate(combo)) + fm = view.fontMetrics() + longest = max((fm.horizontalAdvance(combo.itemText(i)) + for i in range(combo.count())), default=0) + tick = combo.style().pixelMetric(QStyle.PM_IndicatorWidth, None, combo) + pad = combo.style().pixelMetric(QStyle.PM_FocusFrameHMargin, None, combo) * 2 + view.setMinimumWidth(longest + tick + pad + 16) + + +def ui_scale(widget: QWidget) -> float: + """How much bigger this machine draws things than the design baseline. + + Breakpoints written as raw pixels only hold on the screen they were tuned + on. At 125%/150% display scaling Qt still reports logical pixels, but every + label, button and margin is taller — so the same layout needs MORE logical + width before it stops being cramped. Font height is the honest proxy for + that: it moves with the display scale and with a user's font-size choice, + both of which change how much fits. + + 1.0 at the 15px line height the layouts were measured against. + """ + return max(0.75, min(2.5, widget.fontMetrics().height() / 15.0)) + + +class _NarrowGuard(QObject): + """Calls back when the WINDOW crosses a width threshold. + + Watching the widget's own width does not work: a pane whose minimum width is + larger than the space available never reports being narrow — it just gets + clipped, which is the very problem being solved. The window always knows its + real size, so that is what gets watched. + + A fold the user did by hand is never undone: auto-expand only reverses an + auto-collapse. + """ + + def __init__(self, owner: QWidget, threshold: int, apply): + super().__init__(owner) + self._owner = owner + self._threshold = threshold + self._apply = apply + self._auto = False # True while WE are the ones holding it folded + self._window = None + + def attach(self) -> None: + win = self._owner.window() + if win is not None and win is not self._owner and win is not self._window: + win.installEventFilter(self) + self._window = win + # Dragging the window to a monitor with different scaling changes + # how much fits without changing its width, so re-decide then too. + handle = win.windowHandle() + if handle is not None: + handle.screenChanged.connect(lambda *_a: self.check()) + self.check() + + def eventFilter(self, obj, ev): # noqa: N802 - Qt override + if ev.type() == QEvent.Resize and obj is self._window: + self.check() + return super().eventFilter(obj, ev) + + def check(self) -> None: + win = self._owner.window() + width = win.width() if win is not None else self._owner.width() + # The threshold is written for the baseline scale and grows with the + # machine's — see ui_scale(). + narrow = width < self._threshold * ui_scale(self._owner) + if narrow == self._auto: + return + self._auto = narrow + self._apply(narrow) + + +def narrow_guard(owner: QWidget, threshold: int, apply): + """Fold `owner`'s secondary panes below `threshold` px of window width. + + ``apply(narrow: bool)`` does the folding. Call ``.attach()`` from showEvent. + """ + return _NarrowGuard(owner, threshold, apply) + + +class ToggleSwitch(QCheckBox): + """A checkbox drawn as an on/off switch. + + Subclasses QCheckBox rather than replacing it, so every ``isChecked()`` / + ``setChecked()`` / ``stateChanged`` call site keeps working untouched — only + the painting changes. A switch reads as "this is on or off" where a tick box + reads as "this is selected", which is what these settings actually mean. + """ + + _W, _H = 34, 18 + + def __init__(self, text: str = "", parent=None): + super().__init__(text, parent) + self.setCursor(Qt.PointingHandCursor) + + def sizeHint(self): # noqa: N802 - Qt override + base = super().sizeHint() + base.setWidth(base.width() + self._W) + base.setHeight(max(base.height(), self._H + 4)) + return base + + def paintEvent(self, _e): # noqa: N802 - Qt override + from ..theme import current_palette + p = current_palette() + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + y = (self.height() - self._H) // 2 + track = QRectF(0, y, self._W, self._H) + on = self.isChecked() + enabled = self.isEnabled() + fill = QColor(p.accent_solid if on else p.border_strong) + if not enabled: + fill.setAlpha(110) + painter.setPen(Qt.NoPen) + painter.setBrush(fill) + painter.drawRoundedRect(track, self._H / 2, self._H / 2) + knob = self._H - 4 + kx = self._W - knob - 2 if on else 2 + painter.setBrush(QColor("#FFFFFF" if enabled else p.text_faint)) + painter.drawEllipse(QRectF(kx, y + 2, knob, knob)) + if self.text(): + painter.setPen(QColor(p.text if enabled else p.text_faint)) + painter.drawText( + QRectF(self._W + 8, 0, self.width() - self._W - 8, self.height()), + int(Qt.AlignLeft | Qt.AlignVCenter), self.text()) + painter.end() + + +class SegmentedControl(QWidget): + """Two-to-four choices shown side by side instead of hidden in a drop-list. + + Exposes the slice of the QComboBox API this app's settings code uses + (addItem / findData / currentData / setCurrentIndex / currentIndexChanged), + so it drops into an existing form without touching the save/load paths. + """ + + currentIndexChanged = Signal(int) + + def __init__(self, parent=None): + super().__init__(parent) + self._data: list = [] + self._buttons: list = [] + self._current = -1 + lay = QHBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) + self._lay = lay + lay.addStretch(1) + + def addItem(self, text: str, data=None) -> None: # noqa: N802 - Qt-style name + from PySide6.QtWidgets import QPushButton + btn = QPushButton(text) + btn.setObjectName("segItem") + btn.setCheckable(True) + btn.setCursor(Qt.PointingHandCursor) + index = len(self._buttons) + btn.clicked.connect(lambda _c=False, i=index: self.setCurrentIndex(i)) + self._lay.insertWidget(index, btn) + self._buttons.append(btn) + self._data.append(data) + if self._current < 0: + self.setCurrentIndex(0) + + def findData(self, value) -> int: # noqa: N802 + return self._data.index(value) if value in self._data else -1 + + def currentData(self): # noqa: N802 + return self._data[self._current] if 0 <= self._current < len(self._data) else None + + def currentIndex(self) -> int: # noqa: N802 + return self._current + + def count(self) -> int: + return len(self._buttons) + + def setItemText(self, index: int, text: str) -> None: # noqa: N802 + if 0 <= index < len(self._buttons): + self._buttons[index].setText(text) + + def setCurrentIndex(self, index: int) -> None: # noqa: N802 + if not (0 <= index < len(self._buttons)) or index == self._current: + for i, b in enumerate(self._buttons): + b.setChecked(i == self._current) + return + self._current = index + for i, b in enumerate(self._buttons): + b.setChecked(i == index) + self.currentIndexChanged.emit(index) + + +def section_panels(sections, width: int = 260): + """Left list + right panel: pick a section, see that section only. + + ``sections`` is [(label, widget)]. Returns (list_widget, stack) for the + caller to place side by side. Used by Settings and the Task editor so both + are navigated the same way, instead of one long scroll where you cannot + tell which group you are in or how many are left. + """ + from PySide6.QtWidgets import QListWidget, QListWidgetItem, QStackedWidget + + index = QListWidget() + index.setObjectName("sectionIndex") + index.setFrameShape(QListWidget.NoFrame) + index.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + index.setTextElideMode(Qt.ElideRight) + index.setWordWrap(False) + + stack = QStackedWidget() + for label, widget in sections: + item = QListWidgetItem(label) + item.setToolTip(label) + index.addItem(item) + stack.addWidget(widget) + index.currentRowChanged.connect(stack.setCurrentIndex) + index.setCurrentRow(0) + + natural = max(index.fontMetrics().horizontalAdvance(lab) for lab, _w in sections) + 36 + index.setFixedWidth(max(120, min(width, natural))) + return index, stack + + +def section_index(scroll, sections, width: int = 260): + """A clickable table of contents for a long scrolling dialog. + + ``sections`` is [(label, anchor_widget)]. Clicking a row scrolls its anchor + into view; scrolling the dialog moves the highlight back. Purely navigation: + every field stays exactly where it was, in the same one scrolling column — + Settings and the Task editor were five stacked group boxes deep with no way + to tell what was further down. + + Returns the QListWidget so the caller can place it. + """ + from PySide6.QtWidgets import QListWidget, QListWidgetItem + + index = QListWidget() + index.setObjectName("sectionIndex") + index.setFrameShape(QListWidget.NoFrame) + # Long section names (and 125%/150% display scaling) used to push a + # horizontal scrollbar into this list. It elides instead, with the full + # name on hover. + index.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + index.setTextElideMode(Qt.ElideRight) + index.setWordWrap(False) + for label, anchor in sections: + item = QListWidgetItem(label) + item.setToolTip(label) + item.setData(Qt.UserRole, anchor) + index.addItem(item) + index.setCurrentRow(0) + # Wide enough for the longest name at the CURRENT font — so the width grows + # with display scaling instead of eliding everything — but capped so it + # never eats the form beside it. `width` is that cap, not a fixed size. + natural = max(index.fontMetrics().horizontalAdvance(lab) for lab, _a in sections) + 36 + index.setFixedWidth(max(120, min(width, natural))) + + def _jump(item): + anchor = item.data(Qt.UserRole) + if anchor is not None: + # Scroll so the section's top edge lands at the top of the viewport, + # rather than merely "somewhere visible". + bar = scroll.verticalScrollBar() + top = anchor.mapTo(scroll.widget(), anchor.rect().topLeft()).y() + bar.setValue(min(top, bar.maximum())) + + index.itemClicked.connect(_jump) + + def _follow(value: int): + """Highlight the last section whose top has passed the viewport top.""" + row = 0 + for i in range(index.count()): + anchor = index.item(i).data(Qt.UserRole) + if anchor is None: + continue + top = anchor.mapTo(scroll.widget(), anchor.rect().topLeft()).y() + if top <= value + 4: + row = i + if index.currentRow() != row: + blocked = index.blockSignals(True) + index.setCurrentRow(row) + index.blockSignals(blocked) + + scroll.verticalScrollBar().valueChanged.connect(_follow) + return index + + class CollapseStrip(QWidget): """The slim bar shown in place of a collapsed side panel. @@ -185,15 +610,16 @@ class CollapseStrip(QWidget): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) w = self.width() - accent = QColor(ACCENT) if self._hover else QColor("#8b8d98") + tok = current_palette() + accent = QColor(tok.accent) if self._hover else QColor(tok.text_faint) # A small rounded "button" at the top carries the expand arrow so the # collapsed panel always shows a clear, clickable affordance. bw = min(w - 2.0, 16.0) btn = QRectF((w - bw) / 2.0, 6.0, bw, 18.0) - p.setPen(QPen(QColor(139, 144, 150, 130), 1.0)) - p.setBrush(QColor(155, 160, 166, 70) if self._hover else QColor(155, 160, 166, 32)) - p.drawRoundedRect(btn, 4.0, 4.0) + p.setPen(QPen(QColor(tok.border_strong), 1.0)) + p.setBrush(QColor(tok.hover if self._hover else tok.surface)) + p.drawRoundedRect(btn, float(tok.radius_sm), float(tok.radius_sm)) cx = w / 2.0 cy = btn.center().y() @@ -211,7 +637,7 @@ class CollapseStrip(QWidget): # thin handle line below the button p.setPen(Qt.NoPen) - p.setBrush(QColor(155, 160, 166, 90)) + p.setBrush(QColor(tok.border_strong)) line_w = 2.0 x = (w - line_w) / 2.0 ltop = btn.bottom() + 6.0 @@ -226,7 +652,12 @@ class PlanSection(QWidget): close). Hidden until it has steps; updated in place as the agent calls ``update_plan``.""" - _COLORS = {STEP_RUNNING: ACCENT, STEP_DONE: "#6fe3a4", STEP_ERROR: "#ef6368"} + @staticmethod + def _step_color(status: str) -> str | None: + """Row text colour per step status; None leaves the default. Resolved + per call so it follows a live theme switch.""" + p = current_palette() + return {STEP_RUNNING: p.accent, STEP_DONE: p.success, STEP_ERROR: p.danger}.get(status) @staticmethod def _step_icon(status: str): @@ -272,7 +703,7 @@ class PlanSection(QWidget): continue status = str((s or {}).get("status", STEP_PENDING)).strip().lower() item = QListWidgetItem(self._step_icon(status), f" {title}") - color = self._COLORS.get(status) + color = self._step_color(status) if color: item.setForeground(QColor(color)) self.list.addItem(item) @@ -308,7 +739,11 @@ class CollapsibleSection(QWidget): activated = Signal(str) # emits the path of a clicked item - def __init__(self, title: str, max_height: int = 130): + def __init__(self, title: str, max_height: int | None = 130): + """``max_height`` caps the list so it scrolls instead of growing + (the default, e.g. for a section sharing space with siblings). + ``None`` instead lets it expand to fill whatever room its parent + layout hands it — for a section that owns the whole panel.""" super().__init__() self._title = title self._paths: list[str] = [] @@ -325,11 +760,14 @@ class CollapsibleSection(QWidget): lay.addWidget(self.header) self.list = QListWidget() - self.list.setMaximumHeight(max_height) # scrolls when longer + if max_height is not None: + self.list.setMaximumHeight(max_height) # scrolls when longer + else: + self.list.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) self.list.setVisible(False) self.list.itemActivated.connect(self._emit) self.list.itemClicked.connect(self._emit) - lay.addWidget(self.list) + lay.addWidget(self.list, 1 if max_height is None else 0) self.setVisible(False) self._update_header() diff --git a/ui/workspace_tab.py b/ui/workspace_tab.py index 2830377..533e760 100644 --- a/ui/workspace_tab.py +++ b/ui/workspace_tab.py @@ -32,12 +32,32 @@ from .osutil import open_folder from .widgets import CollapseStrip +class _ProjectRow(QWidget): + """A project in the list: its name, and under it how much is in it. + + The drawing gives every row a second line — "2 đoạn chat · 3 task" — which + is the only thing on this screen that says a project holds anything at all. + """ + + def __init__(self, name: str, counts: str): + super().__init__() + lay = QVBoxLayout(self) + lay.setContentsMargins(6, 4, 6, 4) + lay.setSpacing(0) + title = QLabel(name) + sub = QLabel(counts) + sub.setObjectName("hint") + lay.addWidget(title) + lay.addWidget(sub) + + class WorkspaceTab(QWidget): status_message = Signal(str) open_chat = Signal(str, dict) # kind, conversation — open a thread in Cowork new_chat = Signal(str) # project_id — start a new thread in this project projects_changed = Signal() # created/edited/deleted → History regroups subtabs_changed = Signal() # visible sub-tabs changed → left-nav children refresh + project_selected = Signal(str) # project_id — the rail's picker follows this # ---- nav integration: the sub-tabs are driven from the left nav rail ----- def nav_subtabs(self): @@ -50,10 +70,37 @@ class WorkspaceTab(QWidget): return [(self.tabs.tabText(i), i, icons.get(i, "chevron-right")) for i in range(self.tabs.count()) if self.tabs.isTabVisible(i)] + def nav_entries(self): + """(label, index, icon_name, enabled) for EVERY sub-tab, hidden ones + included. + + The rail lists all five all the time and greys out the ones the project + gate is currently closing (Cowork, GraphRAG) instead of removing them — + same gate, shown rather than hidden, so the menu stops changing shape + under the user's hand. See nav_subtabs() for the visible-only view. + """ + icons = {self._project_tab_idx: "folder", self._cowork_tab_idx: "chat", + self._co4e_tab_idx: "flow", self._folder_tab_idx: "folder", + self._graphrag_tab_idx: "graph"} + return [(self.tabs.tabText(i), i, icons.get(i, "chevron-right"), + self.tabs.isTabVisible(i)) + for i in range(self.tabs.count())] + + def subtab_available(self, index: int) -> bool: + """False while the project gate is holding this sub-tab shut. + + The rail greys those rows out, but that only guards the rail. This lets + every other route ask the same question of the same state. + """ + return bool(0 <= index < self.tabs.count() and self.tabs.isTabVisible(index)) + def select_subtab(self, index: int) -> None: if 0 <= index < self.tabs.count(): self.tabs.setCurrentIndex(index) + def current_subtab(self) -> int: + return self.tabs.currentIndex() + def hide_tab_bar(self) -> None: """Hide the in-content tab strip (the nav rail drives the sub-tabs now), so the content area is as large as possible.""" @@ -63,6 +110,9 @@ class WorkspaceTab(QWidget): super().__init__() self.ctx = ctx self._current_id = "" + # True once the user asks for the full History panel; until then Cowork + # opens with it folded, as the drawing lays the screen out. + self._history_opened = False # Shared widgets embedded as per-project sub-tabs (None in unit tests # that only drive project management). self._cowork = cowork @@ -70,12 +120,23 @@ class WorkspaceTab(QWidget): self._sidebar = sidebar root = QVBoxLayout(self) + # Title row — the drawing puts "+ Project mới" up here beside the title, + # not at the foot of the project list where it read as belonging to the + # list's own controls. self._header = QLabel() self._header.setStyleSheet("font-weight:700; font-size:15px;") + self._new_btn = QPushButton() + self._new_btn.setIcon(icon("plus")) + self._new_btn.setObjectName("primary") + self._new_btn.clicked.connect(self._create) + title_row = QHBoxLayout() + title_row.addWidget(self._header) + title_row.addStretch(1) + title_row.addWidget(self._new_btn) + root.addLayout(title_row) self._hint = QLabel() self._hint.setObjectName("hint") self._hint.setWordWrap(True) - root.addWidget(self._header) root.addWidget(self._hint) self._split = QSplitter(Qt.Horizontal) @@ -87,6 +148,9 @@ class WorkspaceTab(QWidget): ll = QVBoxLayout(left) ll.setContentsMargins(0, 0, 0, 0) left_hdr = QHBoxLayout() + self._projects_hdr = QLabel() + self._projects_hdr.setObjectName("navSectionHdr") + left_hdr.addWidget(self._projects_hdr) self._proj_collapse_btn = QPushButton() self._proj_collapse_btn.setIcon(collapse_left_icon()) self._proj_collapse_btn.setFixedWidth(28) @@ -98,15 +162,13 @@ class WorkspaceTab(QWidget): self.project_list.currentItemChanged.connect(self._on_select) ll.addWidget(self.project_list, 1) btns = QHBoxLayout() - self._new_btn = QPushButton() - self._new_btn.setIcon(icon("plus")) - self._new_btn.setObjectName("primary") - self._new_btn.clicked.connect(self._create) + # Delete stays under the list it acts on. The drawing does not show it, + # but it does not show it moved either, and dropping a control is not + # something a layout pass gets to do. self._del_btn = QPushButton() self._del_btn.setIcon(icon("trash")) self._del_btn.clicked.connect(self._delete) - btns.addWidget(self._new_btn, 1) - btns.addWidget(self._del_btn) + btns.addWidget(self._del_btn, 1) ll.addLayout(btns) self._projects_panel = left @@ -127,6 +189,13 @@ class WorkspaceTab(QWidget): # Project comes FIRST; Cowork + GraphRAG only appear once a project is # actually selected (see _update_tab_visibility). self.tabs = QTabWidget() + # A QTabWidget's minimum width is the MAXIMUM over every page, hidden + # ones included — so Co4E (the widest, ~1180px) was setting the floor for + # Project and Cowork as well, and through them for the whole window, + # which then refused to be smaller than 1453px on any screen. An explicit + # minimum overrides that: each page still gets whatever width is going, + # and the pages that are not on screen no longer vote. + self.tabs.setMinimumWidth(560) self._project_tab_idx = self.tabs.addTab(self._build_project_tab(), tr("workspace.tab_project")) self._cowork_tab_idx = -1 self._graphrag_tab_idx = -1 @@ -205,9 +274,14 @@ class WorkspaceTab(QWidget): rl.addWidget(self._instr_lbl) rl.addWidget(self.instr_edit) + self._folder_hdr = QLabel() + rl.addWidget(self._folder_hdr) folder_row = QHBoxLayout() - self.folder_lbl = QLabel() - self.folder_lbl.setObjectName("hint") + # The drawing shows the path in a field, not as grey caption text. A + # read-only line edit looks like one and, unlike a label, lets the path + # be selected and copied. + self.folder_lbl = QLineEdit() + self.folder_lbl.setReadOnly(True) self._browse_btn = QPushButton() self._browse_btn.setIcon(icon("folder")) self._browse_btn.clicked.connect(self._pick_folder) @@ -219,6 +293,7 @@ class WorkspaceTab(QWidget): folder_row.addWidget(self._open_btn) rl.addLayout(folder_row) + rl.addStretch(1) # the drawing floats Lưu project at the bottom save_row = QHBoxLayout() self._save_btn = QPushButton() self._save_btn.setIcon(icon("save")) @@ -239,11 +314,20 @@ class WorkspaceTab(QWidget): sb = self._sidebar sb.open_chat.connect(self._on_sidebar_open) sb.new_chat.connect(self._on_sidebar_new) - sb.collapse_requested.connect(lambda: self._set_sidebar_collapsed(True)) - sb.expand_requested.connect(lambda: self._set_sidebar_collapsed(False)) + sb.collapse_requested.connect(lambda: self._on_history_fold(True)) + sb.expand_requested.connect(lambda: self._on_history_fold(False)) sb.refresh_requested.connect(self._on_sidebar_refresh) sb.history_changed.connect(self._reload_threads) + def _on_history_fold(self, collapsed: bool) -> None: + """Its chevron closes the panel away, back to the drawn layout.""" + self._history_opened = not collapsed + if collapsed: + self._sidebar.setVisible(False) + self._apply_pane_visibility() + else: + self._set_sidebar_collapsed(False) + def _set_sidebar_collapsed(self, collapsed: bool) -> None: """Collapse/expand History sidebar and redistribute splitter space so the Cowork chat area fills the freed width (same pattern as @@ -321,17 +405,44 @@ class WorkspaceTab(QWidget): on_project = idx == self._project_tab_idx on_cowork = self._cowork_tab_idx >= 0 and idx == self._cowork_tab_idx self._projects_pane.setVisible(on_project) + # "Workspace — Projects" and its three-line explanation describe the + # PROJECT screen, but were drawn above every sub-tab — ~90px of vertical + # space taken from Co4E's canvas and Folder's tree on every laptop + # screen. Shown where they apply; the text itself is unchanged. + self._header.setVisible(on_project) + # The title row sits above the sub-tabs, so everything on it has to + # follow the same rule the title does — moving + Project mới up here put + # it in the corner of Cowork, Co4E, Folder and GraphRAG as well. + self._new_btn.setVisible(on_project) + # ...and the explanation only while there is nothing to explain against: + # the drawing heads a populated screen with the title alone. + self._hint.setVisible(on_project and self.project_list.count() == 0) + narrow = getattr(self, "_is_narrow", False) if self._sidebar is not None: - self._sidebar.setVisible(on_cowork) - # Auto-expand History when entering Cowork tab so it's always usable - if on_cowork: + # Cowork is two columns in the drawing — transcript and files — with + # no history pane and no strip where one used to be. The relocation + # table is explicit: History moves to Sidebar ▸ RECENTS, "giữ, dễ + # tới hơn". So the panel is not on this screen at all until asked + # for: "Tất cả project…" in RECENTS brings it in, since search, + # filters, pin, rename and multi-select delete live only there. + self._sidebar.setVisible(on_cowork and self._history_opened) + if on_cowork and self._history_opened: self._sidebar.set_collapsed(False) # QSplitter ignores hidden panes, but the freed width isn't handed to # the remaining panes deterministically — set explicit sizes after any # pane toggle (same lesson as _set_projects_collapsed). total = sum(self._split.sizes()) or 1300 - proj_w = 260 if on_project else 0 - hist_w = 240 if (on_cowork and self._sidebar is not None) else 0 + # A collapsed pane must be given the STRIP width here, not its open + # width: this ran after every tab change and handed History a flat + # 240px even while it was folded to an 18px strip, leaving ~220px of + # dead space beside the chat on a small screen. + strip_w = CollapseStrip.WIDTH + 2 + proj_w = 0 + if on_project: + proj_w = strip_w if self._projects_strip.isVisible() else 260 + hist_w = 0 + if on_cowork and self._sidebar is not None: + hist_w = strip_w if self._sidebar.is_collapsed() else 240 if self._split.count() >= 3: self._split.setSizes([proj_w, hist_w, max(1, total - proj_w - hist_w)]) else: @@ -341,6 +452,8 @@ class WorkspaceTab(QWidget): def _retranslate(self) -> None: self._header.setText(tr("workspace.header")) self._hint.setText(tr("workspace.hint")) + self._projects_hdr.setText(tr("workspace.projects_heading").upper()) + self._folder_hdr.setText(tr("workspace.folder_label")) self._new_btn.setText(tr("workspace.new_project")) self._del_btn.setText(tr("workspace.delete")) self._name_lbl.setText(tr("workspace.name")) @@ -365,6 +478,38 @@ class WorkspaceTab(QWidget): self.tabs.setTabText(self._graphrag_tab_idx, tr("workspace.tab_graphrag")) # ---- project list collapse (same pattern as History / GraphRAG Agent panel) -- + # Below this window width the three panes (projects 260 + history 240 + + # the sub-page, which alone wants ~1245px on Cowork) no longer fit and Qt + # clips them instead of shrinking. Measured with tools/check_responsive.py. + _NARROW = 1500 + + def showEvent(self, e): # noqa: N802 - Qt override + super().showEvent(e) + if getattr(self, "_narrow", None) is None: + from .widgets import narrow_guard + self._narrow = narrow_guard(self, self._NARROW, self._apply_narrow) + self._narrow.attach() + + def _apply_narrow(self, narrow: bool) -> None: + """Fold the two side panes on a small screen so the sub-page keeps its + width; unfold them when the window grows back. + + Nothing becomes unreachable: both panes leave their usual collapse strip + behind, and the project picker + RECENTS in the rail cover the same + ground while they are folded. + """ + self._is_narrow = narrow + self._set_projects_collapsed(narrow) + if self._sidebar is not None: + self._set_sidebar_collapsed(narrow) + # The chat's Files pane (~300px) is the other thing that pushes Cowork + # past the window; it has the same collapse strip to come back from. + if self._cowork is not None and hasattr(self._cowork, "_set_io_collapsed"): + self._cowork._set_io_collapsed(narrow) + if not narrow: + # Re-apply the per-tab rules the two calls above just overrode. + self._apply_pane_visibility() + def _set_projects_collapsed(self, collapsed: bool) -> None: strip_w = CollapseStrip.WIDTH + 2 self._projects_panel.setVisible(not collapsed) @@ -406,21 +551,48 @@ class WorkspaceTab(QWidget): from ..core.projects import list_projects keep = self._current_id + counts = self._project_counts() self.project_list.blockSignals(True) self.project_list.clear() row_to_select = 0 for i, p in enumerate(list_projects()): - item = QListWidgetItem(p.name) + chats, tasks = counts.get(p.project_id, (0, 0)) + # No text on the item: the row widget paints the name, and setting + # both drew it twice, one string ghosting the other. + item = QListWidgetItem() item.setData(Qt.UserRole, p.project_id) if p.description: item.setToolTip(p.description) self.project_list.addItem(item) + row = _ProjectRow(p.name, tr("workspace.counts", chats=chats, tasks=tasks)) + item.setSizeHint(row.sizeHint()) + self.project_list.setItemWidget(item, row) if p.project_id == keep: row_to_select = i self.project_list.blockSignals(False) self.project_list.setCurrentRow(row_to_select) + # The drawing heads a populated screen with the title alone; the + # explanation is what an EMPTY one says instead of showing nothing. + self._hint.setVisible(self.project_list.count() == 0) self._load_current() + @staticmethod + def _project_counts(): + """{project_id: (chats, tasks)} — read once per refresh, not per row.""" + from ..core.history import list_conversations + from ..core.tasks import list_tasks + + out: dict = {} + for conv in list_conversations(): + pid = conv.get("project_id") or "default" + chats, tasks = out.get(pid, (0, 0)) + out[pid] = (chats + 1, tasks) + for task in list_tasks(): + pid = task.get("project_id") or "default" + chats, tasks = out.get(pid, (0, 0)) + out[pid] = (chats, tasks + 1) + return out + def _selected_id(self) -> str: item = self.project_list.currentItem() return item.data(Qt.UserRole) if item else "" @@ -474,6 +646,94 @@ class WorkspaceTab(QWidget): self._bind_project(pid) finally: self._set_tabs_busy(False) + # The rail's project picker mirrors this selection — it is a second view + # of the same state, never a second source of truth. + self.project_selected.emit(pid) + + # ---- rail integration ------------------------------------------------- + def project_choices(self): + """(name, project_id) for the rail picker, in the list's own order. + + Read from the store, which is what the list is built from — reading + item.text() coupled the picker to how a row happens to be drawn, and + when rows became widgets the picker went blank: every project showed + as a bare folder glyph with no name. + """ + from ..core.projects import list_projects + + return [(p.name, p.project_id) for p in list_projects()] + + def selected_project_id(self) -> str: + return self._selected_id() + + def choose_project(self, project_id: str) -> bool: + """Select a project by id — the same path the list row takes.""" + return self._select_project_row(project_id) + + def recent_threads(self, limit: int = 5): + """The active project's most recent conversations, newest first. + + Scoped to the project on purpose: history is stored inside the project's + own folder (config.history_dir() follows the selection) and the History + pane groups by project. A flat, cross-project recents list would quietly + drop that scoping. + """ + from ..core.history import list_conversations + + pid = self._current_id + if not pid: + return [] + try: + convos = list_conversations(self.ctx.config.history_dir()) + except Exception: # noqa: BLE001 + return [] + out = [] + for meta in convos: + if (meta.get("project_id", "") or "default") != pid: + continue + out.append({ + "title": meta.get("title") or tr("sidebar.empty"), + "path": str(meta["path"]), + "kind": meta.get("kind", "") or "cowork", + "pinned": bool(meta.get("pinned", False)), + "session_id": meta.get("session_id", ""), + }) + if len(out) >= limit: + break + return out + + def open_thread(self, path: str, kind: str = "cowork") -> bool: + """Open a conversation by file path — the same route the History pane's + own click takes (load_conversation → _on_sidebar_open).""" + from ..core.history import load_conversation + + try: + conv = load_conversation(path) + except Exception: # noqa: BLE001 + return False + self._on_sidebar_open(kind, conv) + return True + + def show_history_pane(self) -> None: + """Bring the full History panel into view (un-collapsing it if needed). + + The rail's recents list is a shortcut, not a replacement: search, + filters, pin, rename, multi-select delete and the context menu all still + live in this panel. + """ + self._history_opened = True + self._show_cowork_tab() + self._sidebar.setVisible(True) + self._set_sidebar_collapsed(False) + + def start_new_chat(self) -> None: + """Start a new thread in the current project and show it. + + Exactly what the History pane's own new-chat button does + (_on_sidebar_new); the rail button is a second door to the same room, + not a second implementation. + """ + self._on_sidebar_new("cowork") def _set_tabs_busy(self, busy: bool) -> None: for idx in (self._cowork_tab_idx, self._graphrag_tab_idx):