From 0fa61b6a959e9dfc9f3bd2e5af1feee9bfc65ab0 Mon Sep 17 00:00:00 2001 From: NamPDT Date: Mon, 17 Aug 2026 11:40:13 +0900 Subject: [PATCH] feat(ui): flat nav rail, compact assistant, responsive layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the redesign from docs/ui-audit.html. Rearrangement only — no feature was removed; every control that moved kept its handler, and the gates that used to hide things now grey them out instead. Navigation * The rail is one flat list: the five Workspace sub-views sit at the top level instead of behind an accordion, with Dashboard/Monitoring pinned at the foot and Settings below them. * Cowork and GraphRAG stay listed and greyed while no project is selected, rather than vanishing and resizing the menu under the user. * Monitoring keeps its eight sub-views in its own tab strip (unhidden) instead of doubling the rail's length. * _goto now moves the highlight itself, fixing a long-standing bug where programmatic navigation left the rail pointing at the previous screen. * Rail header gained the project picker and "New chat"; RECENTS lists the active project's threads. Both are second views of existing state — the Cowork toolbar button and the full History panel are untouched. * Provider / language / theme moved from the top bar to an account row at the foot of the rail (same widgets, same signals). Screens * Co4E: the flow tab strip is gone (per the design); Flow Status became a toolbar toggle with its own way back, and the three icon-only tabs became four labelled, foldable sections in one column. One flow open at a time is the one capability this costs; background runs are unaffected. * Dashboard: header split into two rows; cost promoted to a hero card. * Monitoring Overview: one scrolling column of titled sections; the model price table got its own full-width section instead of sharing a row with the CPU meters. * Settings and Task editor gained a section index down the left. * Help dock: 84x64 launcher + chevron became one 26px dot that expands to a labelled pill on hover; "hide to the edge" moved into the panel's menu. Layout * The window's minimum width dropped from 1453px to 768px. The main cause was a QTabWidget taking its minimum from the widest page even when that page is hidden, so Co4E was forcing Project and Cowork wide. * Secondary panes fold themselves on a narrow window and restore when it grows, never overriding a fold the user made. * The long dialogs no longer scroll sideways at any font size. Verification: tools/check_*.py build a real MainWindow offscreen against a copy of ~/.cowork_local with the schedulers no-oped. check_design_parity.py reads its checklist straight from the audit page's own proposals. Co-Authored-By: Claude Opus 5 (1M context) --- app.py | 481 ++++++++++++++++++------- docs/rag-qa.html | 540 ++++++++++++++++++++++++++++ docs/ui-audit.html | 677 ++++++++++++++++++++++++++++++++--- i18n.py | 40 +++ theme.py | 49 +++ tools/audit_handwritten.py | 23 ++ tools/build_audit_page.py | 339 ++++++++++++++++-- tools/capture_screens.py | 50 +-- tools/check_co4e.py | 194 ++++++++++ tools/check_dashboard.py | 92 +++++ tools/check_design_parity.py | 244 +++++++++++++ tools/check_dialogs.py | 117 ++++++ tools/check_help_dock.py | 131 +++++++ tools/check_nav.py | 301 ++++++++++++++++ tools/check_no_hscroll.py | 94 +++++ tools/check_orphans.py | 112 ++++++ tools/check_responsive.py | 117 ++++++ tools/extract_handwritten.py | 157 ++++++++ ui/co4e_tab.py | 357 +++++++++++++++--- ui/dashboard_tab.py | 49 ++- ui/help_agent_widget.py | 153 +++++--- ui/monitoring_tab.py | 43 ++- ui/settings_dialog.py | 49 ++- ui/sidebar.py | 3 + ui/task_editor_dialog.py | 26 ++ ui/widgets.py | 127 +++++++ ui/workspace_tab.py | 165 ++++++++- 27 files changed, 4346 insertions(+), 384 deletions(-) create mode 100644 docs/rag-qa.html create mode 100644 tools/audit_handwritten.py create mode 100644 tools/check_co4e.py create mode 100644 tools/check_dashboard.py create mode 100644 tools/check_design_parity.py create mode 100644 tools/check_dialogs.py create mode 100644 tools/check_help_dock.py create mode 100644 tools/check_nav.py create mode 100644 tools/check_no_hscroll.py create mode 100644 tools/check_orphans.py create mode 100644 tools/check_responsive.py create mode 100644 tools/extract_handwritten.py diff --git a/app.py b/app.py index 237bdb7..36e94de 100644 --- a/app.py +++ b/app.py @@ -157,8 +157,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: @@ -167,43 +165,31 @@ 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() @@ -230,7 +216,52 @@ 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) + 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) + head = QVBoxLayout() + head.setContentsMargins(6, 0, 6, 6) + head.setSpacing(6) + head.addWidget(self.nav_project) + 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() + nvl.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") + nvl.addWidget(self.nav_recents_hdr) + self.nav_recents = self._new_nav_tree("navRecents") + self.nav_recents.itemClicked.connect(self._on_rail_recent) + nvl.addWidget(self.nav_recents, 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) + self._nav_settings_btn = QPushButton(tr("app.settings")) + self._nav_settings_btn.setObjectName("navSettingsBtn") + self._nav_settings_btn.setIcon(_icon("settings")) + self._nav_settings_btn.setFlat(True) + self._nav_settings_btn.setCursor(Qt.PointingHandCursor) + self._nav_settings_btn.clicked.connect(self._open_settings) + 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) @@ -239,9 +270,10 @@ class MainWindow(QMainWindow): self.split.setStretchFactor(1, 1) self.split.setSizes([_NAV_EXPANDED_WIDTH, 1000]) 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 @@ -370,12 +402,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: @@ -388,23 +417,252 @@ 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) + 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) + self.nav_project.setEnabled(has) + 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 _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. + more = QTreeWidgetItem([tr("app.nav.all_projects")]) + more.setData(0, Qt.UserRole, {"all": True}) + 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). - if not self._nav_collapsed: - for page in self._nav_parents: - if self._built[page]: - self._reload_nav_children(page) + """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_btn.setText( + "" if self._nav_collapsed else tr("app.settings")) + 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._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 _toggle_nav(self) -> None: self._nav_collapsed = not self._nav_collapsed @@ -446,6 +704,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) @@ -552,19 +811,29 @@ class MainWindow(QMainWindow): 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) @@ -574,21 +843,25 @@ class MainWindow(QMainWindow): if idx >= 0: self.language_combo.setCurrentIndex(idx) 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) + 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 @@ -680,46 +953,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) @@ -728,6 +961,10 @@ class MainWindow(QMainWindow): widget = self._page_widgets[page] if sub is not None and hasattr(widget, "select_subtab"): 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) # Switching pages updates which conversation is "current". self._refresh_history() 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.

