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
+161 -4
View File
@@ -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):