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:
+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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user