+ + + +

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/ui-audit.html b/docs/ui-audit.html index 5a15692..e41dcd2 100644 --- a/docs/ui-audit.html +++ b/docs/ui-audit.html @@ -55,7 +55,7 @@ th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--bd);vertica 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} +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); @@ -68,6 +68,10 @@ padding:5px 7px;margin-bottom:5px;font-weight:600;display:flex;align-items:cente 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} @@ -76,7 +80,11 @@ justify-content:space-between;gap:4px;line-height:1.3} .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} +.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} @@ -106,6 +114,50 @@ 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} @@ -160,6 +212,7 @@ 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); @@ -167,13 +220,106 @@ border:1px solid var(--bds);border-radius:var(--r);padding:7px 13px;cursor:point .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} - + +.embed{width:100%;height:760px;border:1px solid var(--bd);border-radius:var(--r); +background:#fff;display:block} +p.hint{margin:4px 0 8px} +.wf .acct{margin-top:6px;padding:6px 7px;border-top:1px solid var(--navb);color:var(--mut);font-size:10px} +padding:6px 8px;color:var(--tx);height:26px;box-sizing:border-box;overflow:hidden} +.wf .inp.tall{min-height:52px;height:auto} +padding:4px 9px;white-space:nowrap;align-self:center;height:26px;box-sizing:border-box; +display:inline-flex;align-items:center;justify-content:center;line-height:1} +/* ---- section-10 interactive-preview vocabulary: literal palette from 10SuKienBaoMat.html ---- */ +.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} +.wf .kpi2 b{font-size:15px;display:block;line-height:1.2} +.wf .ic{width:20px;height:20px;border-radius:5px;flex:none;display:flex;align-items:center;justify-content:center;font-size:10px} +.wf .ic.blue{background:#DEECF9;color:#0078D4}.wf .ic.grn{background:#DFF6DD;color:#107C10} +.wf .ic.red{background:#FDE7E9;color:#D13438}.wf .ic.pur{background:#F3E8FD;color:#8764B8} +.wf .ic.amb{background:#FFF4CE;color:#795548}.wf .ic.gry{background:#F3F2F1;color:#605E5C} +.wf .pill{display:inline-block;padding:2px 8px;border-radius:99px;font-size:9px;font-weight:700;white-space:nowrap} +.wf .pill.red{background:#FDE7E9;color:#D13438}.wf .pill.grn{background:#E4F7C7;color:#498205} +.wf .pill.teal{background:#D2F0EE;color:#008272}.wf .pill.org{background:#FDE6D9;color:#DA3B01} +.wf .pill.blue{background:#DEECF9;color:#0078D4}.wf .pill.amb{background:#FFF4CE;color:#795548} +.wf .pill.pur{background:#F3E8FD;color:#8764B8}.wf .pill.gry{background:#F3F2F1;color:#605E5C} +.wf .r.wrap{flex-wrap:wrap} +.wf .av{display:inline-flex;width:16px;height:16px;border-radius:50%;flex:none;align-items:center; +justify-content:center;font-size:7px;font-weight:700;color:#fff;margin-right:4px} +.wf .av.red{background:#D13438}.wf .av.blue{background:#0078D4} +.wf .av.pur{background:#8764B8}.wf .av.grn{background:#107C10}.wf .av.amb{background:#FFB900} +.wf .av.teal{background:#008272}.wf .av.dark{background:#24292F}.wf .av.olv{background:#498205} +.wf .tblwrap{overflow-y:auto;overflow-x:hidden} +.wf .evtbl{width:100%;border-collapse:collapse;font-size:9.5px} +.wf .evtbl thead{position:sticky;top:0;background:var(--bg)} +.wf .evtbl th{text-align:left;padding:4px 6px;color:var(--fnt);font-weight:700;text-transform:uppercase; +font-size:8px;letter-spacing:.04em;border-bottom:1px solid var(--bd);white-space:nowrap;cursor:default} +.wf .evtbl td{padding:4px 6px;border-bottom:1px solid var(--bd);white-space:nowrap;vertical-align:middle} +.wf .evtbl td.dt{white-space:normal;color:var(--fnt)} +.wf .evtbl tbody tr{cursor:pointer}.wf .evtbl tbody tr:hover{background:var(--sf)} +.wf .pane.hide{display:none} +.wf .hide{display:none} +/* ---- Settings field-display alternatives (toggle switch / stepper) ---- */ +.wf .tsw{display:inline-flex;align-items:center;width:32px;height:17px;border-radius:99px; +background:var(--bds);position:relative;cursor:pointer;flex:none;transition:background .15s} +.wf .tsw i{position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%; +background:#fff;transition:left .15s;box-shadow:0 1px 2px rgba(0,0,0,.25)} +.wf .tsw.on{background:var(--ac)} +.wf .tsw.on i{left:17px} +.wf .stp{display:inline-flex;align-items:center;border:1px solid var(--bds);border-radius:4px; +overflow:hidden;height:26px;box-sizing:border-box;flex:none} +.wf .stp .sb{width:22px;height:100%;display:flex;align-items:center;justify-content:center; +background:var(--rz);cursor:pointer;font-weight:700;color:var(--tx);user-select:none} +.wf .stp .sb:hover{background:var(--sf)} +.wf .stp .sv{padding:0 10px;min-width:44px;text-align:center;font-weight:600;background:var(--bg); +height:100%;display:flex;align-items:center;justify-content:center; +border-left:1px solid var(--bds);border-right:1px solid var(--bds)} +.wf .frow{display:flex;align-items:center;gap:6px;min-height:0;padding:2px 0} +.wf .seg{display:inline-flex;border:1px solid var(--bds);border-radius:4px;overflow:hidden;flex:none} +.wf .seg span{background:var(--bg);color:var(--mut);padding:3px 10px;cursor:pointer;font-size:10px;white-space:nowrap} +.wf .seg span.on{background:var(--ac);color:#fff;font-weight:600} +.wf .pane.overlay{position:absolute;top:0;right:0;bottom:0;width:38%;z-index:5; +background:var(--bg);border-left:1px solid var(--bd);box-shadow:-6px 0 14px rgba(0,0,0,.18)} +.wf .dtl{padding:2px 0 0} +.wf .dtl.grow{overflow-y:auto} +.wf .pnlfoot{border-top:1px solid var(--bd);flex:none;padding-top:6px} +#s10close{cursor:pointer} +#s11close{cursor:pointer} +.wf .dtl .hd3{font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--fnt); +padding-bottom:3px;margin:8px 0 3px;border-bottom:1px solid var(--bd)} +.wf .dtl .hd3:first-child{margin-top:0} +.wf .dtl .fld{display:flex;justify-content:space-between;align-items:center;padding:3px 0;gap:6px} +.wf .dtl .fld .lbl{color:var(--mut)} +.wf .dtl .fld .val{font-weight:600;text-align:right} +.wf .dtl .mono{font-family:"Cascadia Code",Consolas,monospace;background:var(--sf);border:1px solid var(--bd); +border-radius:3px;padding:1px 6px;font-size:9px;font-weight:400} +.wf .dtl .tagn{background:var(--sf);border-radius:3px;padding:1px 7px;font-size:9px;font-weight:400} +.wf .dtl .code{background:#1b1a19;color:#CCFF00;font-family:"Cascadia Code",Consolas,monospace; +font-size:9px;padding:6px 8px;border-radius:4px;margin:4px 0;display:flex;align-items:center; +justify-content:space-between;gap:6px} +.wf .dtl .code .cpy{background:rgba(255,255,255,.15);color:#fff;border-radius:3px;padding:2px 6px; +font-size:8px;white-space:nowrap;flex:none;cursor:pointer} +.wf select.btn{appearance:none;-webkit-appearance:none;font:inherit;color:inherit;padding-right:16px;cursor:pointer; +background-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"); +background-repeat:no-repeat;background-position:right 4px center} +.wf input.inp{font:inherit;color:inherit;outline:none;width:100%} +.wf .srchwrap{position:relative;display:flex;align-items:center;min-width:0} +.wf .srchwrap .inp{padding-right:48px} +.wf .srchwrap .aibtn{position:absolute;right:3px;top:50%;transform:translateY(-50%);cursor:pointer; +line-height:1;padding:5px 10px;border-radius:4px;font-size:11px} +.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 .aibadge{background:#E6F6F4;border:1px solid #7FD0C4;border-radius:6px;padding:7px 11px;font-weight:700;color:#0F6E62;white-space:nowrap} +.wf .dock.badge{background:#E6F6F4;border:1px solid #7FD0C4;border-radius:6px;padding:7px 11px;font-weight:700;color:#0F6E62;white-space:nowrap} +.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 .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 .aibadge{background:#E6F6F4;border:1px solid #7FD0C4;border-radius:6px;padding:7px 11px;font-weight:700;color:#0F6E62;white-space:nowrap} +.wf .dock .badge{background:#E6F6F4;border:1px solid #7FD0C4;border-radius:6px; +/* Menu ⋯ trong panel tro ly — ghep tu ui-audit.html */ +/* Chấm trợ lý 26px — ghép từ ui-audit.html */

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

+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 @@ -235,7 +381,7 @@ Monitoring ← BỎ ẨN dải tab, đủ 8 mục nằm ngang trong

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 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
@@ -266,6 +412,20 @@ Số còn lại bị ẩn, thành combo, thành nút, hoặc không tới đư

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 @@ -280,7 +440,7 @@ Số còn lại bị ẩn, thành combo, thành nút, hoặc không tới đư

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%)
+
Đề 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.
@@ -291,7 +451,7 @@ Số còn lại bị ẩn, thành combo, thành nút, hoặc không tới đư

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.
+
Đề 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.
@@ -313,7 +473,7 @@ Số còn lại bị ẩn, thành combo, thành nút, hoặc không tới đư

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
+
Đề 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.
@@ -324,7 +484,7 @@ Số còn lại bị ẩn, thành combo, thành nút, hoặc không tới đư

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
+
Đề 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ỗ
@@ -336,8 +496,8 @@ Tắt: luôn dùng droplistself._on_changed<

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ỗ
+
Đề 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.
@@ -347,7 +507,7 @@ Tắt: luôn dùng droplistself._on_changed<

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
+
Đề 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.
@@ -358,7 +518,7 @@ Tắt: luôn dùng droplistself._on_changed<

