feat(ui): close the last gaps against the audit design (31/31)

check_design_parity.py reads its checklist from the audit page's own
proposals; it now reports every one of the 31 as implemented, with no
deliberate divergences left.

  * Schedule: the Kanban/Calendar drop-list became a pair of tabs, and the
    Running lane is outlined while it holds anything — dropping a card there
    starts the task for real, so it should not look like the other six.
  * Cowork: agent / routing / usage / folder moved out of the typing box into
    their own status strip beneath it, styled as status rather than a second
    toolbar. All of them stay interactive; the design's read-only strip would
    have cost features.
  * Folder: the path is written as the screen's title instead of sitting in a
    read-only text box that looked editable and cost a row.
  * GraphRAG: the second toolbar row is gone (Export joined the first), and
    the one button that relabelled itself became Đồ thị | Tin nhắn tabs, so
    the view you are NOT in is named too.
  * Settings gained the theme picker, so language / provider / theme are all
    reachable there as well as on the rail's account row.
  * Task editor: the five group boxes are grouped into three step tabs
    (Nội dung → Lịch chạy → Liên kết). All 22 fields verified present after
    the move; only the old section index is gone, replaced by the tabs.
  * The assistant dot now clears a screen's own bottom bar (Cowork's
    composer), measured from the composer's top edge in window coordinates.

Also adds .gitattributes: without it a Windows checkout records CRLF and
every file reads as fully rewritten to a Linux CI runner.

