feat(ui): flat nav rail, compact assistant, responsive layouts

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) <noreply@anthropic.com>
This commit is contained in:
NamPDT
2026-08-17 11:40:13 +09:00
co-authored by Claude Opus 5
parent 291a611737
commit 0fa61b6a95
27 changed files with 4346 additions and 384 deletions
+359 -122
View File
@@ -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()
+540
View File
@@ -0,0 +1,540 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Tìm hiểu RAG — Hỏi &amp; Đáp</title>
<style>
:root{
--navy:#1B3C87; --blue:#0A4EA3; --acc:#1565C0; --acc2:#4A90D9;
--bg:#fff; --sf:#F7F9FC; --card:#fff; --bd:#E3E8EF; --bds:#CBD5E1;
--tx:#2B3542; --mut:#5A6675; --fnt:#8A94A3;
--ok:#1B7A3D; --okbg:#E8F5EC; --warn:#B26A00; --warnbg:#FDF3E3;
--bad:#C0392B; --badbg:#FCEDEC; --r:10px;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--tx);
font:15px/1.6 "Segoe UI Variable Text","Segoe UI",system-ui,sans-serif}
.bar{background:var(--navy);color:#fff;padding:12px 28px;font-weight:700;font-size:16px;
display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:9}
.bar .sub{font-weight:400;opacity:.85;font-size:13px}
.wrap{max-width:1060px;margin:0 auto;padding:28px 28px 80px}
h1{color:var(--blue);font-size:30px;margin:14px 0 6px;letter-spacing:-.02em}
h2{color:var(--blue);font-size:20px;margin:40px 0 4px;padding-top:18px;
border-top:2px solid var(--bd)}
.lead{color:var(--mut);margin:0 0 8px}
.qa{border:1px solid var(--bd);border-radius:var(--r);margin:14px 0;background:var(--card);
box-shadow:0 1px 2px rgba(16,32,64,.04)}
.q{padding:13px 18px;font-weight:700;color:var(--blue);font-size:15.5px;
display:flex;gap:10px;align-items:flex-start}
.q .n{background:var(--acc);color:#fff;border-radius:5px;min-width:26px;height:22px;
display:inline-flex;align-items:center;justify-content:center;font-size:12px;flex:none}
.a{padding:0 18px 15px 54px;color:var(--tx)}
.a p{margin:0 0 8px}
.a ul{margin:6px 0;padding-left:20px}.a li{margin:3px 0}
b{color:var(--blue)}
code{font:13px "Cascadia Code",Consolas,monospace;background:var(--sf);
border:1px solid var(--bd);border-radius:4px;padding:1px 5px;color:#0F3D6E}
.note{border-left:4px solid var(--acc);background:#EAF2FC;border-radius:6px;
padding:10px 14px;margin:10px 0}
.note.ok{border-left-color:var(--ok);background:var(--okbg)}
.note.warn{border-left-color:var(--warn);background:var(--warnbg)}
.note.bad{border-left-color:var(--bad);background:var(--badbg)}
figure{margin:12px 0;padding:14px;background:var(--sf);border:1px solid var(--bd);
border-radius:var(--r)}
figure svg{display:block;width:100%;height:auto}
figcaption{color:var(--fnt);font-size:12.5px;margin-top:8px;text-align:center}
table{width:100%;border-collapse:collapse;margin:10px 0;font-size:14px}
th,td{text-align:left;padding:7px 11px;border-bottom:1px solid var(--bd);vertical-align:top}
th{color:var(--mut);font-size:12px;text-transform:uppercase;letter-spacing:.05em}
.toc{background:var(--sf);border:1px solid var(--bd);border-radius:var(--r);padding:14px 20px}
.toc ol{margin:6px 0;padding-left:20px;columns:2;column-gap:32px;font-size:14px}
.toc a{color:var(--tx);text-decoration:none}.toc a:hover{color:var(--acc)}
@media(max-width:820px){.toc ol{columns:1}.a{padding-left:18px}}
</style>
</head>
<body>
<div class="bar"><span>Tìm hiểu RAG — Hỏi &amp; Đáp</span>
<span class="sub">Chuẩn bị cho phần Q&amp;A sau buổi trình bày</span></div>
<div class="wrap">
<h1>Những câu hay được hỏi nhất</h1>
<p class="lead">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.</p>
<div class="toc"><b>Nội dung</b>
<ol>
<li><a href="#q1">RAG là gì, nói gọn trong một câu?</a></li>
<li><a href="#q2">RAG khác fine-tuning thế nào?</a></li>
<li><a href="#q3">RAG có xoá hết bịa đặt không?</a></li>
<li><a href="#q4">Vector là gì mà so sánh được nghĩa?</a></li>
<li><a href="#q5">Chia đoạn bao nhiêu chữ là đúng?</a></li>
<li><a href="#q6">Overlap để làm gì?</a></li>
<li><a href="#q7">top-K nên đặt bao nhiêu?</a></li>
<li><a href="#q8">Chọn mô hình embedding thế nào? Tiếng Việt thì sao?</a></li>
<li><a href="#q9">Bắt buộc phải có Vector DB không?</a></li>
<li><a href="#q10">Chỉ tìm theo vector đã đủ chưa?</a></li>
<li><a href="#q11">Câu hỏi cần nối nhiều tài liệu thì sao?</a></li>
<li><a href="#q12">Context window đã 1 triệu token, còn cần RAG?</a></li>
<li><a href="#q13">Chi phí thực tế bao nhiêu?</a></li>
<li><a href="#q14">RAG làm chậm bao nhiêu?</a></li>
<li><a href="#q15">Tài liệu sửa thì cập nhật thế nào?</a></li>
<li><a href="#q16">Đo chất lượng RAG bằng gì?</a></li>
<li><a href="#q17">Phân quyền tài liệu xử lý ra sao?</a></li>
<li><a href="#q18">Cowork-Local đã có RAG chưa?</a></li>
<li><a href="#q19">GraphRAG của dự án có phải GraphRAG của Microsoft?</a></li>
<li><a href="#q20">Muốn nâng lên RAG đầy đủ cần làm gì?</a></li>
</ol></div>
<h2>Nhóm 1 — Khái niệm</h2>
<div class="qa" id="q1"><div class="q"><span class="n">1</span>
RAG là gì, nói gọn trong một câu?</div>
<div class="a">
<p><b>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 đó</b> — thay vì
để LLM trả lời bằng trí nhớ có sẵn.</p>
<p>Ví von: thay vì bắt thí sinh làm bài từ trí nhớ, ta cho <i>thi mở sách</i> — nhưng có
thủ thư lật sẵn đúng trang cần đọc.</p>
</div></div>
<div class="qa" id="q2"><div class="q"><span class="n">2</span>
RAG khác fine-tuning thế nào? Khi nào dùng cái nào?</div>
<div class="a">
<figure>
<svg viewBox="0 0 720 190" role="img" aria-label="So sánh RAG và fine-tuning">
<rect x="8" y="14" width="340" height="162" rx="8" fill="#EAF2FC" stroke="#1565C0"/>
<text x="26" y="40" font-size="15" font-weight="700" fill="#0A4EA3">RAG — đưa thêm tài liệu</text>
<rect x="26" y="56" width="86" height="34" rx="5" fill="#fff" stroke="#4A90D9"/>
<text x="69" y="77" font-size="12" text-anchor="middle" fill="#2B3542">Câu hỏi</text>
<path d="M116 73 h22" stroke="#5A6675" stroke-width="1.6" marker-end="url(#ar)"/>
<rect x="142" y="56" width="94" height="34" rx="5" fill="#fff" stroke="#4A90D9"/>
<text x="189" y="72" font-size="11" text-anchor="middle" fill="#2B3542">Tìm tài liệu</text>
<text x="189" y="84" font-size="10" text-anchor="middle" fill="#5A6675">top-K đoạn</text>
<path d="M240 73 h22" stroke="#5A6675" stroke-width="1.6" marker-end="url(#ar)"/>
<rect x="266" y="56" width="66" height="34" rx="5" fill="#1565C0"/>
<text x="299" y="77" font-size="12" text-anchor="middle" fill="#fff">LLM</text>
<text x="26" y="116" font-size="12" fill="#2B3542">✔ Cập nhật tức thì — chỉ re-index</text>
<text x="26" y="136" font-size="12" fill="#2B3542">✔ Trích được nguồn</text>
<text x="26" y="156" font-size="12" fill="#2B3542">✔ Rẻ, không cần GPU train</text>
<rect x="372" y="14" width="340" height="162" rx="8" fill="#FDF3E3" stroke="#B26A00"/>
<text x="390" y="40" font-size="15" font-weight="700" fill="#8A5000">Fine-tune — dạy lại mô hình</text>
<rect x="390" y="56" width="96" height="34" rx="5" fill="#fff" stroke="#D9A24A"/>
<text x="438" y="72" font-size="11" text-anchor="middle" fill="#2B3542">Dữ liệu mẫu</text>
<text x="438" y="84" font-size="10" text-anchor="middle" fill="#5A6675">hàng nghìn cặp</text>
<path d="M490 73 h22" stroke="#5A6675" stroke-width="1.6" marker-end="url(#ar)"/>
<rect x="516" y="56" width="80" height="34" rx="5" fill="#fff" stroke="#D9A24A"/>
<text x="556" y="77" font-size="12" text-anchor="middle" fill="#2B3542">Huấn luyện</text>
<path d="M600 73 h22" stroke="#5A6675" stroke-width="1.6" marker-end="url(#ar)"/>
<rect x="626" y="56" width="70" height="34" rx="5" fill="#B26A00"/>
<text x="661" y="77" font-size="12" text-anchor="middle" fill="#fff">Model mới</text>
<text x="390" y="116" font-size="12" fill="#2B3542">✔ Dạy được <i>văn phong</i>, định dạng</text>
<text x="390" y="136" font-size="12" fill="#2B3542">✔ Dạy được kỹ năng chuyên ngành</text>
<text x="390" y="156" font-size="12" fill="#2B3542">✘ Kiến thức mới → phải train lại</text>
<defs><marker id="ar" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
<path d="M0 0 L7 3.5 L0 7 z" fill="#5A6675"/></marker></defs>
</svg>
<figcaption>RAG thêm <i>kiến thức</i>. Fine-tune thay đổi <i>hành vi</i>.</figcaption>
</figure>
<p><b>Quy tắc chọn:</b> câu trả lời phụ thuộc <i>nội dung tài liệu</i> → RAG.
Phụ thuộc <i>cách nói / định dạng / kỹ năng</i> → fine-tune. Cần cả hai thì làm cả hai.</p>
<div class="note">Đ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.</div>
</div></div>
<div class="qa" id="q3"><div class="q"><span class="n">3</span>
RAG có xoá hết bịa đặt (hallucination) không?</div>
<div class="a">
<p><b>Không. Chỉ giảm mạnh.</b> Đây là câu dễ bị hỏi vặn nhất, nên trả lời thẳng.</p>
<p>RAG vẫn sai được ở bốn chỗ:</p>
<ul>
<li><b>Tra sai đoạn</b> — lấy nhầm tài liệu, LLM trả lời trung thực trên tài liệu sai.</li>
<li><b>Không có trong kho</b> — LLM vẫn cố trả lời thay vì nói "không tìm thấy".</li>
<li><b>Đọc đúng nhưng suy diễn thêm</b> — thêm chi tiết không có trong đoạn trích.</li>
<li><b>Tài liệu gốc đã sai</b> — RAG không kiểm chứng nội dung.</li>
</ul>
<div class="note warn">Cách khắc phục thực dụng: bắt LLM <b>trích dẫn đoạn nguồn</b> cho từng ý,
và cho phép trả lời <b>"không tìm thấy trong tài liệu"</b>. Slide "Ưu điểm" nên nói
<i>giảm</i> hallucination, không nói <i>hết</i>.</div>
</div></div>
<div class="qa" id="q4"><div class="q"><span class="n">4</span>
Vector là gì mà so sánh được "nghĩa giống nhau"?</div>
<div class="a">
<figure>
<svg viewBox="0 0 720 210" role="img" aria-label="Không gian vector, các câu gần nghĩa nằm gần nhau">
<rect x="40" y="14" width="640" height="164" rx="8" fill="#fff" stroke="var(--bds)"/>
<line x1="70" y1="160" x2="660" y2="160" stroke="#CBD5E1"/>
<line x1="70" y1="160" x2="70" y2="30" stroke="#CBD5E1"/>
<circle cx="180" cy="70" r="6" fill="#1565C0"/><text x="192" y="74" font-size="12">"Xe hơi"</text>
<circle cx="214" cy="88" r="6" fill="#1565C0"/><text x="226" y="92" font-size="12">"Ô tô"</text>
<circle cx="196" cy="52" r="6" fill="#1565C0"/><text x="208" y="56" font-size="12">"Xe bốn bánh"</text>
<ellipse cx="200" cy="70" rx="86" ry="46" fill="none" stroke="#1565C0"
stroke-dasharray="4 3" opacity=".6"/>
<circle cx="520" cy="120" r="6" fill="#B26A00"/><text x="532" y="124" font-size="12">"Nấu phở"</text>
<circle cx="556" cy="98" r="6" fill="#B26A00"/><text x="568" y="102" font-size="12">"Công thức bún"</text>
<ellipse cx="540" cy="110" rx="70" ry="38" fill="none" stroke="#B26A00"
stroke-dasharray="4 3" opacity=".6"/>
<circle cx="300" cy="118" r="7" fill="#C0392B"/>
<text x="252" y="140" font-size="12" fill="#C0392B">câu hỏi của user</text>
<line x1="300" y1="118" x2="214" y2="88" stroke="#C0392B" stroke-width="1.4"/>
<text x="236" y="112" font-size="10.5" fill="#C0392B">gần → lấy</text>
<line x1="300" y1="118" x2="520" y2="120" stroke="#CBD5E1" stroke-width="1.2"
stroke-dasharray="3 3"/>
<text x="386" y="134" font-size="10.5" fill="#8A94A3">xa → bỏ qua</text>
</svg>
<figcaption>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.</figcaption>
</figure>
<p>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 <b>hai đoạn cùng nghĩa cho ra hai điểm gần nhau</b>, kể cả khi không trùng một chữ nào.</p>
<p>Máy đo "gần" bằng <b>cosine similarity</b> — 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ô".</p>
<div class="note">Đây chính là điểm RAG hơn tìm kiếm từ khoá: từ khoá cần <i>trùng chữ</i>,
vector chỉ cần <i>trùng nghĩa</i>.</div>
</div></div>
<h2>Nhóm 2 — Tham số kỹ thuật</h2>
<div class="qa" id="q5"><div class="q"><span class="n">5</span>
Chia đoạn bao nhiêu chữ là đúng?</div>
<div class="a">
<p><b>Không có con số đúng chung</b> — phụ thuộc loại tài liệu. Nhưng có nguyên tắc:</p>
<table>
<tr><th>Loại tài liệu</th><th>Cỡ đoạn gợi ý</th><th>Vì sao</th></tr>
<tr><td>FAQ, hỏi đáp ngắn</td><td>100 – 300 chữ</td><td>Mỗi mục vốn đã độc lập</td></tr>
<tr><td>Chính sách, quy trình</td><td>300 – 600 chữ</td><td>Giữ trọn một điều khoản</td></tr>
<tr><td>Sách, báo cáo dài</td><td>500 – 1000 chữ</td><td>Cần đủ ngữ cảnh xung quanh</td></tr>
<tr><td>Mã nguồn</td><td>theo hàm / lớp</td><td>Cắt giữa hàm là hỏng nghĩa</td></tr>
</table>
<div class="note warn"><b>Đoạn quá nhỏ</b> → mất ngữ cảnh, tra ra mảnh vụn vô nghĩa.
<b>Đoạn quá lớn</b> → 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.</div>
<p>Thực tế nên <b>cắt theo cấu trúc trước</b> (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.</p>
</div></div>
<div class="qa" id="q6"><div class="q"><span class="n">6</span>
Overlap 10–20% để làm gì?</div>
<div class="a">
<figure>
<svg viewBox="0 0 720 150" role="img" aria-label="Chia đoạn có phần chồng lấn">
<text x="20" y="26" font-size="12.5" font-weight="700" fill="#C0392B">Không overlap — câu bị cắt đôi</text>
<rect x="20" y="36" width="200" height="30" rx="4" fill="#EAF2FC" stroke="#4A90D9"/>
<rect x="222" y="36" width="200" height="30" rx="4" fill="#EAF2FC" stroke="#4A90D9"/>
<rect x="424" y="36" width="200" height="30" rx="4" fill="#EAF2FC" stroke="#4A90D9"/>
<text x="120" y="55" font-size="11" text-anchor="middle">đoạn 1</text>
<text x="322" y="55" font-size="11" text-anchor="middle">đoạn 2</text>
<text x="524" y="55" font-size="11" text-anchor="middle">đoạn 3</text>
<line x1="221" y1="30" x2="221" y2="72" stroke="#C0392B" stroke-width="2"/>
<text x="228" y="82" font-size="10.5" fill="#C0392B">"Mức phụ cấp là | 2 triệu/tháng" — mất vế sau</text>
<text x="20" y="110" font-size="12.5" font-weight="700" fill="#1B7A3D">Có overlap — câu nào cũng trọn ở ít nhất 1 đoạn</text>
<rect x="20" y="118" width="210" height="26" rx="4" fill="#E8F5EC" stroke="#1B7A3D"/>
<rect x="196" y="118" width="210" height="26" rx="4" fill="#E8F5EC" stroke="#1B7A3D"
opacity=".75"/>
<rect x="372" y="118" width="210" height="26" rx="4" fill="#E8F5EC" stroke="#1B7A3D"
opacity=".55"/>
<rect x="196" y="118" width="34" height="26" fill="#1B7A3D" opacity=".2"/>
<rect x="372" y="118" width="34" height="26" fill="#1B7A3D" opacity=".2"/>
<text x="600" y="136" font-size="10.5" fill="#1B7A3D">phần tô đậm = chồng lấn</text>
</svg>
<figcaption>Overlap là bảo hiểm cho những câu nằm vắt ngang ranh giới đoạn.</figcaption>
</figure>
<p>Cắt cứng theo số chữ sẽ có lúc cắt <b>giữa một câu hoặc giữa một ý</b>. Đ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.</p>
<p>Giá phải trả: kho phình thêm đúng bằng tỉ lệ overlap. 20% overlap → nhiều hơn ~20% vector.</p>
</div></div>
<div class="qa" id="q7"><div class="q"><span class="n">7</span>
top-K nên đặt bao nhiêu?</div>
<div class="a">
<p>Thường <b>3 – 10</b>. Cách chọn:</p>
<ul>
<li><b>K nhỏ (3–5)</b> — câu hỏi tra cứu một dữ kiện. Ít nhiễu, rẻ, nhanh.</li>
<li><b>K lớn (8–15)</b> — câu hỏi tổng hợp, cần gom nhiều nguồn.</li>
</ul>
<div class="note warn">K càng lớn <b>không</b> đồng nghĩa càng chính xác. Đoạn thứ 15 thường
đã lạc đề, và nó <i>làm loãng</i> 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".</div>
<p>Thực dụng hơn: đặt <b>ngưỡng điểm tương đồng</b> 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".</p>
</div></div>
<div class="qa" id="q8"><div class="q"><span class="n">8</span>
Chọn mô hình embedding thế nào? Tiếng Việt có ổn không?</div>
<div class="a">
<p>Ba tiêu chí: <b>hỗ trợ tiếng Việt</b>, <b>số chiều</b>, <b>chạy nội bộ hay gọi API</b>.</p>
<table>
<tr><th>Nhóm</th><th>Ví dụ</th><th>Ghi chú</th></tr>
<tr><td>API thương mại</td><td>OpenAI <code>text-embedding-3</code>, Cohere</td>
<td>Chất lượng tốt, nhưng <b>tài liệu phải gửi ra ngoài</b></td></tr>
<tr><td>Đa ngữ, chạy nội bộ</td><td>multilingual-e5, BGE-M3</td>
<td>Tiếng Việt khá tốt, chạy được trên máy công ty</td></tr>
<tr><td>Chuyên tiếng Việt</td><td>PhoBERT và các bản fine-tune</td>
<td>Cần đánh giá lại trên chính dữ liệu của mình</td></tr>
</table>
<div class="note bad"><b>Lưu ý bắt buộc:</b> đổi mô hình embedding thì
<b>phải index lại toàn bộ kho</b>. 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.</div>
<p>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.</p>
</div></div>
<div class="qa" id="q9"><div class="q"><span class="n">9</span>
Bắt buộc phải có Vector DB riêng không?</div>
<div class="a">
<p><b>Không.</b> Chọn theo quy mô:</p>
<table>
<tr><th>Quy mô</th><th>Giải pháp</th><th>Ghi chú</th></tr>
<tr><td>&lt; 100k vector</td><td>FAISS, Chroma, hoặc file numpy</td>
<td>Không cần dựng thêm dịch vụ</td></tr>
<tr><td>Đã có PostgreSQL</td><td><code>pgvector</code></td>
<td>Dùng luôn DB sẵn có — thường là lựa chọn tốt nhất</td></tr>
<tr><td>Triệu vector trở lên</td><td>Milvus, Qdrant, Weaviate</td>
<td>Cần index ANN chuyên dụng</td></tr>
<tr><td>Không muốn tự vận hành</td><td>Pinecone</td>
<td>Dịch vụ đám mây, dữ liệu ra ngoài</td></tr>
</table>
<p>Ví dụ trong slide — 100 file PDF ra 20.000 vector — <b>hoàn toàn không cần</b> Vector DB
chuyên dụng. FAISS trên một máy là đủ và nhanh.</p>
</div></div>
<h2>Nhóm 3 — Chất lượng truy hồi</h2>
<div class="qa" id="q10"><div class="q"><span class="n">10</span>
Chỉ tìm theo vector đã đủ chưa?</div>
<div class="a">
<figure>
<svg viewBox="0 0 720 168" role="img" aria-label="Hybrid search và rerank">
<rect x="14" y="52" width="98" height="42" rx="6" fill="#fff" stroke="#4A90D9"/>
<text x="63" y="70" font-size="12" text-anchor="middle">Câu hỏi</text>
<text x="63" y="85" font-size="10" text-anchor="middle" fill="#5A6675">của user</text>
<path d="M116 73 h26" stroke="#5A6675" stroke-width="1.6" marker-end="url(#a2)"/>
<rect x="146" y="20" width="128" height="42" rx="6" fill="#EAF2FC" stroke="#1565C0"/>
<text x="210" y="38" font-size="12" text-anchor="middle" fill="#0A4EA3">Tìm theo vector</text>
<text x="210" y="52" font-size="10" text-anchor="middle" fill="#5A6675">bắt được ý nghĩa</text>
<rect x="146" y="84" width="128" height="42" rx="6" fill="#FDF3E3" stroke="#B26A00"/>
<text x="210" y="102" font-size="12" text-anchor="middle" fill="#8A5000">Tìm theo từ khoá</text>
<text x="210" y="116" font-size="10" text-anchor="middle" fill="#5A6675">bắt mã, tên riêng</text>
<path d="M278 41 h20 v32" stroke="#5A6675" stroke-width="1.6" fill="none"/>
<path d="M278 105 h20 v-32" stroke="#5A6675" stroke-width="1.6" fill="none"
marker-end="url(#a2)"/>
<rect x="318" y="52" width="104" height="42" rx="6" fill="#fff" stroke="#4A90D9"/>
<text x="370" y="70" font-size="12" text-anchor="middle">Gộp kết quả</text>
<text x="370" y="85" font-size="10" text-anchor="middle" fill="#5A6675">~30 đoạn</text>
<path d="M426 73 h26" stroke="#5A6675" stroke-width="1.6" marker-end="url(#a2)"/>
<rect x="456" y="52" width="110" height="42" rx="6" fill="#1565C0"/>
<text x="511" y="70" font-size="12" text-anchor="middle" fill="#fff">Rerank</text>
<text x="511" y="85" font-size="10" text-anchor="middle" fill="#D6E7F8">chấm lại điểm</text>
<path d="M570 73 h26" stroke="#5A6675" stroke-width="1.6" marker-end="url(#a2)"/>
<rect x="600" y="52" width="104" height="42" rx="6" fill="#E8F5EC" stroke="#1B7A3D"/>
<text x="652" y="70" font-size="12" text-anchor="middle" fill="#14612F">Top 5 tinh</text>
<text x="652" y="85" font-size="10" text-anchor="middle" fill="#5A6675">đưa cho LLM</text>
<defs><marker id="a2" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
<path d="M0 0 L7 3.5 L0 7 z" fill="#5A6675"/></marker></defs>
</svg>
<figcaption>Hai cách tìm bù khuyết cho nhau, rồi lọc lại một lần nữa.</figcaption>
</figure>
<p><b>Chưa đủ.</b> Vector giỏi bắt ý nghĩa nhưng <b>dở với mã số, tên riêng, ký hiệu</b> —
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.</p>
<p>Hai cải tiến gần như luôn đáng làm:</p>
<ul>
<li><b>Hybrid search</b> — chạy song song vector + từ khoá (BM25), gộp kết quả.</li>
<li><b>Rerank</b> — 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à <b>cải thiện lớn nhất</b> với chi phí nhỏ nhất.</li>
</ul>
</div></div>
<div class="qa" id="q11"><div class="q"><span class="n">11</span>
Câu hỏi cần nối nhiều tài liệu (multi-hop) thì sao?</div>
<div class="a">
<p>Slide đã nêu đúng đây là điểm yếu. Ví dụ: <i>"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?"</i> — cần tra bảng doanh số trước, rồi mới tra hợp đồng.</p>
<p>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ý:</p>
<ul>
<li><b>Tra nhiều vòng (agentic RAG)</b> — 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.</li>
<li><b>Tách câu hỏi</b> — chia thành các câu con, tra từng câu, rồi tổng hợp.</li>
<li><b>Knowledge graph</b> — 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.</li>
</ul>
</div></div>
<h2>Nhóm 4 — Vận hành</h2>
<div class="qa" id="q12"><div class="q"><span class="n">12</span>
Context window đã tới 1 triệu token — còn cần RAG không?</div>
<div class="a">
<p><b>Vẫn cần</b>, vì ba lý do:</p>
<ul>
<li><b>Chi phí</b> — 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.</li>
<li><b>Độ trễ</b> — đọc 500k token mất hàng chục giây.</li>
<li><b>Quy mô</b> — kho tài liệu doanh nghiệp thường vài chục triệu token, vượt xa mọi
context window.</li>
</ul>
<div class="note">Thêm nữa, độ chính xác <b>giảm khi ngữ cảnh quá dài</b> — 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.</div>
<p>Context dài <i>có</i> 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ý.</p>
</div></div>
<div class="qa" id="q13"><div class="q"><span class="n">13</span>
Chi phí thực tế bao nhiêu?</div>
<div class="a">
<p>Tách làm hai phần, và phần đắt <b>không</b> phải phần người ta hay lo:</p>
<table>
<tr><th>Khoản</th><th>Khi nào phát sinh</th><th>Mức độ</th></tr>
<tr><td>Embedding tài liệu</td><td>Một lần lúc index + khi tài liệu đổi</td>
<td><b>Rẻ</b> — embedding rẻ hơn LLM hàng chục lần</td></tr>
<tr><td>Lưu trữ vector</td><td>Liên tục</td><td>Nhỏ, trừ khi kho cực lớn</td></tr>
<tr><td>Embedding câu hỏi</td><td>Mỗi lượt hỏi</td><td>Không đáng kể</td></tr>
<tr><td><b>LLM sinh câu trả lời</b></td><td>Mỗi lượt hỏi</td>
<td><b>Chiếm phần lớn chi phí</b></td></tr>
</table>
<p>Vì vậy giảm chi phí RAG thực chất là <b>giảm số token đưa vào LLM</b> — 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.</p>
</div></div>
<div class="qa" id="q14"><div class="q"><span class="n">14</span>
RAG làm chậm thêm bao nhiêu?</div>
<div class="a">
<p>Bước tra thường tốn <b>vài chục tới vài trăm mili-giây</b>: embedding câu hỏi + tìm trong
vector DB. Có rerank thì cộng thêm chút nữa.</p>
<p>So với thời gian LLM sinh câu trả lời (thường vài giây), phần này <b>gần như không đáng kể</b>.</p>
<div class="note warn">Slide ghi "ứng dụng real-time cần &lt; 100ms" thì nên cẩn trọng —
đúng, nhưng lúc đó nút thắt là <b>LLM</b>, 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.</div>
</div></div>
<div class="qa" id="q15"><div class="q"><span class="n">15</span>
Tài liệu sửa thì cập nhật thế nào?</div>
<div class="a">
<p>Chỉ cần <b>index lại phần thay đổi</b>, không đụng tới mô hình:</p>
<ul>
<li>File sửa → xoá vector cũ của file đó, embedding lại, ghi vector mới.</li>
<li>File xoá → xoá vector tương ứng.</li>
<li>File mới → embedding và thêm vào.</li>
</ul>
<p>Cách làm thực dụng: lưu kèm <b>hash nội dung</b> 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.</p>
<div class="note bad">Ngoại lệ duy nhất phải làm lại toàn bộ: <b>đổi mô hình embedding</b>
hoặc <b>đổi cách chia đoạn</b>.</div>
</div></div>
<div class="qa" id="q16"><div class="q"><span class="n">16</span>
Đo chất lượng RAG bằng gì? Làm sao biết là tốt?</div>
<div class="a">
<p>Điểm mấu chốt: <b>đo tách hai khâu</b>, vì hỏng ở đâu thì sửa ở đó khác nhau.</p>
<table>
<tr><th>Khâu</th><th>Đo gì</th><th>Hỏng thì sửa gì</th></tr>
<tr><td><b>Truy hồi</b></td><td>Đoạn đúng có nằm trong top-K không?</td>
<td>Chia đoạn, mô hình embedding, hybrid, rerank</td></tr>
<tr><td><b>Sinh câu trả lời</b></td><td>Câu trả lời có bám vào đoạn đã lấy không?</td>
<td>Prompt, model, yêu cầu trích nguồn</td></tr>
</table>
<p>Cách làm tối thiểu mà hiệu quả: dựng <b>bộ 50–100 câu hỏi mẫu có đáp án đúng</b> 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.</p>
<div class="note">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.</div>
</div></div>
<div class="qa" id="q17"><div class="q"><span class="n">17</span>
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.</div>
<div class="a">
<p>Đây là câu hay bị bỏ quên tới lúc triển khai thật mới lộ ra.</p>
<p><b>Nguyên tắc: lọc quyền ở bước truy hồi, không phải ở bước trả lời.</b> 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.</p>
<ul>
<li>Mỗi vector lưu kèm <b>metadata quyền</b> (phòng ban, mức mật, danh sách người xem).</li>
<li>Khi tra, lọc theo quyền của người hỏi <b>ngay trong truy vấn</b>.</li>
<li>Tài liệu ngoài quyền thì không bao giờ vào được ngữ cảnh của LLM.</li>
</ul>
<div class="note bad">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à <b>rò rỉ gián tiếp</b> dù không trích nguyên văn.</div>
</div></div>
<h2>Nhóm 5 — Về dự án Cowork-Local</h2>
<p class="lead">Nhóm này gần như chắc chắn được hỏi, vì slide 17 đã tự nêu ra.</p>
<div class="qa" id="q18"><div class="q"><span class="n">18</span>
Vậy Cowork-Local đã có RAG chưa?</div>
<div class="a">
<p>Trả lời thẳng như slide 17 đã viết: <b>chưa có RAG theo nghĩa đầy đủ.</b></p>
<figure>
<svg viewBox="0 0 720 150" role="img" aria-label="Ba mức nạp ngữ cảnh">
<rect x="10" y="24" width="222" height="104" rx="8" fill="#FDF3E3" stroke="#B26A00"/>
<text x="121" y="48" font-size="13" font-weight="700" text-anchor="middle" fill="#8A5000">
Mức 1 — Nạp thủ công</text>
<text x="121" y="70" font-size="11.5" text-anchor="middle" fill="#2B3542">Đính kèm file, dán link,</text>
<text x="121" y="86" font-size="11.5" text-anchor="middle" fill="#2B3542">Instructions của project</text>
<text x="121" y="110" font-size="11" text-anchor="middle" fill="#8A5000">Người dùng tự chọn</text>
<rect x="248" y="24" width="222" height="104" rx="8" fill="#EAF2FC" stroke="#1565C0"/>
<text x="359" y="48" font-size="13" font-weight="700" text-anchor="middle" fill="#0A4EA3">
Mức 2 — Tra theo cấu trúc</text>
<text x="359" y="70" font-size="11.5" text-anchor="middle" fill="#2B3542">GraphRAG: sơ đồ file,</text>
<text x="359" y="86" font-size="11.5" text-anchor="middle" fill="#2B3542">lớp, hàm, quan hệ</text>
<text x="359" y="110" font-size="11" text-anchor="middle" fill="#0A4EA3">AI tự tra — đang ở đây</text>
<rect x="486" y="24" width="224" height="104" rx="8" fill="#F2F4F7" stroke="#CBD5E1"
stroke-dasharray="5 4"/>
<text x="598" y="48" font-size="13" font-weight="700" text-anchor="middle" fill="#5A6675">
Mức 3 — Tra theo ngữ nghĩa</text>
<text x="598" y="70" font-size="11.5" text-anchor="middle" fill="#5A6675">Embedding + Vector DB</text>
<text x="598" y="86" font-size="11.5" text-anchor="middle" fill="#5A6675">tìm theo nghĩa</text>
<text x="598" y="110" font-size="11" text-anchor="middle" fill="#8A94A3">chưa có</text>
</svg>
<figcaption>Dự án đang ở mức 2. Mức 3 mới là RAG như trình bày ở phần đầu.</figcaption>
</figure>
<p>Cách nói an toàn khi bị hỏi vặn: <i>"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."</i></p>
</div></div>
<div class="qa" id="q19"><div class="q"><span class="n">19</span>
"GraphRAG" của dự án có phải GraphRAG của Microsoft không?</div>
<div class="a">
<div class="note warn"><b>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.</b></div>
<table>
<tr><th></th><th>GraphRAG (Microsoft)</th><th>GraphRAG trong Cowork-Local</th></tr>
<tr><td>Đồ thị chứa gì</td><td>Thực thể và quan hệ do <b>LLM trích</b> từ nội dung</td>
<td>File, lớp, hàm và liên kết import</td></tr>
<tr><td>Dựng bằng gì</td><td>Gọi LLM nhiều lượt, tốn chi phí</td>
<td>Phân tích cú pháp mã nguồn, <b>không tốn phí gọi AI</b></td></tr>
<tr><td>Trả lời câu hỏi</td><td>Đi theo quan hệ + tóm tắt theo cụm</td>
<td>Đọc sơ đồ và nội dung file liên quan</td></tr>
</table>
<p><b>Cùng tên, khác bản chất.</b> 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".</p>
<p>Nói rõ điểm này lại là <b>lợi thế</b>: cách của dự án <i>rẻ và nhanh hơn nhiều</i> vì không
phải gọi LLM để dựng đồ thị.</p>
</div></div>
<div class="qa" id="q20"><div class="q"><span class="n">20</span>
Muốn nâng lên RAG đầy đủ thì cần làm gì?</div>
<div class="a">
<p>Bốn việc, xếp theo thứ tự nên làm:</p>
<table>
<tr><th>#</th><th>Việc</th><th>Quyết định phải chốt</th></tr>
<tr><td>1</td><td>Chọn mô hình embedding</td>
<td>Chạy nội bộ hay gọi API — quyết định này ràng buộc mọi thứ sau, và
<b>đổi về sau là phải index lại toàn bộ</b></td></tr>
<tr><td>2</td><td>Chia đoạn tài liệu</td>
<td>Cắt theo cấu trúc (mục, điều, hàm) trước khi cắt theo độ dài</td></tr>
<tr><td>3</td><td>Chọn nơi lưu vector</td>
<td>Quy mô hiện tại chỉ cần FAISS hoặc <code>pgvector</code></td></tr>
<tr><td>4</td><td>Dựng bộ câu hỏi đánh giá</td>
<td>50–100 câu có đáp án đúng — <b>làm trước khi tối ưu</b></td></tr>
</table>
<div class="note ok"><b>Điểm mạnh sẵn có:</b> dự án đã có sẵn khái niệm <i>project</i> 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.</div>
</div></div>
<h2>Ba câu nên chuẩn bị sẵn câu trả lời</h2>
<div class="note bad"><b>1. "RAG có hết bịa không?"</b> → 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".</div>
<div class="note bad"><b>2. "GraphRAG này có phải GraphRAG kia không?"</b> → Không, cùng tên
khác bản chất. Chủ động nói trước khi bị hỏi.</div>
<div class="note bad"><b>3. "Vậy dự án đã có RAG chưa?"</b> → Chưa đủ. Đang ở mức truy xuất
theo cấu trúc, chưa có truy xuất theo ngữ nghĩa.</div>
</div>
</body>
</html>
+627 -50
View File
File diff suppressed because one or more lines are too long
+40
View File
@@ -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"},
+49
View File
@@ -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 {
File diff suppressed because one or more lines are too long
+317 -22
View File
@@ -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 = ('<div class="dock fab" title="AI Assistant — trợ lý cách dùng app">'
'<span class="spark">✨</span></div>')
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'<div class="i{" on" if n == active else ""}">{n}</div>' for n in items)
f'<div class="i{" on" if n == active else ""}'
f'{" off" if empty and n in needs_project else ""}">{n}</div>'
for n in items)
if empty:
return ('<div class="rail">'
'<div class="menutog"><span>MENU</span><span class="chev">‹</span></div>'
'<div class="rpick empty"><span>Chưa có project</span>'
'<span class="cv">▾</span></div>'
'<div class="newbtn off">+ Đoạn chat mới</div>'
'<div class="hint">Tạo project trước</div>'
f'{rows}'
'<div class="sep"></div><div class="hd">RECENTS</div>'
'<div class="i sm off">trống</div>'
'<div class="grow"></div><div class="sep"></div>'
'<div class="i">Dashboard</div><div class="i">Monitoring</div>'
'<div class="i">Cài đặt</div>'
'<div class="acct"><span>👤 local</span>'
'<span class="lang">VN ▾</span><span class="thm">🌙</span></div></div>')
recents = "".join(f'<div class="i sm">{t}</div>' for t in RECENTS)
return (
'<div class="rail">'
@@ -65,7 +114,9 @@ def rail(active: str = "", project: str = "Báo cáo tài chính Q3") -> str:
'<div class="grow"></div>'
'<div class="sep"></div>'
'<div class="i">Dashboard</div><div class="i">Monitoring</div>'
'<div class="acct">👤 local · Ollama ▾</div>'
'<div class="i">Cài đặt</div>'
'<div class="acct"><span>👤 local</span>'
'<span class="lang">VN ▾</span><span class="thm">🌙</span></div>'
'</div>')
@@ -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'<span class="pchev">{chev}</span>' if chev else ""
act = f'<span class="gact">{action}</span>' if action else ""
return f'<div class="hd2 row">{label}<span class="ghd">{act}{tail}</span></div>'
def li(text: str, sub: str = "", *, on: bool = False) -> str:
"""One row in a list pane."""
s = f'<span class="s">{sub}</span>' if sub else ""
@@ -170,8 +234,13 @@ DESCRIPTIONS: dict[str, dict] = {
"dialog-login": {"d": "Màn đăng nhập — <b>đã dựng xong nhưng không nơi nào gọi</b>. "
"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, "
"<b>luôn ghim góc dưới phải</b>"),
("3 nút", "<b>›</b> ẩn vào cạnh phải · <b>—</b> 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] = {
'<div class="btn pri">▷ Chạy</div></div>'
'<div class="r grow">'
'<div class="c w26 pane">'
'<div class="hd2 row">WORKFLOWS<span class="pchev">‹</span></div>'
+ 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")
+ '<div class="hd2">AGENTS (5)</div>'
+ 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")
+ '<div class="hd2">SKILLS (5)</div>'
+ grp("SKILLS (5)", "Quản lý…")
+ li("Rà soát bảo mật · Viết test trước · Chuẩn hoá Excel · …")
+ '<div class="hd2">LẦN CHẠY (6)</div>'
+ 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")
+ '<div class="grow"></div></div>'
+ '<div class="grow"></div>'
'<div class="r tb"><div class="btn">✎</div><div class="btn">⧉</div>'
'<div class="btn">🗑</div><div class="btn grow">▷ Chạy nền</div></div></div>'
'<div class="c grow"><div class="b canvas grow">'
'<div class="node ok">Phân tích yêu cầu</div><div class="arw">→</div>'
'<div class="node ok">Thiết kế</div><div class="arw">→</div>'
@@ -478,7 +549,18 @@ ANALYSIS: dict[str, dict] = {
'<div class="hd2">SANDBOX &amp; QUYỀN</div>'
'<div class="r"><div class="b grow">Tệp: chỉ trong workspace · Mạng: chặn · '
'Tiến trình: giới hạn 4</div></div>'
'<div class="hd2">NHẬT KÝ GẦN ĐÂY</div>'
'<div class="hd2 row">BẢNG GIÁ MODEL'
'<span class="ghd"><span class="gact">Nhập · Xuất · Thêm · Tự dò</span>'
'<span class="pchev">USD ▾</span></span></div>'
'<div class="b tbl">'
'<div class="tr th"><span>Model</span><span>Vào</span><span>Ra</span>'
'<span>Cache</span><span>Đơn vị</span></div>'
'<div class="tr"><span>qwen2.5-coder:7b</span><span>0.00</span><span>0.00</span>'
'<span>0.00</span><span>/Mtok</span></div>'
'<div class="tr"><span>gpt-4o-mini</span><span>0.15</span><span>0.60</span>'
'<span>0.08</span><span>/Mtok</span></div></div>'
'<div class="hd2 row">NHẬT KÝ GẦN ĐÂY'
'<span class="ghd"><span class="gact">Xem tất cả</span></span></div>'
'<div class="b grow"><span class="bad">✕ Chặn đọc personal.xlsx (ngoài sandbox)</span><br>'
'<span class="ok">✓ pytest tests/test_stations.py → 4 passed</span><br>'
'<span class="bad">✕ jira.create_issue — 401 token hết hạn</span></div>'
@@ -509,6 +591,73 @@ ANALYSIS: dict[str, dict] = {
+ li("image_gen", "Sinh ảnh — ☐ tắt")
+ '<div class="grow"></div></div></div>'),
},
"overlay-help-panel": {
"problems": [
"<b>Hai vùng bấm cho một tính năng.</b> Huy hiệu mở, chevron ẩn — nằm sát nhau, "
"dễ bấm nhầm.",
"<b>Vùng bấm quá nhỏ.</b> Chevron rộng <b>18px</b>, tab mép <b>16px</b> "
"(<code>help_agent_widget.py:36-38</code>) — dưới ngưỡng ~24px để bấm thoải mái, "
"nhất là trên màn cảm ứng.",
"<b>Ba trạng thái, thừa một.</b> “Nép mép” và “huy hiệu” đều nghĩa là <i>đang đóng</i>; "
"người dùng phải học hai kiểu đóng và hai đường quay lại.",
"<b>Chiếm 84×64px vĩnh viễn</b> ngay góc dưới phải (huy hiệu 64 + khe 2 + "
"chevron 18 — <code>help_agent_widget.py:34-37</code>) — ở 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 <b>chính icon app</b> (<code>help_agent_widget.py:49-53</code>, "
"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 <i>chỗ dùng</i> "
"chứ không nói <i>nó là gì</i>.",
],
"changes": [
"<b>Một chấm 26px, không chữ.</b> Bỏ luôn chevron rời — chỗ chiếm giảm từ "
"<b>84×64 xuống 26×26</b> (<b>−88% diện tích</b>). Vẫn là một vùng bấm, "
"26px ≥ ngưỡng bấm thoải mái.",
"<b>Tên: “AI Assistant”</b> — giữ nguyên ở cả <b>3 ngôn ngữ</b>, sửa đúng một "
"khoá <code>help_agent.title</code> (<code>i18n.py:470</code>) 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.",
"<b>Chữ chỉ hiện khi rê chuột / focus bàn phím</b> — 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.",
"<b>“Ẩn trợ lý” dời vào menu ⋯</b> 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 <i>đang</i> tương tác.",
"Thường ngày chỉ còn <b>2 trạng thái</b>: đóng ↔ mở. Ẩn hẳn thành lựa chọn hiếm.",
"Tab mép nới từ <b>16px → 28px</b> cho bấm được.",
"Ở màn có ô nhập dưới đáy (Cowork), chấm <b>nâng lên trên hàng nhập</b>, "
"không đè nút Gửi.",
],
"wf": ('<div class="main dlg">'
'<div class="r tb"><div class="ttl">Trợ lý — thu gọn còn một chấm</div></div>'
'<div class="r grow">'
# 1. at rest — drawn to scale beside the old footprint
'<div class="c w24"><div class="lbl">Bình thường</div>'
'<div class="b grow ctr2" style="gap:14px">'
'<div class="oldbox">cũ 84×64</div>'
'<div class="fab"><span class="spark">✨</span></div></div>'
'<div class="s">26×26 · không chữ, không chevron · −88% diện tích</div></div>'
# 2. hover — the label appears only on demand
'<div class="c w24"><div class="lbl">Rê chuột / focus</div>'
'<div class="b grow ctr2">'
'<span class="fabpill"><span class="fab"><span class="spark">✨</span></span>'
'AI Assistant</span></div>'
'<div class="s">tên chỉ hiện lúc cần</div></div>'
# 3. open — hide lives in the ⋯ menu
'<div class="c grow"><div class="lbl">Mở — “Ẩn” nằm trong menu ⋯</div>'
'<div class="b grow pnl">'
'<div class="r tb phdr"><span class="spark">✨</span><b>AI Assistant</b>'
'<div class="grow"></div><span class="mut">— ⋯</span></div>'
'<div class="mnu"><div class="mi">Thu nhỏ về chấm</div>'
'<div class="mi">Ẩn trợ lý vào cạnh phải</div>'
'<div class="mi">Đổi model…</div></div>'
'<div class="msg a">Xin chào Nam, mình giúp gì khi bạn dùng app?</div>'
'<div class="grow"></div>'
'<div class="r tb"><div class="inp grow">Hỏi về cách dùng app…</div>'
'<div class="btn pri">Gửi</div></div></div></div>'
# 4. hidden — wider edge tab
'<div class="c w18"><div class="lbl">Đã ẩn</div>'
'<div class="b grow ctr2"><div class="edge wide">‹</div></div>'
'<div class="s">tab mép 28px</div></div>'
'</div></div>'),
},
"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", "<span class='ok'>Không đổi.</span>"),
]
# 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 (<code>monitoring_tab.py:586</code>). "
"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 <b>3 giây</b> (<code>monitoring_tab.py:43</code>), "
"Schedule <b>10 giây</b> (<code>schedule_task_tab.py:150</code>), "
"Dashboard <b>30 giây</b> (<code>dashboard_tab.py:175</code>). "
"Đề 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ả <code>SkillsDialog</code> lẫn <code>SkillManagerTab</code> đều không có. "
"Chỉ tạo được qua AI / template / nhập / nhân bản. "
"<code>SkillEditDialog</code> đã 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ử",
"<code>sidebar.py:68</code> khai báo tín hiệu <code>new_chat</code>, "
"<code>workspace_tab.py:241</code> đã nối — nhưng không nơi nào phát.",
"hoàn thiện thứ đã dựng"),
("Gọi <code>ensure_starter_project()</code>",
"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 <code>refresh()</code> lại ghi “no auto-seed”. Hai chỗ mâu thuẫn.",
"đổi hành vi"),
("Mật khẩu Sandbox hard-code",
"<code>settings_dialog.py:115</code> để mật khẩu mở khoá ngay trong mã nguồn.",
"bảo mật"),
("Hai lớp trùng tên <code>CustomAgent</code>",
"<code>core/custom_agents.py:23</code> và <code>core/co4e.py:117</code> — "
"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'<div class="miss">Không chụp được màn này<br><code>{err}</code></div>'
sw = ""
wf = (f'<div class="cap">Đề xuất — bố cục mới</div><div class="wf">{a["wf"]}</div>'
# 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'<div class="cap">Đề xuất — bố cục mới</div>'
f'<div class="wf">{a["wf"]}{dock}</div>'
if a["wf"] else
'<div class="cap">Đề xuất — bố cục mới</div>'
'<p class="mut">Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.</p>')
@@ -1159,9 +1413,15 @@ def main() -> int:
f'<b>{lab}</b>{": " + txt if txt else ""}' for lab, txt in de["r"])
legend = f'<p class="rg">{bits}</p>'
secs.append(f"""<section class="sec" id="{slug}">
<div class="hd"><b>{n}. {esc(info['title'])}</b><span class="tag">{esc(info['note'])}</span>{sw}</div>
<div class="bd">
# 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"""<div class="bd">
{intro}
<div class="cap">Hiện tại</div>{shot}
{legend}
@@ -1170,7 +1430,11 @@ def main() -> int:
<div class="cols pc">
<div><div class="cap">Vấn đề</div><ul class="pr">{''.join(f'<li>{p}</li>' for p in a['problems'])}</ul></div>
<div><div class="cap">Thay đổi</div><ul class="pr">{''.join(f'<li>{c}</li>' for c in a['changes'])}</ul></div>
</div></div></section>""")
</div></div>"""
secs.append(f"""<section class="sec" id="{slug}">
<div class="hd"><b>{n}. {esc(info['title'])}</b><span class="tag">{esc(info['note'])}</span>{sw}</div>
{body}</section>""")
flows = "".join(
f'<tr><td><b>{t}</b></td><td class="mut">{b}</td><td>{af}</td></tr>'
@@ -1183,6 +1447,14 @@ def main() -> int:
newchat = "".join(
f'<tr><td><b>{a}</b></td><td class="mut">{o}</td><td>{n}</td><td>{v}</td></tr>'
for a, o, n, v in NEWCHAT)
later = "".join(f'<tr><td><b>{n}</b></td><td>{d}</td>'
f'<td class="mut">{k}</td></tr>' for n, d, k in LATER)
rail_has = rail("Cowork") + ('<div class="main"><div class="ttl">Cowork</div>'
'<div class="b grow"></div></div>')
rail_none = rail("Project", empty=True) + (
'<div class="main"><div class="ttl">Quản lý project</div>'
'<div class="b grow"><span class="mut">Chưa có project — bấm “+ Project mới”</span>'
'</div></div>')
shell_ctl = controls_table("__shell__", cidx)
colls = "".join(
f'<tr><td><b>{n}</b></td><td>{how}</td><td class="mut">{src}</td>'
@@ -1201,7 +1473,8 @@ def main() -> int:
html = f"""<!doctype html><html lang="vi"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>CoworkLocal — Audit UI/UX</title><style>{CSS}</style></head><body>
<title>CoworkLocal — Audit UI/UX</title><style>{CSS}
{HAND_CSS}</style></head><body>
<button class="tgl" id="tgl">🌙 Tối</button>
<div class="wrap">
<header>
@@ -1267,6 +1540,20 @@ Số còn lại bị ẩn, thành combo, thành nút, hoặc không tới đư
<h3>Truy vết chi tiết: “Đoạn chat mới”</h3>
<table><tr><th style="width:16%">Khía cạnh</th><th style="width:28%">Giao diện cũ</th>
<th style="width:24%">Giao diện mới</th><th>Có đồng bộ không</th></tr>{newchat}</table>
<div class="cols" style="align-items:start">
<div><div class="cap">Có project</div><div class="wf demo">{rail_has}</div></div>
<div><div class="cap">Chưa có project nào</div><div class="wf demo">{rail_none}</div></div>
</div>
<div class="note"><b>Khi chưa có project</b> (đã chạy thử app với 0 project):
hiện nay <b>Cowork và GraphRAG biến mất</b> khỏi menu nên không chat được, mà không nói vì sao.
Thiết kế mới <b>giữ nguyên cổng chặn đó</b> — 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”.<br>
<span class="mut">Ghi nhận thêm: lúc đó <code>ctx.active_project_id</code> vẫn giữ
<code>'default'</code> — trỏ vào một project không tồn tại. Và
<code>projects.ensure_starter_project()</code> (“đảm bảo luôn có ít nhất một project”)
<b>không nơi nào gọi</b>.</span></div>
<div class="note bad"><b>Phát hiện:</b> <code>sidebar.py:68</code> khai báo tín hiệu
<code>new_chat</code> và <code>workspace_tab.py:241</code> đã nối nó vào
<code>_on_sidebar_new</code> — nhưng <b>không nơi nào phát tín hiệu này</b>
@@ -1277,7 +1564,13 @@ Số còn lại bị ẩn, thành combo, thành nút, hoặc không tới đư
<h2>Phần 3 — Từng màn hình</h2>
{''.join(secs)}
<h2>Phần 4 — Màn chết (chỉ ghi nhận)</h2>
<h2>Phần 4 — Phát triển lần sau</h2>
<p class="mut">Những việc audit này phát hiện nhưng <b>cố ý không làm</b>, vì đều thêm
hoặc đổi chức năng — ngoài phạm vi “chỉ sắp xếp lại”.</p>
<table><tr><th style="width:26%">Việc</th><th>Chi tiết</th><th style="width:16%">Loại</th></tr>
{later}</table>
<h2>Phần 5 — Màn chết (chỉ ghi nhận)</h2>
<p class="mut">Sáu màn có trong code nhưng không tới được — tổng <b>64 control</b>
(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ỡ.</p>
@@ -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
<div class="note warn">Ngoài phạm vi: <code>settings_dialog.py:115</code> hard-code mật khẩu
Sandbox; hai lớp cùng tên <code>CustomAgent</code>
(<code>custom_agents.py:23</code> · <code>co4e.py:117</code>).</div>
</div><script>{JS}</script></body></html>"""
</div><script>{JS}</script>
{"".join(f"<script>{j}</script>" for j in HAND_JS)}
</body></html>"""
dest = OUT
dest.write_text(html, encoding="utf-8")
+11 -39
View File
@@ -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"]
+194
View File
@@ -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())
+92
View File
@@ -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())
+244
View File
@@ -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())
+117
View File
@@ -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())
+131
View File
@@ -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())
+301
View File
@@ -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())
+94
View File
@@ -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())
+112
View File
@@ -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:]))
+117
View File
@@ -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:]))
+157
View File
@@ -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'<section class="sec" id="([^"]+)">(.*?)</section>', re.S)
BODY = re.compile(r'(<div class="bd">.*)', re.S)
SHOT = re.compile(r'<div class="shot">.*?</div>', re.S)
CONTROLS = re.compile(r'<details class="ctl">.*?</details>', re.S)
STYLE = re.compile(r"<style>(.*?)</style>", re.S)
SCRIPT = re.compile(r"<script>(.*?)</script>", re.S)
def bodies(html: str) -> dict[str, str]:
"""slug -> the section's <div class="bd"> … </div>, 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+/=]+", "<IMG>", 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+/=]+", "<IMG>",
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:]))
+299 -58
View File
@@ -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"))
+31 -18
View File
@@ -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
+101 -52
View File
@@ -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"))
+27 -16
View File
@@ -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"))
+46 -3
View File
@@ -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:
+3
View File
@@ -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)
+26
View File
@@ -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"))
+127
View File
@@ -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.
+161 -4
View File
@@ -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):