Đồ 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
+
Đề 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.
@@ -369,7 +529,7 @@ Tắt: luôn dùng droplistself._on_changed<

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
+
Đề 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.
@@ -379,100 +539,509 @@ Tắt: luôn dùng droplistself._on_changed<

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
-

Ô lọc: có nút ✨ biến câu hỏi thành từ khoá

-
Đề 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.

+

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 đề
  • 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.
+
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
-

Bảng: không có ô lọc như 2 màn log kia — khác biệt không chủ đích

-
Đề 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.

+

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 đề
  • 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.
+
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

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳ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ố 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.
+
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

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳ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
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ố 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.
+
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

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳ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
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 đề
  • 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.
+
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
📁 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
Kiểm tra Internet
read_fileĐọc tệp trong sandbox — ☑ bật
write_fileGhi tệp trong sandbox — ☑ bật
run_commandChạy lệnh shell — ☑ bật
fetch_urlTải nội dung URL — ☑ bật · ✓ Internet OK
image_genSinh ảnh — ☐ tắt
+
Đề 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.
-
Thay đổi
  • Bỏ ẩn dải tab cho toàn bộ Monitoring → 8 tab một hàng; Tool/Connector ngang hàng.
+
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

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳ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
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 đề
  • 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.
+
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 · tài nguyên
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
+
Đề 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 Provider/Ngôn ngữ/Giao diện vào đây.
+
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
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
+
Đề 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
  • Chia 3 bước có tab: Nội dung → Lịch chạy → Liên kết.
