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

Implements the redesign from docs/ui-audit.html. Rearrangement only — no
feature was removed; every control that moved kept its handler, and the
gates that used to hide things now grey them out instead.

Navigation
  * The rail is one flat list: the five Workspace sub-views sit at the top
    level instead of behind an accordion, with Dashboard/Monitoring pinned
    at the foot and Settings below them.
  * Cowork and GraphRAG stay listed and greyed while no project is
    selected, rather than vanishing and resizing the menu under the user.
  * Monitoring keeps its eight sub-views in its own tab strip (unhidden)
    instead of doubling the rail's length.
  * _goto now moves the highlight itself, fixing a long-standing bug where
    programmatic navigation left the rail pointing at the previous screen.
  * Rail header gained the project picker and "New chat"; RECENTS lists the
    active project's threads. Both are second views of existing state — the
    Cowork toolbar button and the full History panel are untouched.
  * Provider / language / theme moved from the top bar to an account row at
    the foot of the rail (same widgets, same signals).

Screens
  * Co4E: the flow tab strip is gone (per the design); Flow Status became a
    toolbar toggle with its own way back, and the three icon-only tabs became
    four labelled, foldable sections in one column. One flow open at a time
    is the one capability this costs; background runs are unaffected.
  * Dashboard: header split into two rows; cost promoted to a hero card.
  * Monitoring Overview: one scrolling column of titled sections; the model
    price table got its own full-width section instead of sharing a row with
    the CPU meters.
  * Settings and Task editor gained a section index down the left.
  * Help dock: 84x64 launcher + chevron became one 26px dot that expands to
    a labelled pill on hover; "hide to the edge" moved into the panel's menu.

Layout
  * The window's minimum width dropped from 1453px to 768px. The main cause
    was a QTabWidget taking its minimum from the widest page even when that
    page is hidden, so Co4E was forcing Project and Cowork wide.
  * Secondary panes fold themselves on a narrow window and restore when it
    grows, never overriding a fold the user made.
  * The long dialogs no longer scroll sideways at any font size.

