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