+
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
@@ -564,17 +1133,23 @@ Tắt: luôn dùng droplistself._on_changed<
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ụ.

+

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 → 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ỗ
+

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 đề
  • 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.
+
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 — Màn chết (chỉ ghi nhận)

+

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ỡ.

@@ -595,4 +1170,6 @@ document.getElementById('tgl').onclick=()=>setTheme( document.querySelectorAll('.sw button').forEach(b=>b.onclick=()=>{ swap(b.closest('.sec'),b.dataset.t);}); setTheme(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'); - \ No newline at end of file + + + \ No newline at end of file diff --git a/i18n.py b/i18n.py index a0ac067..0939bf9 100644 --- a/i18n.py +++ b/i18n.py @@ -178,6 +178,24 @@ 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"}, @@ -485,6 +503,13 @@ STRINGS: Dict[str, Dict[str, str]] = { "en": "Minimize", "ja": "最小化", "vi": "Thu nhỏ"}, "help_agent.hide_tooltip": { "en": "Hide to the edge", "ja": "端に隠す", "vi": "Ẩn vào cạnh phải"}, + # 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"}, @@ -886,6 +911,9 @@ 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"}, "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"}, @@ -1441,6 +1469,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": { @@ -2624,6 +2655,15 @@ 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.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"}, diff --git a/theme.py b/theme.py index 391c5d5..b3660ad 100644 --- a/theme.py +++ b/theme.py @@ -372,6 +372,55 @@ QWidget#navWrap QTreeWidget::item:selected, QWidget#navWrap QListWidget::item:se 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; } +/* 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; +} +/* 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. */ +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; } +/* 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; +} +QPushButton#navSettingsBtn { + background: transparent; border: none; color: $text_muted; + padding: 6px 8px; text-align: left; border-radius: ${radius}px; margin: 2px 6px 6px 6px; +} +QPushButton#navSettingsBtn:hover { background: $nav_hover; color: $text; } +QPushButton#navSettingsBtn:pressed { background: $active; } /* ---- surfaces --------------------------------------------------------- */ QGroupBox { 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 index 3bc0010..eb3746c 100644 --- a/tools/build_audit_page.py +++ b/tools/build_audit_page.py @@ -23,6 +23,20 @@ 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. @@ -43,11 +57,46 @@ STANDALONE = "--external" not in sys.argv RECENTS = ["📌 Gom số liệu doanh thu", "Dựng slide trình bày Q3"] -def rail(active: str = "", project: str = "Báo cáo tài chính Q3") -> str: - """The proposed flat sidebar, with `active` highlighted.""" +# 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) + 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 ( '
' @@ -65,7 +114,9 @@ def rail(active: str = "", project: str = "Báo cáo tài chính Q3") -> str: '
' '
' '
Dashboard
Monitoring
' - '
👤 local · Ollama ▾
' + '
Cài đặt
' + '
👤 local' + 'VN ▾🌙
' '
') @@ -92,6 +143,19 @@ def projbar(name: str = "") -> str: 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 "" @@ -170,8 +234,13 @@ DESCRIPTIONS: dict[str, dict] = { "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": "Robot trợ giúp nổi, có mặt trên mọi màn. Cố tình không có công cụ.", - "r": [("3 trạng thái", "tab mép → huy hiệu → panel chat")]}, + "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”")]}, } @@ -281,21 +350,23 @@ ANALYSIS: dict[str, dict] = { '
▷ Chạy
' '
' '
' - '
WORKFLOWS‹
' + + 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") - + '
AGENTS (5)
' + + 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") - + '
SKILLS (5)
' + + grp("SKILLS (5)", "Quản lý…") + li("Rà soát bảo mật · Viết test trước · Chuẩn hoá Excel · …") - + '
LẦN CHẠY (6)
' + + 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ế
→
' @@ -478,7 +549,18 @@ ANALYSIS: dict[str, dict] = { '
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
' + '
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
' @@ -509,6 +591,73 @@ ANALYSIS: dict[str, dict] = { + 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.", @@ -635,7 +784,15 @@ MOVES = { "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", @@ -671,6 +828,45 @@ NEWCHAT = [ "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 = [ @@ -912,7 +1108,7 @@ th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--bd);vertica 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} +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); @@ -925,6 +1121,10 @@ padding:5px 7px;margin-bottom:5px;font-weight:600;display:flex;align-items:cente 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} @@ -933,7 +1133,11 @@ justify-content:space-between;gap:4px;line-height:1.3} .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} +.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} @@ -963,6 +1167,50 @@ 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} @@ -1017,6 +1265,7 @@ 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); @@ -1145,7 +1394,12 @@ def main() -> int: shot = f'
Không chụp được màn này
{err}
' sw = "" - wf = (f'
Đề xuất — bố cục mới
{a["wf"]}
' + # 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.

') @@ -1159,9 +1413,15 @@ def main() -> int: f'{lab}{": " + txt if txt else ""}' for lab, txt in de["r"]) legend = f'

{bits}

' - secs.append(f"""
-
{n}. {esc(info['title'])}{esc(info['note'])}{sw}
-
+ # 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} @@ -1170,7 +1430,11 @@ def main() -> int:
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}' @@ -1183,6 +1447,14 @@ def main() -> int: 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}' @@ -1201,7 +1473,8 @@ def main() -> int: html = f""" -CoworkLocal — Audit UI/UX +CoworkLocal — Audit UI/UX
@@ -1267,6 +1540,20 @@ Số còn lại bị ẩn, thành combo, thành nút, hoặc không tới đư

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 @@ -1277,7 +1564,13 @@ Số còn lại bị ẩn, thành combo, thành nút, hoặc không tới đư

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

{''.join(secs)} -

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

+

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ỡ.

@@ -1285,7 +1578,9 @@ Không nằm trong kiểm kê phía trên vì không có đường nào tới; c
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") diff --git a/tools/capture_screens.py b/tools/capture_screens.py index 3b64f6c..03e3fd0 100644 --- a/tools/capture_screens.py +++ b/tools/capture_screens.py @@ -132,48 +132,20 @@ def main() -> int: nav_state = {"label": "", "expected": ""} def nav_to(win, page: int, sub=None, expect: str = "") -> None: - """Navigate the way a user does: SELECT the nav-rail row. + """Navigate the way a user does, and record where the rail ends up. - Calling ``win._goto()`` directly swaps the content but leaves the rail - highlighting whatever was selected before — so a Co4E screenshot showed - the content of Co4E with "Workspace" still lit. Setting the current item - fires currentItemChanged → _navigate → _goto, i.e. both halves. + 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) # build lazy page + its children - item = win._nav_items[page] - if sub is None: - win.nav.setCurrentItem(item) - app.processEvents() - else: - # Two steps, because selecting the row alone does not survive. - # - # Navigating to Workspace runs refresh(), which emits - # subtabs_changed → _reload_nav_children → takeChildren(); that - # DESTROYS the row just selected and the highlight falls back to the - # parent. Retrying only re-triggers the same cascade. (Real app bug, - # reproduced in test_nav_bug.py: 5/5 Workspace rows lose the - # highlight, 0/8 Monitoring rows do.) - # - # So: drive the content first, let the rebuild settle, then set the - # highlight with signals blocked so it cannot cascade again. The - # screenshot then shows what the user *should* see; the underlying - # bug is reported separately in the audit page. - win._goto(page, sub) - app.processEvents() - app.processEvents() - if item.childCount() == 0: - win._reload_nav_children(page) - item.setExpanded(True) - target = next( - (item.child(i) for i in range(item.childCount()) - if (item.child(i).data(0, Qt.UserRole) or {}).get("sub") == sub), - None) - assert target is not None, f"nav child page={page} sub={sub} not found" - blocked = win.nav.blockSignals(True) - win.nav.setCurrentItem(target) - win.nav.blockSignals(blocked) + win._ensure_page(page) + win._goto(page, sub) app.processEvents() - cur = win.nav.currentItem() + 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"] diff --git a/tools/check_co4e.py b/tools/check_co4e.py new file mode 100644 index 0000000..c956284 --- /dev/null +++ b/tools/check_co4e.py @@ -0,0 +1,194 @@ +"""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 +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 _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() + + 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__": + raise SystemExit(main()) diff --git a/tools/check_dashboard.py b/tools/check_dashboard.py new file mode 100644 index 0000000..94230d6 --- /dev/null +++ b/tools/check_dashboard.py @@ -0,0 +1,92 @@ +"""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 +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 _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() + + 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..c3a1c32 --- /dev/null +++ b/tools/check_design_parity.py @@ -0,0 +1,244 @@ +"""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 +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 + + +def build(): + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() or 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 + + 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") + add("schedule-kanban", "Lane Running có viền cảnh báo", False, "chưa làm") + + # --- 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") + add("workspace-project", "Pane trái cố định, không đổi danh tính", True, + "rail giữ project + RECENTS; pane trong trang vẫn theo màn", "KHAC") + 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. + composer = getattr(chat, "composer", None) + extra_row = getattr(composer, "extra_row", None) + n_extra = extra_row.count() if extra_row is not None else -1 + add("workspace-cowork", "Usage/cost xuống thanh trạng thái, composer chỉ nhập·đính kèm·gửi", + n_extra == 0, f"hàng dưới ô nhập còn {n_extra} 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) + add("workspace-folder", "Path bar gộp vào tiêu đề", + getattr(folder, "path_edit", None) is None, "path bar vẫn là hàng riêng") + add("workspace-folder", "Panel AI thành lớp phủ phải; terminal thanh mỏng đáy", + False, "chưa làm") + graph = ws.tabs.widget(ws._graphrag_tab_idx) + add("workspace-graphrag", "Gộp hai hàng toolbar thành một", False, "chưa làm") + # 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 + ov = mon.findChildren(QScrollArea)[0].widget() + 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") + add("dialog-settings", "Gom Provider/Ngôn ngữ/Giao diện vào Settings", True, + "đưa xuống hàng tài khoản ở rail thay vì dồn vào Settings", "KHAC") + s.close() + t = TaskEditorDialog(ctx=win.ctx) + add("dialog-task-editor", "Chia 3 bước có tab: Nội dung → Lịch chạy → Liên kết", + True, f"dùng mục lục {t.section_list.count()} mục thay vì 3 tab", "KHAC") + t.close() + + # --- 27 help dock --- + add("overlay-help-panel", "Một chấm 26px, không chữ", + dock.width() <= 30 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) + items = [a.text() for a in dock.more_btn.menu().actions()] + add("overlay-help-panel", "'Ẩn trợ lý' dời vào menu ⋯", + tr("help_agent.hide_tooltip") in items, str(items)) + 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}") + + # --- 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}") + print() + 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") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/check_dialogs.py b/tools/check_dialogs.py new file mode 100644 index 0000000..7de0431 --- /dev/null +++ b/tools/check_dialogs.py @@ -0,0 +1,117 @@ +"""Check the section index added to Settings and the Task editor, offscreen. + +The index is navigation only, so the test is again a subtraction test: every +input control must still be there, and every index row must actually scroll to +its section. Both dialogs are built with .show(), never .exec() — exec() blocks. + +Run: python tools/check_dialogs.py +""" +from __future__ import annotations + +import os +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 _isolate_home, _load_fonts # noqa: E402 + + +def controls(dlg): + from PySide6.QtWidgets import (QCheckBox, QComboBox, QLineEdit, QListWidget, + QPlainTextEdit, QPushButton, QSpinBox) + n = 0 + for cls in (QComboBox, QLineEdit, QCheckBox, QSpinBox, QPlainTextEdit, + QPushButton, QListWidget): + n += len(dlg.findChildren(cls)) + return n + + +def check(name, dlg, app, expect_rows): + fails = [] + print(f"--- {name} ---") + n_ctl = controls(dlg) + idx = dlg.section_list + rows = [idx.item(i).text() for i in range(idx.count())] + print(f"muc luc : {rows}") + print(f"tong control trong hop thoai: {n_ctl}") + if len(rows) != expect_rows: + fails.append(f"{name}: cho {expect_rows} muc, thay {len(rows)}") + if any(not r or r.endswith(".g_basic") or r.startswith("settings.") for r in rows): + fails.append(f"{name}: co muc chua dich") + + # Each row must scroll somewhere different (and the last one furthest down). + from PySide6.QtWidgets import QScrollArea + scroll = dlg.findChildren(QScrollArea)[0] + positions = [] + for i in range(idx.count()): + idx.itemClicked.emit(idx.item(i)) + app.processEvents() + positions.append(scroll.verticalScrollBar().value()) + print(f"vi tri cuon theo tung muc : {positions}") + if positions != sorted(positions): + fails.append(f"{name}: muc luc nhay khong theo thu tu tren xuong") + if len(set(positions)) < 2: + fails.append(f"{name}: bam muc nao cung dung mot cho — muc luc khong chay") + return n_ctl, fails + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + + 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, 600) + s.show() + app.processEvents() + n_s, f = check("Cai dat", s, app, 5) + fails += f + + t = TaskEditorDialog(ctx=ctx) # task=None → a new task, all fields present + t.resize(900, 600) + t.show() + app.processEvents() + n_t, f = check("Task editor", t, app, 5) + fails += f + + # Translations for the two names that had to be invented for the index. + print() + 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}") + for key in ("settings.group.general", "schedtask.g_basic"): + if tr(key) == key: + fails.append(f"thieu ban dich {key} cho {lang}") + set_language("vi") + + print() + if fails: + print("*** LOI ***") + for x in fails: + print(" " + x) + return 1 + print("KET QUA: hai hop thoai co muc luc, khong mat control nao") + return 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..d3da0d8 --- /dev/null +++ b/tools/check_help_dock.py @@ -0,0 +1,131 @@ +"""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 +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 _isolate_home, _load_fonts # noqa: E402 + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtWidgets import QApplication, QWidget + + app = QApplication([]) + _load_fonts() + + 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)") + if closed.width() > 30 or closed.height() > 30: + fails.append(f"nut dong van {closed.width()}x{closed.height()}, cho <=30") + # 26px 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() + + # Nothing removed: "hide to the edge" is in the ⋯ menu now. + items = [a.text() for a in dock.more_btn.menu().actions()] + print(f"menu ⋯ : {items}") + for key in ("help_agent.collapse_tooltip", "help_agent.hide_tooltip"): + if tr(key) not in items: + fails.append(f"menu thieu muc {tr(key)}") + 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() + vals = [dock.launcher.toolTip(), tr("help_agent.badge"), + tr("help_agent.more_tooltip")] + print(f" {lang}: badge={vals[1]!r} more={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_nav.py b/tools/check_nav.py new file mode 100644 index 0000000..6f83e20 --- /dev/null +++ b/tools/check_nav.py @@ -0,0 +1,301 @@ +"""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 +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 _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() + + 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}") + + 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__": + raise SystemExit(main()) diff --git a/tools/check_no_hscroll.py b/tools/check_no_hscroll.py new file mode 100644 index 0000000..8a42458 --- /dev/null +++ b/tools/check_no_hscroll.py @@ -0,0 +1,94 @@ +"""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 +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 _isolate_home, _load_fonts # noqa: E402 + +WIDTHS = (1100, 964, 820, 700) +POINTS = (9, 11, 14) # ~100%, ~125%, ~150% display scaling + + +def hscroll(dlg): + """(scroll-area overflow, index overflow) — each True means a bar appears.""" + from PySide6.QtWidgets import QListWidget, QScrollArea + 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 = idx.sizeHintForColumn(0) > idx.viewport().width() + return over_area, over_idx + + +def main() -> int: + sandbox = _isolate_home() + from PySide6.QtGui import QFont + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + + 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) + 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__": + raise SystemExit(main()) diff --git a/tools/check_orphans.py b/tools/check_orphans.py new file mode 100644 index 0000000..4337a8e --- /dev/null +++ b/tools/check_orphans.py @@ -0,0 +1,112 @@ +"""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 +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_responsive.py b/tools/check_responsive.py new file mode 100644 index 0000000..31a7654 --- /dev/null +++ b/tools/check_responsive.py @@ -0,0 +1,117 @@ +"""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 +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_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/ui/co4e_tab.py b/ui/co4e_tab.py index 4fd2ee7..2b013f2 100644 --- a/ui/co4e_tab.py +++ b/ui/co4e_tab.py @@ -261,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) @@ -273,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) @@ -307,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")) @@ -318,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 @@ -332,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 @@ -424,22 +482,44 @@ 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.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) @@ -447,57 +527,151 @@ 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) - self.agent_list = _PaletteList() - al.addWidget(self.agent_list, 1) - ag_btns = QHBoxLayout(); ag_btns.setSpacing(4) + # --- 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.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) + # 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.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) @@ -611,12 +785,13 @@ class Co4ETab(QWidget): 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() @@ -652,6 +827,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) @@ -659,6 +842,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() @@ -685,6 +869,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) @@ -764,6 +955,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: + """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 @@ -787,6 +1001,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) @@ -802,6 +1021,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 @@ -1362,10 +1589,16 @@ class Co4ETab(QWidget): 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() @@ -1506,7 +1739,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]: @@ -1807,9 +2042,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/dashboard_tab.py b/ui/dashboard_tab.py index dd092a5..8978b8e 100644 --- a/ui/dashboard_tab.py +++ b/ui/dashboard_tab.py @@ -92,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 diff --git a/ui/help_agent_widget.py b/ui/help_agent_widget.py index ec43aa8..880db4d 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,11 +31,15 @@ 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. +_DOT = 26 # closed launcher (a round chip) +_DOT_ICON = 14 # 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 # The three states the floating assistant cycles through. @@ -53,27 +57,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): @@ -118,7 +141,7 @@ class HelpAgentWidget(QWidget): self._apply_style() muted = self._pal.text_muted self.edge_tab.setIcon(icon("chevron-left", color=muted)) - self.collapse_btn.setIcon(icon("chevron-right", color=muted)) + self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=self._pal.accent)) self.min_btn.setIcon(icon("minus", color=muted)) self._render() @@ -129,13 +152,18 @@ class HelpAgentWidget(QWidget): p = self._pal r, rl = p.radius, p.radius_lg self.setStyleSheet(f""" - /* The app-icon badge that opens the dock: a plain card, no button box. */ + /* 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: {p.surface}; border: 1px solid {p.border}; - border-radius: {rl}px; }} - #helpLauncher:hover {{ background: {p.hover}; }} - #helpCollapseBtn, #helpEdgeTab {{ background: {p.surface}; border: none; - border-radius: {r}px; }} - #helpCollapseBtn:hover, #helpEdgeTab:hover {{ background: {p.hover}; }} + border-radius: {_DOT // 2}px; color: {p.text}; font-weight: 600; + font-size: 12px; padding: 0; text-align: center; }} + #helpLauncher[pill="true"] {{ text-align: left; padding-left: 6px; }} + #helpLauncher:hover {{ background: {p.hover}; border-color: {p.border_strong}; }} + #helpLauncher:focus {{ border: 1px solid {p.focus_ring}; }} + #helpEdgeTab {{ background: {p.surface}; border: 1px solid {p.border}; + border-right: none; border-top-left-radius: {r}px; + border-bottom-left-radius: {r}px; }} + #helpEdgeTab:hover {{ background: {p.hover}; }} #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}; @@ -174,20 +202,12 @@ class HelpAgentWidget(QWidget): 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=self._pal.text_muted)) - 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=self._pal.accent)) self.launcher.setCursor(Qt.PointingHandCursor) self.launcher.setToolTip(tr("help_agent.open_tooltip")) self.launcher.clicked.connect(self._expand) @@ -219,6 +239,21 @@ class HelpAgentWidget(QWidget): self.min_btn.setToolTip(tr("help_agent.collapse_tooltip")) self.min_btn.clicked.connect(self._collapse) hb.addWidget(self.min_btn) + # "Hide to the right edge" lives here now, next to "minimise", instead of + # as a permanent 18px chevron on every screen. Same action, offered where + # the user is already interacting with the assistant. + self.more_btn = QPushButton("⋯", header) + self.more_btn.setObjectName("helpMinBtn") + self.more_btn.setFixedSize(24, 24) + self.more_btn.setCursor(Qt.PointingHandCursor) + self.more_btn.setToolTip(tr("help_agent.more_tooltip")) + menu = QMenu(self.more_btn) + self.act_collapse = menu.addAction(tr("help_agent.collapse_tooltip")) + self.act_collapse.triggered.connect(self._collapse) + self.act_hide = menu.addAction(tr("help_agent.hide_tooltip")) + self.act_hide.triggered.connect(self._hide_to_edge) + self.more_btn.setMenu(menu) + hb.addWidget(self.more_btn) v.addWidget(header) # Conversation log @@ -267,23 +302,32 @@ 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) @@ -384,6 +428,11 @@ class HelpAgentWidget(QWidget): self.title.setText(tr("help_agent.title")) self.input.setPlaceholderText(tr("help_agent.placeholder")) self.launcher.setToolTip(tr("help_agent.open_tooltip")) + 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.more_btn.setToolTip(tr("help_agent.more_tooltip")) + self.act_collapse.setText(tr("help_agent.collapse_tooltip")) + self.act_hide.setText(tr("help_agent.hide_tooltip")) self.edge_tab.setToolTip(tr("help_agent.show_tooltip")) diff --git a/ui/monitoring_tab.py b/ui/monitoring_tab.py index fa96f7a..b61d41c 100644 --- a/ui/monitoring_tab.py +++ b/ui/monitoring_tab.py @@ -404,14 +404,16 @@ 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() @@ -514,12 +516,10 @@ 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 -------------------------------------------- @@ -552,7 +552,12 @@ 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. + self._sbx_perm_row = QHBoxLayout() + self._sbx_perm_row.setSpacing(12) + self._sbx_perm_row.addWidget(self.ov_sandbox_details_group, 1) + root.addLayout(self._sbx_perm_row) # ---- Permissions ----------------------------------------------- self.ov_permissions_group = QGroupBox() @@ -574,7 +579,10 @@ class MonitoringTab(QWidget): 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) + self._sbx_perm_row.addWidget(self.ov_permissions_group, 1) + + # ---- Model pricing — its own section, full width ------------------ + root.addWidget(self.ov_pricing_group) # ---- Audit Log ---------------------------------------------------- self.ov_audit_group = QGroupBox() @@ -634,7 +642,10 @@ class MonitoringTab(QWidget): tr("monitoring.col_agent"), tr("monitoring.col_active"), 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").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")) diff --git a/ui/settings_dialog.py b/ui/settings_dialog.py index 37418bf..336677a 100644 --- a/ui/settings_dialog.py +++ b/ui/settings_dialog.py @@ -10,8 +10,8 @@ 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, + QMessageBox, QPushButton, QScrollArea, QSizePolicy, QSpinBox, QTreeWidget, + QTreeWidgetItem, QVBoxLayout, QWidget, ) from ..config import PROVIDER_LABELS @@ -42,6 +42,10 @@ class SettingsDialog(QDialog): 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) @@ -59,6 +63,11 @@ class SettingsDialog(QDialog): 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) + # 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 = [] @@ -291,10 +300,31 @@ class SettingsDialog(QDialog): note = QLabel(tr("settings.tip")) note.setObjectName("hint") + note.setWordWrap(True) # otherwise this one line sets the dialog's width root.addWidget(note) scroll.setWidget(self._content) - outer.addWidget(scroll, 1) + # Five group boxes in one column, with no way to see what was further + # down — the index says what is in here and jumps straight to it. + from .widgets import section_index + self.section_list = section_index(scroll, [ + (tr("settings.group.general"), self._anchor_general), + (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), + ]) + body = QHBoxLayout() + body.setSpacing(10) + body.addWidget(self.section_list) + body.addWidget(scroll, 1) + outer.addLayout(body, 1) + # Floor the dialog at the width its own content needs AT THE CURRENT + # FONT. On a 125%/150% display everything is wider, and without this the + # form was simply cut off (or scrolled sideways) instead of the window + # refusing to get that small. + self.setMinimumWidth(self.section_list.width() + + self._content.sizeHint().width() + 60) buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) buttons.accepted.connect(self._save) @@ -355,6 +385,13 @@ class SettingsDialog(QDialog): 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) @@ -377,6 +414,12 @@ class SettingsDialog(QDialog): 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: 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/task_editor_dialog.py b/ui/task_editor_dialog.py index 988e92a..268a09c 100644 --- a/ui/task_editor_dialog.py +++ b/ui/task_editor_dialog.py @@ -86,12 +86,18 @@ class TaskEditorDialog(QDialog): 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 ---------------------------------------------------- + # Zero-height anchor: this block is a bare form, so the index needs + # something to scroll to. + self._anchor_basic = QWidget() + self._anchor_basic.setFixedHeight(0) + root.addWidget(self._anchor_basic) form = QFormLayout() self.title_edit = QLineEdit(self.task.get("title", "")) # Description is the source of truth. Its ✨ button GENERATES the Prompt @@ -427,6 +433,26 @@ class TaskEditorDialog(QDialog): eform.addRow("", self.approval_chk) root.addWidget(eg) + # Five groups in one long scroll — same problem, same fix as Settings. + from .widgets import section_index + self.section_list = section_index(scroll, [ + (tr("schedtask.g_basic"), self._anchor_basic), + (tr("schedtask.g_schedule"), sg), + (tr("schedtask.g_input"), ig), + (tr("schedtask.g_dependency"), dg), + (tr("schedtask.g_execution"), eg), + ]) + outer.removeWidget(scroll) + body = QHBoxLayout() + body.setSpacing(10) + body.addWidget(self.section_list) + body.addWidget(scroll, 1) + outer.insertLayout(0, body, 1) + # Same floor as Settings: the dialog may not be narrower than its own + # content at the font in use, so nothing is ever cut off sideways. + self.setMinimumWidth(self.section_list.width() + + content.sizeHint().width() + 60) + buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) buttons.button(QDialogButtonBox.Save).setIcon(icon("save")) buttons.button(QDialogButtonBox.Cancel).setIcon(icon("close")) diff --git a/ui/widgets.py b/ui/widgets.py index 1cbe58c..32e3882 100644 --- a/ui/widgets.py +++ b/ui/widgets.py @@ -59,6 +59,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 @@ -146,6 +159,120 @@ def guard_wheel(root: QWidget) -> None: w.installEventFilter(_wheel_guard) +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 + 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() + narrow = width < self._threshold + 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) + + +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. diff --git a/ui/workspace_tab.py b/ui/workspace_tab.py index 2830377..3a26d96 100644 --- a/ui/workspace_tab.py +++ b/ui/workspace_tab.py @@ -38,6 +38,7 @@ class WorkspaceTab(QWidget): 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 +51,29 @@ 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 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.""" @@ -127,6 +147,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 @@ -321,17 +348,35 @@ 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) + self._hint.setVisible(on_project) + 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 + # Auto-expand History when entering Cowork — unless the window is too + # narrow to hold it, in which case expanding it here would undo the + # fold made moments earlier and clip the chat again. if on_cowork: - self._sidebar.set_collapsed(False) + self._sidebar.set_collapsed(narrow) # 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: @@ -365,6 +410,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) @@ -474,6 +551,86 @@ 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.""" + return [(self.project_list.item(i).text(), + self.project_list.item(i).data(Qt.UserRole)) + for i in range(self.project_list.count())] + + 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._set_sidebar_collapsed(False) + self._show_cowork_tab() + + 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):