Verification: 7 check_*.py suites green, no screen clipped at 1920/1366/1280,
and no dialog scrolls sideways at 9/11/14pt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
NamPDT
2026-08-17 12:25:25 +09:00
co-authored by Claude Opus 5
parent 0fa61b6a95
commit cb68f87b35
15 changed files with 454 additions and 93 deletions
+10 -3
View File
@@ -460,11 +460,18 @@ class Composer(QWidget):
# bottom row: left slot (e.g. Cowork's output-folder picker) — stretch —
# right slot (e.g. Plan/Act toggle, the per-tab Agent combo on Code/Cowork)
# Its own strip UNDER the typing box, styled as a status line rather
# than a second toolbar: the design asks for the typing area to be just
# input · attach · send, with agent / routing / usage / folder reading
# as status underneath. They stay interactive — only quieter.
self._bottom_left_count = 0
self.extra_row = QHBoxLayout()
self.extra_row.setContentsMargins(0, 0, 0, 0)
self.extra_bar = QWidget()
self.extra_bar.setObjectName("composerStatus")
self.extra_row = QHBoxLayout(self.extra_bar)
self.extra_row.setContentsMargins(2, 2, 2, 0)
self.extra_row.setSpacing(6)
self.extra_row.addStretch(1)
root.addLayout(self.extra_row)
root.addWidget(self.extra_bar)
on_language_changed(self._retranslate)
+14 -5
View File
@@ -261,14 +261,20 @@ class FolderTab(QWidget):
root = QVBoxLayout(self)
# The path IS the title of this screen, so it is written as one rather
# than shown in a read-only text box that looks editable and costs a
# whole row of its own. Full path on hover; the button still opens the
# folder picker.
bar = QHBoxLayout()
self.path_edit = QLineEdit(self._root)
self.path_edit.setReadOnly(True)
self.path_lbl = QLabel(self._root)
self.path_lbl.setObjectName("folderTitle")
self.path_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse)
self.path_lbl.setToolTip(self._root)
self._open_btn = QPushButton()
self._open_btn.setIcon(icon("folder"))
self._open_btn.setObjectName("primary")
self._open_btn.clicked.connect(self._pick_root)
bar.addWidget(self.path_edit, 1)
bar.addWidget(self.path_lbl, 1)
bar.addWidget(self._open_btn)
root.addLayout(bar)
@@ -383,7 +389,8 @@ class FolderTab(QWidget):
if not p or not os.path.isdir(p):
return
self._root = p
self.path_edit.setText(p)
self.path_lbl.setText(p)
self.path_lbl.setToolTip(p)
self.model.setRootPath(p)
self.tree.setRootIndex(self.model.index(p))
if getattr(self, "terminal", None) is not None:
@@ -1492,7 +1499,9 @@ class FolderTab(QWidget):
else tr("folder.preview"))
def _retranslate(self) -> None:
self.path_edit.setPlaceholderText(tr("folder.path_placeholder"))
# The label always shows a real path, so the placeholder became a
# tooltip hint on the button that changes it.
self._open_btn.setToolTip(tr("folder.path_placeholder"))
self._open_btn.setText(tr("folder.open_folder"))
self.save_btn.setText(tr("folder.save"))
self.ext_btn.setText(tr("folder.open_external"))
+13 -1
View File
@@ -334,13 +334,25 @@ class HelpAgentWidget(QWidget):
self.reposition()
self.raise_()
# A screen whose bottom edge is an input row (Cowork's composer) must not
# have the dock sitting on top of it — set by MainWindow when the page
# changes, in window coordinates.
_bottom_guard = 0
def set_bottom_guard(self, height: int) -> None:
"""Reserve `height` px at the foot of the window for the page's own
controls; the dock floats above it instead of over the Send button."""
if height != self._bottom_guard:
self._bottom_guard = max(0, height)
self.reposition()
def reposition(self) -> None:
"""Pin to the parent's bottom-right corner (called on parent resize)."""
p = self.parentWidget()
if p is None:
return
x = max(0, p.width() - self.width() - _MARGIN)
y = max(0, p.height() - self.height() - _MARGIN)
y = max(0, p.height() - self.height() - _MARGIN - self._bottom_guard)
self.move(x, y)
# ---- rendering --------------------------------------------------------
+26 -9
View File
@@ -16,8 +16,8 @@ from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout,
QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox,
QPlainTextEdit, QPushButton, QScrollArea, QStackedWidget, QTableWidget,
QTableWidgetItem, QVBoxLayout, QWidget,
QPlainTextEdit, QPushButton, QScrollArea, QStackedWidget, QTabBar,
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
)
from ..core import tasks as taskrepo
@@ -92,13 +92,18 @@ class ScheduleTaskTab(QWidget):
self.ai_btn = QPushButton()
self.ai_btn.setIcon(icon("sparkle"))
self.ai_btn.clicked.connect(self._ai_create)
self.view_combo = QComboBox()
for v in _VIEWS:
self.view_combo.addItem("", v)
self.view_combo.currentIndexChanged.connect(self._on_view_changed)
# Two views of the same tasks, so they read as a pair of tabs rather
# than a drop-list you have to open to discover the Calendar exists.
self.view_tabs = QTabBar()
self.view_tabs.setObjectName("viewTabs")
self.view_tabs.setDrawBase(False)
self.view_tabs.setExpanding(False)
for _v in _VIEWS:
self.view_tabs.addTab("")
self.view_tabs.currentChanged.connect(self._on_view_changed)
header.addWidget(self._title)
header.addWidget(self.counts_lbl, 1)
header.addWidget(self.view_combo)
header.addWidget(self.view_tabs)
header.addWidget(self.add_btn)
header.addWidget(self.ai_btn)
root.addLayout(header)
@@ -162,14 +167,14 @@ class ScheduleTaskTab(QWidget):
self.ai_btn.setText(tr("schedtask.ai_btn"))
self.ai_btn.setToolTip(tr("schedtask.ai_tooltip"))
for i, v in enumerate(_VIEWS):
self.view_combo.setItemText(i, tr(f"schedtask.view.{v}"))
self.view_tabs.setTabText(i, tr(f"schedtask.view.{v}"))
for status, col in self.columns.items():
col.setToolTip(tr(f"schedtask.col_tip.{status}"))
self.refresh()
# ---- Kanban / Calendar view switch --------------------------------
def _on_view_changed(self) -> None:
self._view_stack.setCurrentIndex(self.view_combo.currentIndex())
self._view_stack.setCurrentIndex(self.view_tabs.currentIndex())
def _add_task_on_date(self, date_str: str) -> None:
"""Create a task pre-filled with the clicked calendar date (default
@@ -213,9 +218,21 @@ class ScheduleTaskTab(QWidget):
item = QListWidgetItem(self._card_text(t))
item.setData(Qt.UserRole, t["task_id"])
self.columns[status].addItem(item)
pal = current_palette()
for status, col in self.columns.items():
self.column_headers[status].setText(
f"{tr(f'schedtask.status.{status}')} ({counts[status]})")
# Dropping a card into Running STARTS the task for real, so that
# lane is outlined while it holds anything — the one column here
# with a side effect should not look like the other six.
if status == "running" and counts[status]:
col.setStyleSheet(
f"border: 1px solid {pal.warning}; border-radius: {pal.radius}px;")
self.column_headers[status].setStyleSheet(
f"font-weight:600; color: {pal.warning};")
else:
col.setStyleSheet("")
self.column_headers[status].setStyleSheet("font-weight:600;")
if col.count() == 0:
empty = QListWidgetItem(tr("schedtask.no_tasks"))
empty.setFlags(Qt.NoItemFlags)
+12
View File
@@ -57,6 +57,15 @@ class SettingsDialog(QDialog):
self._select_combo(self.language_combo, ctx.config.language)
top.addRow(tr("settings.language"), self.language_combo)
# Theme belongs with the other per-account settings. It is also on the
# rail's account row (one click for the common flip); this is the same
# value, named and explained, for people who come looking in Settings.
self.theme_combo = QComboBox()
for key in ("system", "dark", "light"):
self.theme_combo.addItem(tr(f"settings.theme_{key}"), key)
self._select_combo(self.theme_combo, getattr(ctx.config, "theme", "system"))
top.addRow(tr("settings.theme"), self.theme_combo)
self.tray_chk = QCheckBox(tr("settings.tray_keep"))
self.tray_chk.setChecked(bool(data.get("tray", {}).get("minimize_on_close", True)))
top.addRow("", self.tray_chk)
@@ -642,6 +651,9 @@ class SettingsDialog(QDialog):
data = self.ctx.config.data
data["active_provider"] = self.provider_combo.currentData()
data["language"] = self.language_combo.currentData()
# MainWindow._open_settings re-applies the theme after this returns, so
# writing the value here is enough to make it take effect.
data["theme"] = self.theme_combo.currentData()
self._stash_provider_fields()
for key, staged in self._prov_staging.items():
+41 -29
View File
@@ -18,7 +18,7 @@ from PySide6.QtGui import QBrush, QColor, QFont, QPen
from PySide6.QtWidgets import (
QComboBox, QFileDialog, QGraphicsEllipseItem, QGraphicsLineItem,
QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout,
QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget,
QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, QTabBar,
QTextBrowser, QVBoxLayout, QWidget,
)
@@ -229,29 +229,36 @@ class StructureGraphView(QWidget):
self._scan_btn.setIcon(icon("search"))
self._scan_btn.setObjectName("primary")
self._scan_btn.clicked.connect(self._scan)
bar.addWidget(self.path_edit, 1)
bar.addWidget(self._pick_btn)
bar.addWidget(self.project_combo)
bar.addWidget(self._scan_btn)
root.addLayout(bar)
self._refresh_project_combo()
# Toolbar: messages toggle + export
bar2 = QHBoxLayout()
bar2.addStretch(1)
self._msgs_toggle_btn = QPushButton()
self._msgs_toggle_btn.setIcon(icon("message"))
self._msgs_toggle_btn.setToolTip(tr("structure.msgs_tooltip"))
self._msgs_toggle_btn.clicked.connect(self._toggle_messages)
bar2.addWidget(self._msgs_toggle_btn)
# ONE toolbar row. There used to be a second row holding just the
# messages toggle and Export, which cost a whole row of height to carry
# two buttons.
self._export_btn = QPushButton()
self._export_btn.setIcon(icon("upload"))
self._export_btn.setObjectName("primary")
self._export_btn.clicked.connect(self._export)
bar2.addWidget(self._export_btn)
bar.addWidget(self.path_edit, 1)
bar.addWidget(self._pick_btn)
bar.addWidget(self.project_combo)
bar.addWidget(self._scan_btn)
bar.addWidget(self._export_btn)
root.addLayout(bar)
self._refresh_project_combo()
root.addLayout(bar2)
# Đồ thị | Tin nhắn as a real pair of tabs: the old single button
# relabelled itself, so the view you were NOT looking at was the only
# one named on screen.
self.view_tabs = QTabBar()
self.view_tabs.setObjectName("viewTabs")
self.view_tabs.setDrawBase(False)
self.view_tabs.setExpanding(False)
self.view_tabs.addTab(icon("graph"), "")
self.view_tabs.addTab(icon("message"), "")
self.view_tabs.currentChanged.connect(self._on_view_tab)
tab_row = QHBoxLayout()
tab_row.setContentsMargins(0, 0, 0, 0)
tab_row.addWidget(self.view_tabs)
tab_row.addStretch(1)
root.addLayout(tab_row)
split = QSplitter(Qt.Horizontal)
self.scene = QGraphicsScene()
@@ -337,8 +344,10 @@ class StructureGraphView(QWidget):
self._pick_btn.setText(tr("structure.browse"))
self._scan_btn.setText(tr("structure.scan"))
self._export_btn.setText(tr("structure.export_png"))
showing = self._stack.currentWidget() is getattr(self, "_msgs_view", None)
self._msgs_toggle_btn.setText(tr("structure.graph_btn") if showing else tr("structure.msgs_btn"))
# Both views are named at once now, so neither label depends on state.
self.view_tabs.setTabText(0, tr("structure.graph_btn"))
self.view_tabs.setTabText(1, tr("structure.msgs_btn"))
self.view_tabs.setTabToolTip(1, tr("structure.msgs_tooltip"))
self._ag_collapse.setToolTip(tr("structure.collapse_agent_tooltip"))
self._ag_label.setText(tr("structure.agent_header"))
self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder"))
@@ -413,16 +422,19 @@ class StructureGraphView(QWidget):
self._rescan_timer.start()
# ---- Messages (by day, as JSON) --------------------------------------
def _toggle_messages(self) -> None:
"""Switch between the knowledge graph and the Messages-by-day view."""
showing = self._stack.currentWidget() is self._msgs_view
if showing:
self._stack.setCurrentWidget(self.web if self.web is not None else self.view)
else:
def _on_view_tab(self, index: int) -> None:
"""Tab 0 = graph, tab 1 = messages. Same two views as before, now named
on screen instead of hidden behind one button's changing label."""
if index == 1:
self._reload_messages()
self._stack.setCurrentWidget(self._msgs_view)
self._msgs_toggle_btn.setText(
tr("structure.graph_btn") if not showing else tr("structure.msgs_btn"))
else:
self._stack.setCurrentWidget(self.web if self.web is not None else self.view)
def _toggle_messages(self) -> None:
"""Kept for callers that still ask for a flip (e.g. keyboard paths)."""
showing = self._stack.currentWidget() is self._msgs_view
self.view_tabs.setCurrentIndex(0 if showing else 1)
def _reload_messages(self) -> None:
"""Build the tree: day → conversation. Click a conversation to see its
+43 -26
View File
@@ -19,7 +19,7 @@ from PySide6.QtWidgets import (
QCheckBox, QComboBox, QDateTimeEdit, QDialog, QDialogButtonBox, QFileDialog,
QFormLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit,
QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton,
QScrollArea, QSpinBox, QVBoxLayout, QWidget,
QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
)
from ..config import PROVIDER_LABELS
@@ -93,12 +93,11 @@ class TaskEditorDialog(QDialog):
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()
# Held in its own widget so the whole block can be moved into a step
# tab below; it is a bare form, not a group box.
self._basic_box = QWidget()
form = QFormLayout(self._basic_box)
form.setContentsMargins(0, 0, 0, 0)
self.title_edit = QLineEdit(self.task.get("title", ""))
# Description is the source of truth. Its ✨ button GENERATES the Prompt
# (Input) FROM the description — the title is just the task's label and
@@ -211,7 +210,7 @@ class TaskEditorDialog(QDialog):
form.addRow(tr("schedtask.f_skill"), self.skill_combo)
form.addRow(tr("schedtask.f_priority"), self.priority_combo)
form.addRow(tr("schedtask.f_status"), self.status_combo)
root.addLayout(form)
root.addWidget(self._basic_box)
self._main_form = form
self._model_box = model_box
self._on_run_kind_changed() # apply agent/flow row visibility
@@ -433,25 +432,38 @@ 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),
])
# Three steps, as tabs: Nội dung → Lịch chạy → Liên kết. The five group
# boxes are re-parented into three pages — none is dropped, they are
# grouped by the question being answered rather than stacked in one
# scroll where the later ones are out of sight.
self.step_tabs = QTabWidget()
self.step_tabs.setObjectName("taskSteps")
pages = [
("schedtask.step_content", [self._basic_box, ig]),
("schedtask.step_schedule", [sg]),
("schedtask.step_link", [dg, eg]),
]
self._step_keys = [key for key, _ in pages]
for _key, groups in pages:
page = QWidget()
pv = QVBoxLayout(page)
pv.setContentsMargins(4, 8, 4, 4)
for g in groups:
root.removeWidget(g)
pv.addWidget(g)
pv.addStretch(1)
wrap = QScrollArea()
wrap.setWidgetResizable(True)
wrap.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
wrap.setWidget(page)
self.step_tabs.addTab(wrap, "")
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)
scroll.setParent(None)
outer.insertWidget(0, self.step_tabs, 1)
self._retranslate_steps()
# Floor the width at what the widest page needs, at the font in use.
self.setMinimumWidth(max(p.widget().sizeHint().width()
for p in self.step_tabs.findChildren(QScrollArea)) + 60)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.button(QDialogButtonBox.Save).setIcon(icon("save"))
@@ -465,6 +477,11 @@ class TaskEditorDialog(QDialog):
from .widgets import guard_wheel
guard_wheel(self)
def _retranslate_steps(self) -> None:
"""Name the three step tabs for the current language."""
for i, key in enumerate(self._step_keys):
self.step_tabs.setTabText(i, f"{i + 1}. {tr(key)}")
def _apply_hints(self) -> None:
"""Tooltip hints on every non-obvious control, so each option explains
itself on hover."""