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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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 & Đá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 & Đáp</span>
|
||||
<span class="sub">Chuẩn bị cho phần Q&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>< 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 < 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
File diff suppressed because one or more lines are too long
@@ -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"},
|
||||
|
||||
@@ -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
@@ -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 & 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
@@ -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"]
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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:]))
|
||||
@@ -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:]))
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user