Verification: tools/check_*.py build a real MainWindow offscreen against a
copy of ~/.cowork_local with the schedulers no-oped. check_design_parity.py
reads its checklist straight from the audit page's own proposals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
NamPDT
2026-08-17 11:40:13 +09:00
co-authored by Claude Opus 5
parent 291a611737
commit 0fa61b6a95
27 changed files with 4346 additions and 384 deletions
+101 -52
View File
@@ -17,9 +17,9 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtGui import QIcon, QPixmap
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTextBrowser,
QFrame, QHBoxLayout, QLabel, QLineEdit, QMenu, QPushButton, QTextBrowser,
QVBoxLayout, QWidget,
)
@@ -31,11 +31,15 @@ from .icons import icon
_ASSETS = Path(__file__).resolve().parent.parent / "assets"
_MARGIN = 18 # gap from the window's bottom-right corner
_LAUNCHER = 64 # collapsed app-icon badge size (a clean rounded card, like image 2)
_LAUNCHER_ICON = 52 # the icon inside it, inset so the light badge frames it
_COLLAPSE_W, _COLLAPSE_H = 18, 44 # the "hide to the edge" chevron beside it
_GAP = 2
_TAB_W, _TAB_H = 16, 48 # the thin "show" tab when hidden at the edge
# Closed, the assistant is a single 26px dot. It used to be an 84×64 block (a
# 64px badge plus an 18px "hide" chevron beside it) sitting permanently over the
# bottom-right of every screen — on Cowork, right on top of the Send button —
# for something opened a few times a day. The name now appears on hover only,
# and "hide to the edge" moved into the panel's ⋯ menu.
_DOT = 26 # closed launcher (a round chip)
_DOT_ICON = 14 # the sparkle inside it
_PILL_PAD = 12 # extra width for the label when hovered
_TAB_W, _TAB_H = 28, 48 # the "show" tab when hidden at the edge (was 16 wide)
_PANEL_W, _PANEL_H = 340, 460 # expanded chat panel size
# The three states the floating assistant cycles through.
@@ -53,27 +57,46 @@ def _app_icon() -> QIcon:
return QIcon(str(p)) if p.exists() else icon("robot")
def _app_pixmap(size: int) -> QPixmap:
"""icon.png scaled to ``size`` (smooth), for the launcher badge label."""
p = _ASSETS / "icon.png"
if p.exists():
return QPixmap(str(p)).scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
return icon("robot").pixmap(size, size)
# _app_pixmap()/_IconTap were the 52px icon and its click-through QLabel for the
# old 64px badge. The launcher is a real button now, so both are gone.
class _IconTap(QLabel):
"""A QLabel that behaves like a button (click → signal) — used for the
launcher badge so it carries NO QPushButton chrome/box, just the icon on a
clean rounded card."""
class _HoverPill(QPushButton):
"""The closed launcher: a dot at rest, a labelled pill under the pointer.
clicked = Signal()
Keyboard focus counts as hover, so the name is reachable without a mouse.
Resizing is delegated to the owner because this widget is inside an overlay
that has to re-pin itself to the window corner whenever its size changes.
"""
def mousePressEvent(self, e): # noqa: N802 - Qt override
if e.button() == Qt.LeftButton:
self.clicked.emit()
e.accept()
def __init__(self, owner):
super().__init__(owner)
self._owner = owner
self.open = False
def _set_open(self, value: bool) -> None:
if value == self.open:
return
super().mousePressEvent(e)
self.open = value
self.setText(f" {tr('help_agent.badge')}" if value else "")
self._owner._layout_launcher()
def enterEvent(self, e): # noqa: N802 - Qt override
self._set_open(True)
super().enterEvent(e)
def leaveEvent(self, e): # noqa: N802 - Qt override
if not self.hasFocus():
self._set_open(False)
super().leaveEvent(e)
def focusInEvent(self, e): # noqa: N802 - Qt override
self._set_open(True)
super().focusInEvent(e)
def focusOutEvent(self, e): # noqa: N802 - Qt override
self._set_open(False)
super().focusOutEvent(e)
class HelpAgentWidget(QWidget):
@@ -118,7 +141,7 @@ class HelpAgentWidget(QWidget):
self._apply_style()
muted = self._pal.text_muted
self.edge_tab.setIcon(icon("chevron-left", color=muted))
self.collapse_btn.setIcon(icon("chevron-right", color=muted))
self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=self._pal.accent))
self.min_btn.setIcon(icon("minus", color=muted))
self._render()
@@ -129,13 +152,18 @@ class HelpAgentWidget(QWidget):
p = self._pal
r, rl = p.radius, p.radius_lg
self.setStyleSheet(f"""
/* The app-icon badge that opens the dock: a plain card, no button box. */
/* Closed launcher: a {_DOT}px dot. `pill` flips to true on hover, when
the label comes out and the shape stretches to a rounded bar. */
#helpLauncher {{ background: {p.surface}; border: 1px solid {p.border};
border-radius: {rl}px; }}
#helpLauncher:hover {{ background: {p.hover}; }}
#helpCollapseBtn, #helpEdgeTab {{ background: {p.surface}; border: none;
border-radius: {r}px; }}
#helpCollapseBtn:hover, #helpEdgeTab:hover {{ background: {p.hover}; }}
border-radius: {_DOT // 2}px; color: {p.text}; font-weight: 600;
font-size: 12px; padding: 0; text-align: center; }}
#helpLauncher[pill="true"] {{ text-align: left; padding-left: 6px; }}
#helpLauncher:hover {{ background: {p.hover}; border-color: {p.border_strong}; }}
#helpLauncher:focus {{ border: 1px solid {p.focus_ring}; }}
#helpEdgeTab {{ background: {p.surface}; border: 1px solid {p.border};
border-right: none; border-top-left-radius: {r}px;
border-bottom-left-radius: {r}px; }}
#helpEdgeTab:hover {{ background: {p.hover}; }}
#helpPanel {{ background: {p.surface}; border: 1px solid {p.border};
border-radius: {rl}px; color: {p.text}; }}
#helpHeader {{ background: {p.surface}; border-bottom: 1px solid {p.border};
@@ -174,20 +202,12 @@ class HelpAgentWidget(QWidget):
self.edge_tab.clicked.connect(self._show_launcher)
def _build_launcher(self) -> None:
# A left-side chevron collapses the assistant to the edge…
self.collapse_btn = QPushButton(self)
self.collapse_btn.setObjectName("helpCollapseBtn")
self.collapse_btn.setIcon(icon("chevron-right", color=self._pal.text_muted))
self.collapse_btn.setCursor(Qt.PointingHandCursor)
self.collapse_btn.setToolTip(tr("help_agent.hide_tooltip"))
self.collapse_btn.clicked.connect(self._hide_to_edge)
# …and the app icon itself opens the chat — a clean rounded badge (like
# image 2), NOT a QPushButton (which added a pale box around the icon).
self.launcher = _IconTap(self)
# One control, one job: this opens the chat. The chevron that used to sit
# beside it (a second 18px hit target for a second meaning of "closed")
# is gone — hiding to the edge is now a line in the panel's ⋯ menu.
self.launcher = _HoverPill(self)
self.launcher.setObjectName("helpLauncher")
self.launcher.setFixedSize(_LAUNCHER, _LAUNCHER)
self.launcher.setAlignment(Qt.AlignCenter)
self.launcher.setPixmap(_app_pixmap(_LAUNCHER_ICON))
self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=self._pal.accent))
self.launcher.setCursor(Qt.PointingHandCursor)
self.launcher.setToolTip(tr("help_agent.open_tooltip"))
self.launcher.clicked.connect(self._expand)
@@ -219,6 +239,21 @@ class HelpAgentWidget(QWidget):
self.min_btn.setToolTip(tr("help_agent.collapse_tooltip"))
self.min_btn.clicked.connect(self._collapse)
hb.addWidget(self.min_btn)
# "Hide to the right edge" lives here now, next to "minimise", instead of
# as a permanent 18px chevron on every screen. Same action, offered where
# the user is already interacting with the assistant.
self.more_btn = QPushButton("⋯", header)
self.more_btn.setObjectName("helpMinBtn")
self.more_btn.setFixedSize(24, 24)
self.more_btn.setCursor(Qt.PointingHandCursor)
self.more_btn.setToolTip(tr("help_agent.more_tooltip"))
menu = QMenu(self.more_btn)
self.act_collapse = menu.addAction(tr("help_agent.collapse_tooltip"))
self.act_collapse.triggered.connect(self._collapse)
self.act_hide = menu.addAction(tr("help_agent.hide_tooltip"))
self.act_hide.triggered.connect(self._hide_to_edge)
self.more_btn.setMenu(menu)
hb.addWidget(self.more_btn)
v.addWidget(header)
# Conversation log
@@ -267,23 +302,32 @@ class HelpAgentWidget(QWidget):
self._state = _LAUNCHER_ST
self._apply_state()
def _layout_launcher(self) -> None:
"""Size the overlay to the dot, or to the pill while it is hovered."""
w = _DOT
if self.launcher.open:
w = max(_DOT, self.launcher.fontMetrics()
.horizontalAdvance(self.launcher.text()) + _DOT + _PILL_PAD)
self.resize(w, _DOT)
self.launcher.setGeometry(0, 0, w, _DOT)
# Round while it is a dot, pill-shaped once the label is out.
self.launcher.setProperty("pill", bool(self.launcher.open))
self.launcher.style().unpolish(self.launcher)
self.launcher.style().polish(self.launcher)
self.reposition()
self.raise_()
def _apply_state(self) -> None:
st = self._state
self.edge_tab.setVisible(st == _HIDDEN)
self.collapse_btn.setVisible(st == _LAUNCHER_ST)
self.launcher.setVisible(st == _LAUNCHER_ST)
self.panel.setVisible(st == _PANEL)
if st == _PANEL:
self.resize(_PANEL_W, _PANEL_H)
self.panel.setGeometry(0, 0, _PANEL_W, _PANEL_H)
elif st == _LAUNCHER_ST:
w = _LAUNCHER + _GAP + _COLLAPSE_W
self.resize(w, _LAUNCHER)
# Icon on the left, the collapse chevron on the RIGHT (toward the
# screen edge it tucks into).
self.launcher.setGeometry(0, 0, _LAUNCHER, _LAUNCHER)
self.collapse_btn.setGeometry(_LAUNCHER + _GAP, (_LAUNCHER - _COLLAPSE_H) // 2,
_COLLAPSE_W, _COLLAPSE_H)
self._layout_launcher()
return # _layout_launcher repositions and raises
else: # hidden
self.resize(_TAB_W, _TAB_H)
self.edge_tab.setGeometry(0, 0, _TAB_W, _TAB_H)
@@ -384,6 +428,11 @@ class HelpAgentWidget(QWidget):
self.title.setText(tr("help_agent.title"))
self.input.setPlaceholderText(tr("help_agent.placeholder"))
self.launcher.setToolTip(tr("help_agent.open_tooltip"))
if self.launcher.open:
self.launcher.setText(f" {tr('help_agent.badge')}")
self._layout_launcher()
self.min_btn.setToolTip(tr("help_agent.collapse_tooltip"))
self.collapse_btn.setToolTip(tr("help_agent.hide_tooltip"))
self.more_btn.setToolTip(tr("help_agent.more_tooltip"))
self.act_collapse.setText(tr("help_agent.collapse_tooltip"))
self.act_hide.setText(tr("help_agent.hide_tooltip"))
self.edge_tab.setToolTip(tr("help_agent.show_tooltip"))