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:
@@ -157,8 +157,6 @@ class MainWindow(QMainWindow):
|
||||
("app.tab.workspace", "workspaces", None, self.workspace),
|
||||
("app.tab.monitoring", "monitoring", self._build_monitoring, None),
|
||||
]
|
||||
# Container pages whose sub-tabs become expandable nav children.
|
||||
self._nav_parents = {self._ROW_WORKSPACE, self._ROW_MONITORING}
|
||||
self._page_widgets = [] # page index → widget (placeholder until lazily built)
|
||||
self._built = []
|
||||
for _key, _icon_name, _builder, widget in self._nav_defs:
|
||||
@@ -167,43 +165,31 @@ class MainWindow(QMainWindow):
|
||||
self._page_widgets.append(page)
|
||||
self._built.append(widget is not None)
|
||||
|
||||
# Left nav rail as a parent→child accordion (Claude-style): the container
|
||||
# pages (Workspaces, Monitoring) expand to list their sub-views as
|
||||
# children, and their in-content tab strips are hidden — so the content
|
||||
# area is as large as possible.
|
||||
from .ui.icons import icon as _icon
|
||||
self.nav = QTreeWidget()
|
||||
self.nav.setObjectName("navrail")
|
||||
self.nav.setHeaderHidden(True)
|
||||
self.nav.setIndentation(14)
|
||||
self.nav.setRootIsDecorated(True)
|
||||
self.nav.setExpandsOnDoubleClick(False)
|
||||
self._nav_items = [] # page index → top-level QTreeWidgetItem
|
||||
for page, (key, icon_name, _b, _w) in enumerate(self._nav_defs):
|
||||
it = QTreeWidgetItem([tr(key)])
|
||||
it.setIcon(0, _icon(icon_name))
|
||||
it.setData(0, Qt.UserRole, {"page": page, "sub": None,
|
||||
"parent": page in self._nav_parents, "key": key})
|
||||
# A container (Monitoring/Workspace) always shows the dropdown arrow —
|
||||
# even before its page/children are lazily built — so it's obvious it
|
||||
# holds multiple sub-views. It stays collapsed until first expanded.
|
||||
if page in self._nav_parents:
|
||||
it.setChildIndicatorPolicy(QTreeWidgetItem.ShowIndicator)
|
||||
self.nav.addTopLevelItem(it)
|
||||
self._nav_items.append(it)
|
||||
# Left nav rail — ONE FLAT LIST, no accordion. Every screen the user
|
||||
# works in is one click away: the Workspace sub-views are listed
|
||||
# directly instead of hiding behind an expandable parent. The two
|
||||
# occasional admin destinations sit in a second, bottom-pinned list.
|
||||
#
|
||||
# Monitoring is the exception that keeps its sub-views OUT of the rail:
|
||||
# it has eight, which would double the rail's length for screens opened
|
||||
# once a week. Its own tab strip is left visible instead (it was hidden
|
||||
# while the rail carried its children), so all eight stay reachable.
|
||||
self.nav = self._new_nav_tree("navrail")
|
||||
self.nav_bottom = self._new_nav_tree("navrailBottom")
|
||||
self._nav_building = False # guards the rebuild → select → rebuild loop
|
||||
self.workspace.hide_tab_bar()
|
||||
self._reload_nav_children(self._ROW_WORKSPACE) # Workspace is eager
|
||||
self.workspace.subtabs_changed.connect(
|
||||
lambda: self._reload_nav_children(self._ROW_WORKSPACE))
|
||||
self.nav.currentItemChanged.connect(lambda cur, _prev: self._navigate(cur))
|
||||
self.nav.itemClicked.connect(self._on_nav_click)
|
||||
self.nav.itemExpanded.connect(self._on_nav_expanded) # build children lazily
|
||||
self._rebuild_nav()
|
||||
self.workspace.subtabs_changed.connect(self._rebuild_nav)
|
||||
for tree in (self.nav, self.nav_bottom):
|
||||
tree.currentItemChanged.connect(
|
||||
lambda cur, _prev, t=tree: self._on_nav_current(t, cur))
|
||||
rlay.addWidget(self.pages, 1)
|
||||
|
||||
# Nav rail wrapper: a small toggle button ABOVE the page list so the
|
||||
# whole rail can collapse to icon-only (still fully clickable). Same
|
||||
# collapse/expand chevron iconography as every other collapsible panel.
|
||||
from .ui.icons import collapse_left_icon, collapse_right_icon
|
||||
from .ui.icons import icon as _icon
|
||||
self._collapse_left_icon = collapse_left_icon
|
||||
self._collapse_right_icon = collapse_right_icon
|
||||
self._nav_wrap = QWidget()
|
||||
@@ -230,7 +216,52 @@ class MainWindow(QMainWindow):
|
||||
toggle_row.addWidget(self._nav_toggle_btn, 0, Qt.AlignLeft)
|
||||
toggle_row.addStretch(1)
|
||||
nvl.addLayout(toggle_row)
|
||||
nvl.addWidget(self.nav, 1)
|
||||
# Primary action at the top of the rail, with the project it will land
|
||||
# in named right above it. Before, starting a chat in another project
|
||||
# meant leaving Cowork → Project tab → click a row → come back.
|
||||
self.nav_project = QComboBox()
|
||||
self.nav_project.setObjectName("navProjectPick")
|
||||
self.nav_project.setToolTip(tr("app.nav.project_pick"))
|
||||
self.nav_project.currentIndexChanged.connect(self._on_rail_project_pick)
|
||||
self.nav_new_chat = QPushButton(tr("cowork.new_chat"))
|
||||
self.nav_new_chat.setObjectName("navNewChatBtn")
|
||||
self.nav_new_chat.setIcon(_icon("plus"))
|
||||
self.nav_new_chat.setCursor(Qt.PointingHandCursor)
|
||||
self.nav_new_chat.clicked.connect(self._on_rail_new_chat)
|
||||
head = QVBoxLayout()
|
||||
head.setContentsMargins(6, 0, 6, 6)
|
||||
head.setSpacing(6)
|
||||
head.addWidget(self.nav_project)
|
||||
head.addWidget(self.nav_new_chat)
|
||||
nvl.addLayout(head)
|
||||
self.workspace.project_selected.connect(self._sync_rail_project)
|
||||
self.workspace.projects_changed.connect(self._sync_rail_project)
|
||||
self._syncing_rail_project = False
|
||||
self._sync_rail_project()
|
||||
nvl.addWidget(self.nav, 0)
|
||||
# RECENTS — the threads of the project named in the picker above, right
|
||||
# where Claude puts them. A shortcut only: the full History panel (search,
|
||||
# filters, pin, bulk delete, context menu) stays exactly where it is, and
|
||||
# "all projects…" at the end of this list opens it.
|
||||
self.nav_recents_hdr = QLabel(tr("app.nav.recents"))
|
||||
self.nav_recents_hdr.setObjectName("navSectionHdr")
|
||||
nvl.addWidget(self.nav_recents_hdr)
|
||||
self.nav_recents = self._new_nav_tree("navRecents")
|
||||
self.nav_recents.itemClicked.connect(self._on_rail_recent)
|
||||
nvl.addWidget(self.nav_recents, 1)
|
||||
# Bottom-pinned group: the places you visit occasionally, kept out of the
|
||||
# way of the ones you live in. A hairline (styled via #navrailBottom in
|
||||
# theme.py) separates the two lists.
|
||||
nvl.addWidget(self.nav_bottom, 0)
|
||||
self._nav_settings_btn = QPushButton(tr("app.settings"))
|
||||
self._nav_settings_btn.setObjectName("navSettingsBtn")
|
||||
self._nav_settings_btn.setIcon(_icon("settings"))
|
||||
self._nav_settings_btn.setFlat(True)
|
||||
self._nav_settings_btn.setCursor(Qt.PointingHandCursor)
|
||||
self._nav_settings_btn.clicked.connect(self._open_settings)
|
||||
nvl.addWidget(self._nav_settings_btn)
|
||||
self._account_row = self._build_account_row()
|
||||
nvl.addWidget(self._account_row)
|
||||
|
||||
self.split = QSplitter(Qt.Horizontal)
|
||||
self.split.addWidget(self._nav_wrap)
|
||||
@@ -239,9 +270,10 @@ class MainWindow(QMainWindow):
|
||||
self.split.setStretchFactor(1, 1)
|
||||
self.split.setSizes([_NAV_EXPANDED_WIDTH, 1000])
|
||||
self.setCentralWidget(self.split)
|
||||
# Workspace = landing/home (expand it and select its first sub-view).
|
||||
self._nav_items[self._ROW_WORKSPACE].setExpanded(True)
|
||||
self.nav.setCurrentItem(self._nav_items[self._ROW_WORKSPACE])
|
||||
# Landing stays Workspace ▸ Project, exactly as before. Go through _goto
|
||||
# so the page is actually shown — selecting the row alone only moves the
|
||||
# highlight (its signals are blocked to avoid rebuild loops).
|
||||
self._goto(self._ROW_WORKSPACE, self.workspace.current_subtab())
|
||||
self.toast = _Toast(self) # top-left "task done" popup
|
||||
# Floating in-app Help assistant — a robot icon pinned bottom-right on
|
||||
# every screen; expands into a small help-only chat (see
|
||||
@@ -370,12 +402,9 @@ class MainWindow(QMainWindow):
|
||||
placeholder.deleteLater()
|
||||
self._page_widgets[row] = real
|
||||
self._built[row] = True
|
||||
# Container pages: hide their in-content tab strip + list their sub-views
|
||||
# as children in the nav rail now that the real widget exists.
|
||||
if hasattr(real, "hide_tab_bar"):
|
||||
real.hide_tab_bar()
|
||||
if row in self._nav_parents:
|
||||
self._reload_nav_children(row)
|
||||
# Monitoring KEEPS its own tab strip: its eight sub-views live in the
|
||||
# page, not in the rail. Workspace is the one that hides its strip,
|
||||
# because the rail lists its sub-views directly.
|
||||
|
||||
def _page_index(self, widget) -> int:
|
||||
if widget is self.workspace:
|
||||
@@ -388,23 +417,252 @@ class MainWindow(QMainWindow):
|
||||
return self._ROW_MONITORING
|
||||
return self.pages.indexOf(widget)
|
||||
|
||||
# ---- nav rail collapse (icon-only) --------------------------------
|
||||
# ---- flat nav rail -------------------------------------------------
|
||||
def _new_nav_tree(self, name: str) -> QTreeWidget:
|
||||
"""One flat, single-column list. No indentation and no expand arrows —
|
||||
every row is a destination, nothing is a container."""
|
||||
tree = QTreeWidget()
|
||||
tree.setObjectName(name)
|
||||
tree.setHeaderHidden(True)
|
||||
tree.setIndentation(0)
|
||||
tree.setRootIsDecorated(False)
|
||||
tree.setUniformRowHeights(True)
|
||||
return tree
|
||||
|
||||
def _nav_rows(self):
|
||||
"""(tree, page, sub, label, icon, enabled) for every row, rail order.
|
||||
|
||||
Workspace contributes all five of its sub-views — including the two the
|
||||
project gate currently disables — so the rail never changes shape while
|
||||
the user is looking at it.
|
||||
"""
|
||||
rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on)
|
||||
for label, sub, ic, on in self.workspace.nav_entries()]
|
||||
rows.append((self.nav, self._ROW_SCHEDULE, None,
|
||||
tr("app.tab.schedule"), "schedule", True))
|
||||
rows.append((self.nav_bottom, self._ROW_DASHBOARD, None,
|
||||
tr("app.tab.dashboard"), "dashboard", True))
|
||||
rows.append((self.nav_bottom, self._ROW_MONITORING, None,
|
||||
tr("app.tab.monitoring"), "monitoring", True))
|
||||
return rows
|
||||
|
||||
def _rebuild_nav(self, force: bool = False) -> None:
|
||||
"""Re-fill both lists from _nav_rows(), keeping the current selection.
|
||||
|
||||
Rebuilding changes the current item, which would fire navigation and can
|
||||
loop back here via subtabs_changed — hence the guard and the blocked
|
||||
signals.
|
||||
"""
|
||||
if self._nav_building:
|
||||
return
|
||||
spec = self._nav_rows()
|
||||
# Rebuilding deletes the QTreeWidgetItems, including the one a signal is
|
||||
# currently being delivered for. subtabs_changed fires on every visit to
|
||||
# Workspace, so skip the rebuild unless the rows really differ.
|
||||
sig = [(label, page, sub, enabled)
|
||||
for _t, page, sub, label, _ic, enabled in spec]
|
||||
if not force and sig == getattr(self, "_nav_sig", None):
|
||||
return
|
||||
self._nav_sig = sig
|
||||
self._nav_building = True
|
||||
try:
|
||||
from .ui.icons import icon as _icon
|
||||
keep = self._current_nav_key()
|
||||
for tree in (self.nav, self.nav_bottom):
|
||||
blocked = tree.blockSignals(True)
|
||||
tree.clear()
|
||||
tree.blockSignals(blocked)
|
||||
for tree, page, sub, label, icon_name, enabled in spec:
|
||||
it = QTreeWidgetItem([""] if self._nav_collapsed else [label])
|
||||
it.setIcon(0, _icon(icon_name))
|
||||
it.setData(0, Qt.UserRole, {"page": page, "sub": sub})
|
||||
if not enabled:
|
||||
# Same gate as before, shown instead of hidden: the row stays
|
||||
# in place, greyed, and says why it cannot be opened.
|
||||
it.setDisabled(True)
|
||||
it.setToolTip(0, tr("app.nav.needs_project"))
|
||||
elif self._nav_collapsed:
|
||||
it.setToolTip(0, label)
|
||||
blocked = tree.blockSignals(True)
|
||||
tree.addTopLevelItem(it)
|
||||
tree.blockSignals(blocked)
|
||||
# Both destination lists are exactly as tall as their rows; the
|
||||
# stretch in between belongs to RECENTS.
|
||||
for tree in (self.nav, self.nav_bottom):
|
||||
n = tree.topLevelItemCount()
|
||||
row_h = tree.sizeHintForRow(0) if n else 0
|
||||
tree.setFixedHeight(n * row_h + 8)
|
||||
if keep:
|
||||
self._select_nav_row(*keep)
|
||||
finally:
|
||||
self._nav_building = False
|
||||
|
||||
def _current_nav_key(self):
|
||||
"""(page, sub) of the highlighted row, or None."""
|
||||
for tree in (self.nav, self.nav_bottom):
|
||||
it = tree.currentItem()
|
||||
if it is not None and it.isSelected():
|
||||
data = it.data(0, Qt.UserRole) or {}
|
||||
if "page" in data:
|
||||
return data["page"], data.get("sub")
|
||||
return None
|
||||
|
||||
def _select_nav_row(self, page: int, sub) -> None:
|
||||
"""Highlight the row for (page, sub) without triggering navigation.
|
||||
|
||||
Called both when the user clicks (to keep the two lists mutually
|
||||
exclusive) and from _goto, so programmatic navigation moves the
|
||||
highlight too — it used to stay behind on whatever was clicked last.
|
||||
"""
|
||||
for tree in (self.nav, self.nav_bottom):
|
||||
blocked = tree.blockSignals(True)
|
||||
match = None
|
||||
for i in range(tree.topLevelItemCount()):
|
||||
it = tree.topLevelItem(i)
|
||||
data = it.data(0, Qt.UserRole) or {}
|
||||
if data.get("page") == page and (
|
||||
data.get("sub") == sub or data.get("sub") is None):
|
||||
match = it
|
||||
break
|
||||
if match is not None:
|
||||
tree.setCurrentItem(match)
|
||||
else:
|
||||
tree.setCurrentItem(None)
|
||||
tree.clearSelection()
|
||||
tree.blockSignals(blocked)
|
||||
|
||||
def _on_nav_current(self, tree: QTreeWidget, item) -> None:
|
||||
"""A row was picked: clear the other list so only one row looks active."""
|
||||
if item is None or self._nav_building:
|
||||
return
|
||||
data = item.data(0, Qt.UserRole) or {}
|
||||
other = self.nav_bottom if tree is self.nav else self.nav
|
||||
blocked = other.blockSignals(True)
|
||||
other.setCurrentItem(None)
|
||||
other.clearSelection()
|
||||
other.blockSignals(blocked)
|
||||
self._goto(data.get("page", 0), data.get("sub"))
|
||||
|
||||
# ---- rail header: project picker + new chat ------------------------
|
||||
def _sync_rail_project(self, *_a) -> None:
|
||||
"""Mirror the workspace's project list/selection into the rail picker.
|
||||
|
||||
One-way on purpose: the project list stays the source of truth, this is
|
||||
only a second place to see and change it.
|
||||
"""
|
||||
if self._syncing_rail_project:
|
||||
return
|
||||
self._syncing_rail_project = True
|
||||
try:
|
||||
choices = self.workspace.project_choices()
|
||||
current = self.workspace.selected_project_id()
|
||||
self.nav_project.clear()
|
||||
for name, pid in choices:
|
||||
self.nav_project.addItem(f"📁 {name}", pid)
|
||||
if not choices:
|
||||
# No project yet: say so, and say what to do about it, instead of
|
||||
# leaving an empty box and a button that silently does nothing.
|
||||
self.nav_project.addItem(tr("app.nav.no_project"), "")
|
||||
idx = self.nav_project.findData(current)
|
||||
if idx >= 0:
|
||||
self.nav_project.setCurrentIndex(idx)
|
||||
has = bool(choices)
|
||||
self.nav_project.setEnabled(has)
|
||||
self.nav_new_chat.setEnabled(has)
|
||||
self.nav_new_chat.setToolTip(
|
||||
"" if has else tr("app.nav.create_project_first"))
|
||||
finally:
|
||||
self._syncing_rail_project = False
|
||||
|
||||
def _on_rail_project_pick(self, _idx: int) -> None:
|
||||
if self._syncing_rail_project:
|
||||
return
|
||||
pid = self.nav_project.currentData()
|
||||
if pid:
|
||||
self.workspace.choose_project(pid)
|
||||
|
||||
# ---- rail RECENTS --------------------------------------------------
|
||||
_RAIL_RECENTS = 5
|
||||
|
||||
def _refresh_rail_recents(self) -> None:
|
||||
"""Re-fill the rail's recents from the active project's history."""
|
||||
from .ui.icons import DOT_BLUE, dot_icon
|
||||
from .ui.icons import icon as _icon
|
||||
|
||||
tree = self.nav_recents
|
||||
blocked = tree.blockSignals(True)
|
||||
tree.clear()
|
||||
running = self._running_session_ids()
|
||||
threads = self.workspace.recent_threads(self._RAIL_RECENTS)
|
||||
for t in threads:
|
||||
it = QTreeWidgetItem([t["title"]])
|
||||
it.setToolTip(0, t["title"])
|
||||
if t["session_id"] in running:
|
||||
it.setIcon(0, dot_icon(DOT_BLUE)) # same marker as History
|
||||
elif t["pinned"]:
|
||||
it.setIcon(0, _icon("pin"))
|
||||
it.setData(0, Qt.UserRole, {"path": t["path"], "kind": t["kind"]})
|
||||
tree.addTopLevelItem(it)
|
||||
if not threads:
|
||||
it = QTreeWidgetItem([tr("sidebar.empty")])
|
||||
it.setDisabled(True)
|
||||
tree.addTopLevelItem(it)
|
||||
# The way back to everything the rail cannot show.
|
||||
more = QTreeWidgetItem([tr("app.nav.all_projects")])
|
||||
more.setData(0, Qt.UserRole, {"all": True})
|
||||
tree.addTopLevelItem(more)
|
||||
tree.blockSignals(blocked)
|
||||
self.nav_recents_hdr.setVisible(not self._nav_collapsed)
|
||||
self.nav_recents.setVisible(not self._nav_collapsed)
|
||||
|
||||
def _on_rail_recent(self, item, _col: int = 0) -> None:
|
||||
data = item.data(0, Qt.UserRole) or {}
|
||||
if data.get("all"):
|
||||
self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx)
|
||||
self.workspace.show_history_pane()
|
||||
return
|
||||
path = data.get("path")
|
||||
if path:
|
||||
self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx)
|
||||
self.workspace.open_thread(path, data.get("kind", "cowork"))
|
||||
|
||||
def _on_rail_new_chat(self) -> None:
|
||||
"""Start a new chat, from any screen.
|
||||
|
||||
Same call the Cowork toolbar button makes — that button stays exactly
|
||||
where it was; this is a second entry point, not a replacement.
|
||||
"""
|
||||
self._goto(self._ROW_WORKSPACE, None)
|
||||
self.workspace.start_new_chat()
|
||||
self._select_nav_row(self._ROW_WORKSPACE, self.workspace.current_subtab())
|
||||
|
||||
def _apply_nav_labels(self) -> None:
|
||||
"""Set each top-level nav item's text for the current language AND
|
||||
collapse state: collapsed shows icon-only (label → tooltip) and folds
|
||||
the accordion so only the top-level icons show."""
|
||||
for page, item in enumerate(self._nav_items):
|
||||
key = self._nav_defs[page][0]
|
||||
label = tr(key)
|
||||
item.setText(0, "" if self._nav_collapsed else label)
|
||||
item.setToolTip(0, label if self._nav_collapsed else "")
|
||||
if self._nav_collapsed:
|
||||
item.setExpanded(False)
|
||||
# Refresh child labels (language-aware, from each container's tabText).
|
||||
if not self._nav_collapsed:
|
||||
for page in self._nav_parents:
|
||||
if self._built[page]:
|
||||
self._reload_nav_children(page)
|
||||
"""Re-label every row for the current language and collapse state
|
||||
(collapsed = icon only, label moves to the tooltip)."""
|
||||
# force: collapsing leaves the row spec identical, only the text changes.
|
||||
self._rebuild_nav(force=True)
|
||||
self._nav_settings_btn.setText(
|
||||
"" if self._nav_collapsed else tr("app.settings"))
|
||||
self._nav_settings_btn.setToolTip(tr("app.settings"))
|
||||
# Collapsed to 54px there is no room for either control's label; the
|
||||
# picker would be a stub of a name, so it steps aside entirely and the
|
||||
# button keeps just its + icon.
|
||||
self.nav_project.setVisible(not self._nav_collapsed)
|
||||
self._refresh_rail_recents()
|
||||
# Collapsed to 54px only the theme toggle still fits; the rest of the
|
||||
# account row would be clipped, so it steps aside (Settings, which opens
|
||||
# the same values in a dialog, stays reachable as an icon).
|
||||
self.account_lbl.setVisible(not self._nav_collapsed)
|
||||
self.language_combo.setVisible(not self._nav_collapsed)
|
||||
self.provider_combo.setVisible(not self._nav_collapsed)
|
||||
self.nav_new_chat.setText("" if self._nav_collapsed else tr("cowork.new_chat"))
|
||||
if self._nav_new_chat_enabled():
|
||||
self.nav_new_chat.setToolTip(
|
||||
tr("cowork.new_chat") if self._nav_collapsed else "")
|
||||
self._sync_rail_project()
|
||||
|
||||
def _nav_new_chat_enabled(self) -> bool:
|
||||
return bool(self.workspace.project_choices())
|
||||
|
||||
def _toggle_nav(self) -> None:
|
||||
self._nav_collapsed = not self._nav_collapsed
|
||||
@@ -446,6 +704,7 @@ class MainWindow(QMainWindow):
|
||||
current = self.cowork.session_id
|
||||
self.sidebar.set_view_state(current, self._running_session_ids())
|
||||
self.sidebar.refresh()
|
||||
self._refresh_rail_recents() # the rail shortcut follows the panel
|
||||
|
||||
QTimer.singleShot(0, _do)
|
||||
|
||||
@@ -552,19 +811,29 @@ class MainWindow(QMainWindow):
|
||||
self.logo_lbl.setObjectName("brand") # styled centrally — see theme._TEMPLATE
|
||||
h.addWidget(self.logo_lbl)
|
||||
h.addStretch(1)
|
||||
# Provider / language / theme / Settings used to live here, five controls
|
||||
# wide across the top of every screen. They are per-account settings, not
|
||||
# per-screen ones, so they moved to the account row at the foot of the
|
||||
# rail (_build_account_row) — same widgets, same handlers, new home.
|
||||
return bar
|
||||
|
||||
self.provider_lbl = QLabel(tr("app.provider"))
|
||||
self.provider_lbl.setObjectName("hint")
|
||||
h.addWidget(self.provider_lbl)
|
||||
self.provider_combo = QComboBox()
|
||||
for key, label in PROVIDER_LABELS.items():
|
||||
self.provider_combo.addItem(label, key)
|
||||
idx = self.provider_combo.findData(self.ctx.config.active_provider)
|
||||
if idx >= 0:
|
||||
self.provider_combo.setCurrentIndex(idx)
|
||||
self.provider_combo.currentIndexChanged.connect(self._on_provider_changed)
|
||||
h.addWidget(self.provider_combo)
|
||||
def _build_account_row(self) -> QWidget:
|
||||
"""The rail's foot: who you are, and the settings that follow you.
|
||||
|
||||
Nothing new is introduced here — these are the exact widgets the top bar
|
||||
used to hold, moved as-is so every existing signal still lands.
|
||||
"""
|
||||
box = QWidget()
|
||||
box.setObjectName("navAccount")
|
||||
v = QVBoxLayout(box)
|
||||
v.setContentsMargins(6, 4, 6, 4)
|
||||
v.setSpacing(4)
|
||||
|
||||
who = QHBoxLayout()
|
||||
who.setSpacing(4)
|
||||
self.account_lbl = QLabel(f"👤 {self._user_name}" if self._user_name else "👤")
|
||||
self.account_lbl.setObjectName("hint")
|
||||
who.addWidget(self.account_lbl, 1)
|
||||
self.language_combo = QComboBox()
|
||||
for key in LANGUAGES:
|
||||
self.language_combo.addItem(LANGUAGE_SHORT.get(key, key.upper()), key)
|
||||
@@ -574,21 +843,25 @@ class MainWindow(QMainWindow):
|
||||
if idx >= 0:
|
||||
self.language_combo.setCurrentIndex(idx)
|
||||
self.language_combo.currentIndexChanged.connect(self._on_language_changed)
|
||||
h.addWidget(self.language_combo)
|
||||
|
||||
who.addWidget(self.language_combo)
|
||||
self.theme_btn = self._build_theme_button()
|
||||
h.addWidget(self.theme_btn)
|
||||
who.addWidget(self.theme_btn)
|
||||
v.addLayout(who)
|
||||
|
||||
if self._user_name:
|
||||
user_lbl = QLabel(f"👤 {self._user_name}")
|
||||
user_lbl.setObjectName("hint")
|
||||
h.addWidget(user_lbl)
|
||||
self.settings_btn = QPushButton(tr("app.settings"))
|
||||
from .ui.icons import icon as _icon
|
||||
self.settings_btn.setIcon(_icon("settings"))
|
||||
self.settings_btn.clicked.connect(self._open_settings)
|
||||
h.addWidget(self.settings_btn)
|
||||
return bar
|
||||
self.provider_lbl = QLabel(tr("app.provider"))
|
||||
self.provider_lbl.setObjectName("hint")
|
||||
self.provider_lbl.setVisible(False) # the combo names itself in the rail
|
||||
self.provider_combo = QComboBox()
|
||||
self.provider_combo.setToolTip(tr("app.provider"))
|
||||
for key, label in PROVIDER_LABELS.items():
|
||||
self.provider_combo.addItem(label, key)
|
||||
idx = self.provider_combo.findData(self.ctx.config.active_provider)
|
||||
if idx >= 0:
|
||||
self.provider_combo.setCurrentIndex(idx)
|
||||
self.provider_combo.currentIndexChanged.connect(self._on_provider_changed)
|
||||
v.addWidget(self.provider_lbl)
|
||||
v.addWidget(self.provider_combo)
|
||||
return box
|
||||
|
||||
_BRAND_LOGO_NAMES = ("fpt_logo.png", "fpt-logo.png", "logo_fpt.png", "fpt_logo.jpg")
|
||||
_BRAND_LOGO_HEIGHT = 22
|
||||
@@ -680,46 +953,6 @@ class MainWindow(QMainWindow):
|
||||
self.sidebar.refresh()
|
||||
self.statusBar().showMessage(tr("app.status.settings_saved"))
|
||||
|
||||
def _reload_nav_children(self, page: int) -> None:
|
||||
"""(Re)build the nav children of a container page from its current
|
||||
sub-views. Called when the page is built, when Workspace sub-tab
|
||||
visibility changes, and on language change."""
|
||||
if not (0 <= page < len(self._nav_items)):
|
||||
return
|
||||
item = self._nav_items[page]
|
||||
expanded = item.isExpanded()
|
||||
item.takeChildren()
|
||||
widget = self._page_widgets[page]
|
||||
if not hasattr(widget, "nav_subtabs"):
|
||||
return
|
||||
from .ui.icons import icon as _icon
|
||||
for label, sub, icon_name in widget.nav_subtabs():
|
||||
child = QTreeWidgetItem([label])
|
||||
child.setIcon(0, _icon(icon_name))
|
||||
child.setData(0, Qt.UserRole, {"page": page, "sub": sub, "parent": False})
|
||||
item.addChild(child)
|
||||
item.setExpanded(True)
|
||||
|
||||
def _on_nav_click(self, item, _col: int = 0) -> None:
|
||||
# Clicking a parent toggles its expansion (its page is still shown).
|
||||
data = item.data(0, Qt.UserRole) or {}
|
||||
if data.get("parent"):
|
||||
item.setExpanded(not item.isExpanded())
|
||||
|
||||
def _on_nav_expanded(self, item) -> None:
|
||||
# Expanding a container whose children aren't built yet (e.g. Monitoring
|
||||
# on first open, shown via its always-on dropdown arrow) builds its page
|
||||
# so the sub-views appear.
|
||||
data = item.data(0, Qt.UserRole) or {}
|
||||
if data.get("parent") and item.childCount() == 0:
|
||||
self._ensure_page(data.get("page", 0))
|
||||
|
||||
def _navigate(self, item) -> None:
|
||||
if item is None:
|
||||
return
|
||||
data = item.data(0, Qt.UserRole) or {}
|
||||
self._goto(data.get("page", 0), data.get("sub"))
|
||||
|
||||
def _goto(self, page: int, sub) -> None:
|
||||
self._ensure_page(page) # build lazy page on first visit
|
||||
self.pages.setCurrentIndex(page)
|
||||
@@ -728,6 +961,10 @@ class MainWindow(QMainWindow):
|
||||
widget = self._page_widgets[page]
|
||||
if sub is not None and hasattr(widget, "select_subtab"):
|
||||
widget.select_subtab(sub)
|
||||
# Move the highlight with the content, however navigation was triggered —
|
||||
# a programmatic _goto used to leave it on whatever was clicked last.
|
||||
if not self._nav_building:
|
||||
self._select_nav_row(page, sub)
|
||||
# Switching pages updates which conversation is "current".
|
||||
self._refresh_history()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user