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:
+299
-58
@@ -261,7 +261,7 @@ class Co4ETab(QWidget):
|
||||
root.addWidget(self._split)
|
||||
|
||||
sidebar = self._build_sidebar()
|
||||
sidebar.setMinimumWidth(210)
|
||||
sidebar.setMinimumWidth(180) # 210 pushed the whole tab past 1214px min
|
||||
self._split.addWidget(sidebar)
|
||||
self._split.addWidget(self._build_center())
|
||||
self.config = StepConfigPanel(ctx)
|
||||
@@ -273,6 +273,19 @@ class Co4ETab(QWidget):
|
||||
self._config_collapsed = False
|
||||
self._config_expanded_w = 360
|
||||
self._split.addWidget(self._wrap_config())
|
||||
from PySide6.QtCore import QTimer
|
||||
|
||||
from .widgets import narrow_guard
|
||||
self._narrow_guard = narrow_guard(self, self._NARROW, self._apply_narrow_layout)
|
||||
# Deferred one tick: the parent chain (and therefore window()) only
|
||||
# exists after whoever is building this has finished adding it.
|
||||
QTimer.singleShot(0, self._narrow_guard.attach)
|
||||
# Start the sidebar wider than its 180px floor — at the floor the
|
||||
# "Chạy nền" button and the flow names are cut off.
|
||||
self._split.setSizes([230, 720, 360])
|
||||
self._split.setStretchFactor(0, 0)
|
||||
self._split.setStretchFactor(1, 1)
|
||||
self._split.setStretchFactor(2, 0)
|
||||
self._split.setStretchFactor(0, 0)
|
||||
self._split.setStretchFactor(1, 1)
|
||||
self._split.setStretchFactor(2, 0)
|
||||
@@ -307,6 +320,11 @@ class Co4ETab(QWidget):
|
||||
self.flow_bar.setCurrentIndex(bar_idx)
|
||||
self._reflect_active_run(wf.id)
|
||||
return
|
||||
# Without the strip there is nowhere to switch between open flows, so
|
||||
# opening one REPLACES the one on the canvas (saved first, as the tab
|
||||
# switch used to do). Runs already in progress are unaffected — they are
|
||||
# tracked per flow id and keep going in the background.
|
||||
self._close_other_flows()
|
||||
self._flows.append(wf)
|
||||
self.flow_bar.blockSignals(True)
|
||||
bar_idx = self.flow_bar.addTab(icon("flow"), wf.name or tr("co4e.untitled"))
|
||||
@@ -318,13 +336,43 @@ class Co4ETab(QWidget):
|
||||
self.flow_bar.setCurrentIndex(bar_idx)
|
||||
self._reflect_active_run(wf.id)
|
||||
|
||||
def _close_other_flows(self) -> None:
|
||||
"""Leave the canvas empty of flows, saving whatever was on it.
|
||||
|
||||
Called before opening a flow, because the tab strip that used to hold
|
||||
several at once is gone. Tab 0 (Runs) is never touched.
|
||||
"""
|
||||
if not self._flows:
|
||||
return
|
||||
if 0 <= self._active_flow_idx < len(self._flows):
|
||||
self._sync_wf_from_canvas()
|
||||
self.flow_bar.blockSignals(True)
|
||||
for idx in range(self.flow_bar.count() - 1, 0, -1):
|
||||
self.flow_bar.removeTab(idx)
|
||||
self.flow_bar.blockSignals(False)
|
||||
self._flows.clear()
|
||||
self._active_flow_idx = -1
|
||||
|
||||
def _show_runs(self, on: bool) -> None:
|
||||
"""Swap the centre between the flow editor and the Runs table.
|
||||
|
||||
This is where the pinned "Runs" tab went when the strip was removed —
|
||||
same page, same table, reached from a toggle in the flow toolbar.
|
||||
"""
|
||||
target = 0 if on else min(1, self.flow_bar.count() - 1)
|
||||
if self.flow_bar.currentIndex() == target:
|
||||
self._on_flow_tab_changed(target) # already there → re-apply
|
||||
else:
|
||||
self.flow_bar.setCurrentIndex(target)
|
||||
|
||||
def _on_flow_tab_changed(self, idx: int) -> None:
|
||||
# save the outgoing flow (active_flow_idx is a FLOWS-list index) first
|
||||
if 0 <= self._active_flow_idx < len(self._flows) and (self._active_flow_idx + 1) != idx:
|
||||
self._sync_wf_from_canvas()
|
||||
if idx <= 0: # the pinned Runs tab
|
||||
if idx <= 0: # the Runs page
|
||||
self._active_flow_idx = -1
|
||||
self.center_stack.setCurrentIndex(0)
|
||||
self._sync_runs_toggle(True)
|
||||
self._refresh_runs()
|
||||
return
|
||||
flow_idx = idx - 1
|
||||
@@ -332,8 +380,18 @@ class Co4ETab(QWidget):
|
||||
return
|
||||
self._active_flow_idx = flow_idx
|
||||
self.center_stack.setCurrentIndex(1)
|
||||
self._sync_runs_toggle(False)
|
||||
self._apply_workflow(self._flows[flow_idx])
|
||||
|
||||
def _sync_runs_toggle(self, on: bool) -> None:
|
||||
"""Keep the Runs toggle showing which page is up, however it got there
|
||||
(a double-click in the runs table also switches pages)."""
|
||||
btn = getattr(self, "runs_btn", None)
|
||||
if btn is not None and btn.isChecked() != on:
|
||||
blocked = btn.blockSignals(True)
|
||||
btn.setChecked(on)
|
||||
btn.blockSignals(blocked)
|
||||
|
||||
def _add_tab_close_button(self, idx: int) -> None:
|
||||
"""Give a flow tab its own close button — a small ✕ placed by QTabBar on
|
||||
the tab's right side, vertically centered and INSIDE the tab (reliable
|
||||
@@ -424,22 +482,44 @@ class Co4ETab(QWidget):
|
||||
|
||||
# ---- sidebar ----------------------------------------------------------
|
||||
def _build_sidebar(self) -> QWidget:
|
||||
self.sidebar = QTabWidget()
|
||||
# Icon-only tabs share the full sidebar width equally (line up with the
|
||||
# list below) via an equal-width tab bar — no left/right scroll. Colours
|
||||
# come from theme.py (QTabBar#co4eSideTabs — transparent tabs + a subtle
|
||||
# translucent selection with the theme text colour, like the app's lists).
|
||||
self.sidebar.setTabBar(_EqualTabBar())
|
||||
_tb = self.sidebar.tabBar()
|
||||
_tb.setObjectName("co4eSideTabs")
|
||||
_tb.setUsesScrollButtons(False)
|
||||
_tb.setElideMode(Qt.ElideNone)
|
||||
# Workflows
|
||||
wf_page = QWidget(); wl = QVBoxLayout(wf_page)
|
||||
wl.setContentsMargins(6, 6, 6, 6)
|
||||
# ONE COLUMN, four named sections — no icon tabs. Every list is on screen
|
||||
# at once, so "what can I drag onto the canvas" is answered by looking
|
||||
# rather than by clicking through three unlabeled tabs.
|
||||
# A vertical splitter, not a fixed stack: on a short window four stacked
|
||||
# lists otherwise squeeze down to one visible row each. The splitter
|
||||
# hands out the available height by weight and lets the user re-balance
|
||||
# it by dragging; each list keeps a small minimum so none disappears.
|
||||
self._sections: dict = {}
|
||||
self.sidebar = QWidget()
|
||||
outer_col = QVBoxLayout(self.sidebar)
|
||||
outer_col.setContentsMargins(6, 6, 6, 6)
|
||||
outer_col.setSpacing(0)
|
||||
self.side_split = QSplitter(Qt.Vertical)
|
||||
self.side_split.setChildrenCollapsible(False)
|
||||
self.side_split.setHandleWidth(8)
|
||||
outer_col.addWidget(self.side_split, 1)
|
||||
|
||||
class _Col:
|
||||
"""Adapter so the section builders below read the same as before."""
|
||||
|
||||
def __init__(self, split):
|
||||
self._split = split
|
||||
|
||||
def addWidget(self, w, stretch=1):
|
||||
self._split.addWidget(w)
|
||||
self._split.setStretchFactor(self._split.count() - 1, stretch)
|
||||
|
||||
col = _Col(self.side_split)
|
||||
|
||||
# --- WORKFLOWS ---------------------------------------------------
|
||||
self.wf_new_btn = QPushButton(tr("co4e.new"))
|
||||
self.wf_new_btn.setIcon(icon("plus"))
|
||||
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
|
||||
self.wf_new_btn.clicked.connect(self._new_workflow)
|
||||
wf_body = QWidget(); wl = QVBoxLayout(wf_body)
|
||||
wl.setContentsMargins(0, 0, 0, 0); wl.setSpacing(4)
|
||||
# Draggable: drag a flow onto the canvas to merge it in (Nova-style);
|
||||
# double-click loads it into an empty canvas. (The old always-on hint
|
||||
# label was removed to give the flow list more room; it's a tooltip now.)
|
||||
# double-click loads it onto the canvas.
|
||||
self.wf_list = _PaletteList(payload_role=Qt.UserRole + 2)
|
||||
self.wf_list.setToolTip(tr("co4e.drag_hint"))
|
||||
self.wf_list.itemDoubleClicked.connect(self._load_selected_workflow)
|
||||
@@ -447,57 +527,151 @@ class Co4ETab(QWidget):
|
||||
self.wf_list.customContextMenuRequested.connect(self._wf_context_menu)
|
||||
wl.addWidget(self.wf_list, 1)
|
||||
wf_btns = QHBoxLayout(); wf_btns.setSpacing(4)
|
||||
# "New flow" moved to the "+" button on the flow tab strip (browser-style).
|
||||
# "Load selected flow to canvas" button removed — double-click a flow in
|
||||
# the list (or drag it onto the canvas) to open it; the explicit button
|
||||
# was redundant.
|
||||
self.wf_edit_btn = self._icon_btn("edit", "co4e.tt_edit_wf", self._edit_selected_workflow)
|
||||
self.wf_dup_btn = self._icon_btn("branch", "co4e.tt_dup_wf", self._duplicate_selected_workflow)
|
||||
self.wf_del_btn = self._icon_btn("trash", "co4e.tt_del_wf", self._delete_selected_workflow)
|
||||
for b in (self.wf_edit_btn, self.wf_dup_btn, self.wf_del_btn):
|
||||
wf_btns.addWidget(b)
|
||||
wf_btns.addStretch(1)
|
||||
wl.addLayout(wf_btns)
|
||||
# Its own row: sharing one line with the three icon buttons cut "Chạy
|
||||
# nền" down to "Chạ" as soon as the sidebar hit its narrow width.
|
||||
self.wf_runbg_btn = QPushButton(tr("co4e.run_bg")); self.wf_runbg_btn.setIcon(icon("play"))
|
||||
self.wf_runbg_btn.setToolTip(tr("co4e.tt_run_bg"))
|
||||
self.wf_runbg_btn.clicked.connect(self._run_selected_in_background)
|
||||
wf_btns.addWidget(self.wf_runbg_btn, 1)
|
||||
wl.addLayout(wf_btns)
|
||||
# (The "Running flows" list moved out of the sidebar into the pinned
|
||||
# "Runs" tab at the front of the flow tabs — see _build_runs_page.)
|
||||
# Icon-only tabs keep the sidebar narrow; the name is a tooltip.
|
||||
self.sidebar.addTab(wf_page, icon("flow"), "")
|
||||
self.sidebar.setTabToolTip(0, tr("co4e.tab_workflows"))
|
||||
wl.addWidget(self.wf_runbg_btn)
|
||||
col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3)
|
||||
|
||||
# Agents (drag onto canvas; CRUD custom)
|
||||
ag_page = QWidget(); al = QVBoxLayout(ag_page)
|
||||
al.setContentsMargins(6, 6, 6, 6)
|
||||
self.agent_list = _PaletteList()
|
||||
al.addWidget(self.agent_list, 1)
|
||||
ag_btns = QHBoxLayout(); ag_btns.setSpacing(4)
|
||||
# --- AGENTS ------------------------------------------------------
|
||||
self.ag_new_btn = QPushButton(tr("co4e.new")); self.ag_new_btn.setIcon(icon("plus"))
|
||||
self.ag_new_btn.setToolTip(tr("co4e.tt_new_agent"))
|
||||
self.ag_new_btn.clicked.connect(self._new_agent)
|
||||
ag_body = QWidget(); al = QVBoxLayout(ag_body)
|
||||
al.setContentsMargins(0, 0, 0, 0); al.setSpacing(4)
|
||||
self.agent_list = _PaletteList()
|
||||
al.addWidget(self.agent_list, 1)
|
||||
ag_btns = QHBoxLayout(); ag_btns.setSpacing(4)
|
||||
# Edit/delete act on the selected row, so they stay with the list.
|
||||
self.ag_edit_btn = self._icon_btn("edit", "co4e.tt_edit_agent", self._edit_agent)
|
||||
self.ag_del_btn = self._icon_btn("trash", "co4e.tt_del_agent", self._delete_agent)
|
||||
ag_btns.addWidget(self.ag_new_btn, 1)
|
||||
ag_btns.addWidget(self.ag_edit_btn)
|
||||
ag_btns.addWidget(self.ag_del_btn)
|
||||
ag_btns.addStretch(1)
|
||||
al.addLayout(ag_btns)
|
||||
self.sidebar.addTab(ag_page, icon("robot"), "")
|
||||
self.sidebar.setTabToolTip(1, tr("co4e.tab_agents"))
|
||||
col.addWidget(self._section("co4e.tab_agents", ag_body, self.ag_new_btn), 3)
|
||||
|
||||
# Skills (drag onto canvas; manage via existing Skills manager button)
|
||||
sk_page = QWidget(); sl = QVBoxLayout(sk_page)
|
||||
sl.setContentsMargins(6, 6, 6, 6)
|
||||
self.skill_list = _PaletteList()
|
||||
sl.addWidget(self.skill_list, 1)
|
||||
# --- SKILLS ------------------------------------------------------
|
||||
self.sk_manage_btn = QPushButton(tr("co4e.manage_skills"))
|
||||
self.sk_manage_btn.setToolTip(tr("co4e.tt_manage_skills"))
|
||||
self.sk_manage_btn.clicked.connect(self._manage_skills)
|
||||
sl.addWidget(self.sk_manage_btn)
|
||||
self.sidebar.addTab(sk_page, icon("sparkle"), "")
|
||||
self.sidebar.setTabToolTip(2, tr("co4e.tab_skills"))
|
||||
sk_body = QWidget(); sl = QVBoxLayout(sk_body)
|
||||
sl.setContentsMargins(0, 0, 0, 0); sl.setSpacing(4)
|
||||
self.skill_list = _PaletteList()
|
||||
sl.addWidget(self.skill_list, 1)
|
||||
col.addWidget(self._section("co4e.tab_skills", sk_body, self.sk_manage_btn), 2)
|
||||
|
||||
# --- RUNS --------------------------------------------------------
|
||||
# A short, always-visible view of the same runs the Flow Status page
|
||||
# tables in full. Clicking one opens that page with the run selected.
|
||||
# Icon only: the heading beside it already reads FLOW STATUS, and the
|
||||
# label was long enough to be cut in half in a narrow sidebar.
|
||||
self.runs_more_btn = QPushButton()
|
||||
self.runs_more_btn.setIcon(icon("chevron-right"))
|
||||
self.runs_more_btn.setFixedWidth(30)
|
||||
self.runs_more_btn.setFlat(True)
|
||||
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.runs_more_btn.clicked.connect(lambda: self._show_runs(True))
|
||||
runs_body = QWidget(); rl = QVBoxLayout(runs_body)
|
||||
rl.setContentsMargins(0, 0, 0, 0); rl.setSpacing(4)
|
||||
self.runs_side_list = QListWidget()
|
||||
self.runs_side_list.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.runs_side_list.itemClicked.connect(self._on_side_run_clicked)
|
||||
rl.addWidget(self.runs_side_list, 1)
|
||||
col.addWidget(self._section("co4e.runs_tab", runs_body, self.runs_more_btn), 2)
|
||||
# Small enough that all four still fit on a laptop screen, large enough
|
||||
# that each shows more than a single row.
|
||||
for lst in (self.wf_list, self.agent_list, self.skill_list, self.runs_side_list):
|
||||
lst.setMinimumHeight(56)
|
||||
return self.sidebar
|
||||
|
||||
_SIDE_RUNS = 6
|
||||
|
||||
def _refresh_side_runs(self) -> None:
|
||||
"""Mirror the newest runs into the sidebar's short list."""
|
||||
lst = getattr(self, "runs_side_list", None)
|
||||
if lst is None:
|
||||
return
|
||||
dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
|
||||
lst.clear()
|
||||
for h in list(reversed(self.manager.runs()))[:self._SIDE_RUNS]:
|
||||
it = QListWidgetItem(f"{dots.get(h.status, '•')} {h.name}"
|
||||
f" {h.progress_text()}")
|
||||
it.setData(Qt.UserRole, h.id)
|
||||
it.setToolTip(f"{h.name} · {tr('co4e.status.' + h.status)} · {h.created_at or '-'}")
|
||||
lst.addItem(it)
|
||||
|
||||
def _on_side_run_clicked(self, item) -> None:
|
||||
"""Open the full Flow Status page with this run selected."""
|
||||
run_id = item.data(Qt.UserRole)
|
||||
self._show_runs(True)
|
||||
for r in range(self.runs_table.rowCount()):
|
||||
cell = self.runs_table.item(r, 0)
|
||||
if cell is not None and cell.data(Qt.UserRole) == run_id:
|
||||
self.runs_table.setCurrentCell(r, 0)
|
||||
break
|
||||
|
||||
def _section(self, key: str, body: QWidget, action: QPushButton | None = None,
|
||||
stretch: int = 1) -> QWidget:
|
||||
"""One named, foldable section of the sidebar column.
|
||||
|
||||
Replaces the three icon-only tabs: all the lists are visible at once
|
||||
(WORKFLOWS · AGENTS · SKILLS · runs), each under its own heading with the
|
||||
action that belongs to it. Clicking the heading folds the section, so a
|
||||
narrow window can still get to everything.
|
||||
"""
|
||||
box = QWidget()
|
||||
v = QVBoxLayout(box)
|
||||
v.setContentsMargins(0, 0, 0, 0)
|
||||
v.setSpacing(2)
|
||||
|
||||
row = QHBoxLayout()
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(4)
|
||||
head = QPushButton()
|
||||
head.setObjectName("co4eSectionHdr")
|
||||
head.setCheckable(True)
|
||||
head.setChecked(True)
|
||||
head.setCursor(Qt.PointingHandCursor)
|
||||
head.setFlat(True)
|
||||
head.toggled.connect(lambda on, w=body, b=box: self._fold_section(key, w, b, on))
|
||||
row.addWidget(head, 1)
|
||||
if action is not None:
|
||||
row.addWidget(action, 0)
|
||||
v.addLayout(row)
|
||||
v.addWidget(body, 1)
|
||||
|
||||
self._sections[key] = (head, body, stretch)
|
||||
self._sync_section_arrow(key)
|
||||
return box
|
||||
|
||||
def _fold_section(self, key: str, body: QWidget, box: QWidget, on: bool) -> None:
|
||||
"""Fold/unfold a section AND give its height back to the others.
|
||||
|
||||
Inside a splitter, hiding the body is not enough — the pane keeps its
|
||||
share of the height, so folding would free nothing. Clamping the whole
|
||||
section to its header height makes the splitter re-deal the space.
|
||||
"""
|
||||
body.setVisible(on)
|
||||
if on:
|
||||
box.setMaximumHeight(16777215)
|
||||
else:
|
||||
box.setMaximumHeight(box.layout().itemAt(0).sizeHint().height() + 4)
|
||||
self._sync_section_arrow(key)
|
||||
|
||||
def _sync_section_arrow(self, key: str) -> None:
|
||||
head, _body, _s = self._sections[key]
|
||||
head.setText(("▾ " if head.isChecked() else "▸ ") + tr(key).upper())
|
||||
|
||||
def _icon_btn(self, icon_name: str, tip_key: str, slot) -> QPushButton:
|
||||
b = QPushButton(); b.setIcon(icon(icon_name)); b.setToolTip(tr(tip_key))
|
||||
b.setFixedWidth(34)
|
||||
@@ -611,12 +785,13 @@ class Co4ETab(QWidget):
|
||||
f" background: {current_palette().scroll_handle}; border-radius: 4px; min-width: 30px; }}"
|
||||
"QScrollArea#flowTabScroll QScrollBar::add-line:horizontal,"
|
||||
"QScrollArea#flowTabScroll QScrollBar::sub-line:horizontal { width: 0; height: 0; }")
|
||||
flow_row = QHBoxLayout()
|
||||
flow_row.setContentsMargins(0, 0, 0, 0)
|
||||
flow_row.setSpacing(3) # same gap as between tabs → looks like one strip
|
||||
flow_row.addWidget(self.flow_scroll, 1)
|
||||
flow_row.addWidget(self.flow_add_btn, 0, Qt.AlignVCenter)
|
||||
lay.addLayout(flow_row)
|
||||
# The strip itself is NOT shown any more (see class docstring): flows are
|
||||
# picked from the WORKFLOWS list on the left, one open at a time. The
|
||||
# QTabBar stays alive off-screen as the index that maps flow ↔ canvas —
|
||||
# every open/close/rename path already goes through it — but the user
|
||||
# never sees or drives it.
|
||||
self.flow_scroll.setVisible(False)
|
||||
self.flow_add_btn.setVisible(False)
|
||||
|
||||
# Content switches between the Runs table (tab 0) and the flow editor.
|
||||
self.center_stack = QStackedWidget()
|
||||
@@ -652,6 +827,14 @@ class Co4ETab(QWidget):
|
||||
self.run_btn.setToolTip(tr("co4e.tt_run"))
|
||||
self.run_btn.clicked.connect(self._on_run_clicked)
|
||||
|
||||
# The pinned "Runs" tab lost its strip, so it becomes a toggle here —
|
||||
# one click to the run table and one click back, from either page.
|
||||
self.runs_btn = QPushButton(tr("co4e.runs_tab"))
|
||||
self.runs_btn.setIcon(icon("monitoring"))
|
||||
self.runs_btn.setCheckable(True)
|
||||
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.runs_btn.toggled.connect(self._show_runs)
|
||||
|
||||
bar.addWidget(QLabel(tr("co4e.flow_name")))
|
||||
bar.addWidget(self.name_edit, 1)
|
||||
bar.addWidget(self.add_step_btn)
|
||||
@@ -659,6 +842,7 @@ class Co4ETab(QWidget):
|
||||
bar.addWidget(self.save_tpl_btn)
|
||||
bar.addWidget(self.mode_combo)
|
||||
bar.addWidget(self.run_btn)
|
||||
bar.addWidget(self.runs_btn)
|
||||
lay.addLayout(bar)
|
||||
|
||||
self.canvas = Co4ECanvas()
|
||||
@@ -685,6 +869,13 @@ class Co4ETab(QWidget):
|
||||
w = QWidget()
|
||||
v = QVBoxLayout(w)
|
||||
hdr = QHBoxLayout()
|
||||
# The Runs page covers the flow toolbar, so it carries its own way back —
|
||||
# otherwise the toggle that opened it is off screen.
|
||||
self.runs_back_btn = QPushButton(tr("co4e.back_to_flow"))
|
||||
self.runs_back_btn.setIcon(icon("chevron-left"))
|
||||
self.runs_back_btn.setToolTip(tr("co4e.tt_back_to_flow"))
|
||||
self.runs_back_btn.clicked.connect(lambda: self._show_runs(False))
|
||||
hdr.addWidget(self.runs_back_btn)
|
||||
self.runs_title = QLabel(tr("co4e.running_flows"))
|
||||
self.runs_title.setObjectName("hint")
|
||||
hdr.addWidget(self.runs_title)
|
||||
@@ -764,6 +955,29 @@ class Co4ETab(QWidget):
|
||||
self.config_container = container
|
||||
return container
|
||||
|
||||
# Below this window width the three panes (rail + 180 sidebar + canvas +
|
||||
# 300 config) leave the canvas too little to draw a flow in, and the config
|
||||
# fields start clipping instead of shrinking. Measured with
|
||||
# tools/check_responsive.py — Co4E gets the full content area (no project or
|
||||
# history pane beside it), so the threshold is about its own screen only.
|
||||
_NARROW = 1300
|
||||
|
||||
def showEvent(self, e): # noqa: N802 - Qt override
|
||||
super().showEvent(e)
|
||||
self._narrow_guard.attach()
|
||||
|
||||
def _apply_narrow_layout(self, narrow: bool) -> None:
|
||||
"""Fold the step-config panel on a narrow window, restore it when there
|
||||
is room again.
|
||||
|
||||
Attached from __init__ rather than only on show: this page sits inside a
|
||||
QTabWidget, whose minimum width is the MAXIMUM over all its pages —
|
||||
including hidden ones. While Co4E sat unfolded in the background it was
|
||||
forcing Project and Cowork to be ~1180px wide too.
|
||||
"""
|
||||
if narrow != self._config_collapsed:
|
||||
self._toggle_config()
|
||||
|
||||
def _toggle_config(self) -> None:
|
||||
self._config_collapsed = not self._config_collapsed
|
||||
v = self._cfg_vlayout
|
||||
@@ -787,6 +1001,11 @@ class Co4ETab(QWidget):
|
||||
sizes[2] = 34
|
||||
sizes[1] = max(200, sizes[1] + freed)
|
||||
self._split.setSizes(sizes)
|
||||
# Without this the splitter keeps reporting the OLD minimum width,
|
||||
# and since a QTabWidget's minimum is the maximum over all its pages
|
||||
# — hidden ones included — Co4E would go on forcing Project and
|
||||
# Cowork to be 1180px wide even while folded here.
|
||||
self._refresh_min_width()
|
||||
else:
|
||||
v.removeItem(self._cfg_top_spacer)
|
||||
v.removeItem(self._cfg_bot_spacer)
|
||||
@@ -802,6 +1021,14 @@ class Co4ETab(QWidget):
|
||||
sizes[2] = want
|
||||
sizes[1] = max(200, sizes[1] - delta)
|
||||
self._split.setSizes(sizes)
|
||||
self._refresh_min_width()
|
||||
|
||||
def _refresh_min_width(self) -> None:
|
||||
"""Make the splitter (and everything above it) re-read its minimum."""
|
||||
self.config_container.updateGeometry()
|
||||
self._split.refresh()
|
||||
self._split.updateGeometry()
|
||||
self.updateGeometry()
|
||||
|
||||
def _build_canvas_overlay(self) -> None:
|
||||
"""Zoom +/− and Fit as a small floating control at the canvas's
|
||||
@@ -1362,10 +1589,16 @@ class Co4ETab(QWidget):
|
||||
sel_row = r
|
||||
if sel_row >= 0:
|
||||
t.setCurrentCell(sel_row, 0)
|
||||
# reflect the active run count in the pinned Runs tab title
|
||||
# The sidebar's short run list is the same data — refresh it together.
|
||||
self._refresh_side_runs()
|
||||
# Active-run count, on the sidebar heading now that the tab strip is gone.
|
||||
n = self.manager.active_count()
|
||||
label = tr("co4e.runs_tab_n", n=n) if n else tr("co4e.runs_tab")
|
||||
if hasattr(self, "flow_bar"):
|
||||
n = self.manager.active_count()
|
||||
self.flow_bar.setTabText(0, tr("co4e.runs_tab_n", n=n) if n else tr("co4e.runs_tab"))
|
||||
self.flow_bar.setTabText(0, label)
|
||||
head = (self._sections.get("co4e.runs_tab") or (None,))[0]
|
||||
if head is not None:
|
||||
head.setText(("▾ " if head.isChecked() else "▸ ") + label.upper())
|
||||
|
||||
def _stop_selected_run(self) -> None:
|
||||
row = self.runs_table.currentRow()
|
||||
@@ -1506,7 +1739,9 @@ class Co4ETab(QWidget):
|
||||
return
|
||||
self.manager.start(wf, skill_map=self._skill_map(),
|
||||
plan_mode=(self._current_mode() == "plan"))
|
||||
self.sidebar.setCurrentIndex(0)
|
||||
# Used to jump the sidebar back to the Workflows tab; with one column
|
||||
# there is nothing to jump to — show the run that just started instead.
|
||||
self._refresh_side_runs()
|
||||
self.status_message.emit(tr("co4e.bg_started", name=wf.name))
|
||||
|
||||
def _wf_by_id(self, wf_id: str) -> Optional[co4e.Workflow]:
|
||||
@@ -1807,9 +2042,15 @@ class Co4ETab(QWidget):
|
||||
|
||||
# ---- i18n -------------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
self.sidebar.setTabToolTip(0, tr("co4e.tab_workflows"))
|
||||
self.sidebar.setTabToolTip(1, tr("co4e.tab_agents"))
|
||||
self.sidebar.setTabToolTip(2, tr("co4e.tab_skills"))
|
||||
for key in self._sections:
|
||||
self._sync_section_arrow(key)
|
||||
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.wf_new_btn.setText(tr("co4e.new"))
|
||||
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
|
||||
self.runs_btn.setText(tr("co4e.runs_tab"))
|
||||
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.runs_back_btn.setText(tr("co4e.back_to_flow"))
|
||||
self.runs_back_btn.setToolTip(tr("co4e.tt_back_to_flow"))
|
||||
self.runs_title.setText(tr("co4e.running_flows"))
|
||||
self.run_stop_btn.setText(tr("co4e.stop"))
|
||||
self.run_rename_btn.setText(tr("co4e.rename_run"))
|
||||
|
||||
+31
-18
@@ -92,39 +92,52 @@ class DashboardTab(QWidget):
|
||||
self.refresh_btn.setIcon(icon("refresh"))
|
||||
self.refresh_btn.setFixedWidth(34)
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
# Two rows, grouped by what the controls do, instead of nine widgets
|
||||
# strung across one line where the title, a date pager, two chart
|
||||
# selectors, a currency picker and Refresh all read as one undifferentiated
|
||||
# strip. Row 1 is "where am I"; row 2 is "what am I looking at".
|
||||
head.addWidget(self._title, 1)
|
||||
head.addWidget(self.chart_prev_btn)
|
||||
head.addWidget(self._chart_period_lbl)
|
||||
head.addWidget(self.chart_next_btn)
|
||||
head.addWidget(self.gran_combo)
|
||||
head.addWidget(self.metric_combo)
|
||||
head.addWidget(self.currency_lbl)
|
||||
head.addWidget(self.currency_combo)
|
||||
head.addWidget(self.refresh_btn)
|
||||
root.addLayout(head)
|
||||
|
||||
controls = QHBoxLayout()
|
||||
controls.setSpacing(6)
|
||||
controls.addWidget(self.chart_prev_btn) # period pager
|
||||
controls.addWidget(self._chart_period_lbl)
|
||||
controls.addWidget(self.chart_next_btn)
|
||||
controls.addSpacing(12)
|
||||
controls.addWidget(self.gran_combo) # what the chart plots
|
||||
controls.addWidget(self.metric_combo)
|
||||
controls.addStretch(1)
|
||||
controls.addWidget(self.currency_lbl) # how money is displayed
|
||||
controls.addWidget(self.currency_combo)
|
||||
root.addLayout(controls)
|
||||
|
||||
# ---- stat cards ---------------------------------------------------
|
||||
# Single row, 5 equal-width cards (same layout as Monitoring Overview)
|
||||
# Cost is the headline this screen exists for, so it gets a card twice
|
||||
# the height of the rest instead of being the fifth of five identical
|
||||
# tiles — with six equal cards nothing said which number mattered.
|
||||
cards_grid = QGridLayout()
|
||||
cards_grid.setSpacing(8)
|
||||
self.card_total = _StatCard()
|
||||
self.card_in = _StatCard()
|
||||
self.card_out = _StatCard()
|
||||
self.card_cache = _StatCard()
|
||||
self.card_cost = _StatCard()
|
||||
for i, card in enumerate((self.card_total, self.card_in, self.card_out,
|
||||
self.card_cache, self.card_cost)):
|
||||
cards_grid.addWidget(card, 0, i)
|
||||
self.card_cost = _StatCard().as_hero()
|
||||
# Hero on the left, spanning both rows; the four supporting figures fill
|
||||
# a 2×2 block beside it.
|
||||
cards_grid.addWidget(self.card_cost, 0, 0, 2, 1)
|
||||
for i, card in enumerate((self.card_total, self.card_in,
|
||||
self.card_out, self.card_cache)):
|
||||
cards_grid.addWidget(card, i // 2, 1 + i % 2)
|
||||
# Budget: remaining/budget, direct entry, auto-warns red past 85% used.
|
||||
self.budget_card = _BudgetCard()
|
||||
self.budget_card.apply_btn.setIcon(icon("check"))
|
||||
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
|
||||
cards_grid.addWidget(self.budget_card, 0, 5)
|
||||
# Equal stretch on every column — otherwise the grid sizes each column
|
||||
# to its widest cell's natural content (Budget's longer "$X / $Y" value
|
||||
# + entry row made its column ~25% wider than the plain stat cards).
|
||||
for col in range(6):
|
||||
cards_grid.setColumnStretch(col, 1)
|
||||
cards_grid.addWidget(self.budget_card, 0, 3, 2, 1)
|
||||
# The hero and Budget columns get more room than the small tiles.
|
||||
for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)):
|
||||
cards_grid.setColumnStretch(col, stretch)
|
||||
root.addLayout(cards_grid)
|
||||
|
||||
# ---- token/cost within the selected period (spline): WEEK → 7 days
|
||||
|
||||
+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"))
|
||||
|
||||
+27
-16
@@ -404,14 +404,16 @@ class MonitoringTab(QWidget):
|
||||
scroll.setWidget(content)
|
||||
outer.addWidget(scroll)
|
||||
|
||||
root = QHBoxLayout(content)
|
||||
# ONE main column, scrolled vertically, sections in a fixed order — the
|
||||
# two-column grid put six group boxes side by side and mixed three
|
||||
# unrelated concerns (cost, machine resources, security) at the same
|
||||
# level, which made this the densest screen in the app. Each section now
|
||||
# spans the full width and lays its own contents out horizontally, so a
|
||||
# wide window is still used well.
|
||||
root = QVBoxLayout(content)
|
||||
root.setSpacing(12)
|
||||
left = QVBoxLayout()
|
||||
left.setSpacing(12)
|
||||
right = QVBoxLayout()
|
||||
right.setSpacing(12)
|
||||
root.addLayout(left, 2)
|
||||
root.addLayout(right, 1)
|
||||
left = root # sections are appended in reading order
|
||||
right = root
|
||||
|
||||
# ---- Token Usage & Cost --------------------------------------------
|
||||
self.ov_usage_group = QGroupBox()
|
||||
@@ -514,12 +516,10 @@ class MonitoringTab(QWidget):
|
||||
self.ov_pricing_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
pg.addWidget(self.ov_pricing_table, 1)
|
||||
|
||||
res_row = QHBoxLayout()
|
||||
res_row.setSpacing(12)
|
||||
res_row.addWidget(self.ov_resource_group, 1)
|
||||
res_row.addWidget(self.ov_pricing_group, 2) # pricing sits beside CPU/resources
|
||||
left.addLayout(res_row)
|
||||
left.addStretch(1)
|
||||
# Resources keep the row to themselves; the model price table gets its
|
||||
# own full-width section further down (it is a reference table, not a
|
||||
# live meter, and squeezing it next to the CPU bars made both unreadable).
|
||||
left.addWidget(self.ov_resource_group)
|
||||
self._reload_pricing_table()
|
||||
|
||||
# ---- Sandbox Details --------------------------------------------
|
||||
@@ -552,7 +552,12 @@ class MonitoringTab(QWidget):
|
||||
sbx_lay.addLayout(limits_row)
|
||||
|
||||
self.ov_sbx_net_lbl, self.ov_sbx_net_val = _kv()
|
||||
right.addWidget(self.ov_sandbox_details_group)
|
||||
# Sandbox and Permissions answer the same question ("what is the agent
|
||||
# allowed to touch?"), so they share one full-width row.
|
||||
self._sbx_perm_row = QHBoxLayout()
|
||||
self._sbx_perm_row.setSpacing(12)
|
||||
self._sbx_perm_row.addWidget(self.ov_sandbox_details_group, 1)
|
||||
root.addLayout(self._sbx_perm_row)
|
||||
|
||||
# ---- Permissions -----------------------------------------------
|
||||
self.ov_permissions_group = QGroupBox()
|
||||
@@ -574,7 +579,10 @@ class MonitoringTab(QWidget):
|
||||
self.ov_perm_edit_btn.setFlat(True)
|
||||
self.ov_perm_edit_btn.clicked.connect(self._open_settings_and_refresh)
|
||||
perm_lay.addWidget(self.ov_perm_edit_btn, 0, Qt.AlignRight)
|
||||
right.addWidget(self.ov_permissions_group)
|
||||
self._sbx_perm_row.addWidget(self.ov_permissions_group, 1)
|
||||
|
||||
# ---- Model pricing — its own section, full width ------------------
|
||||
root.addWidget(self.ov_pricing_group)
|
||||
|
||||
# ---- Audit Log ----------------------------------------------------
|
||||
self.ov_audit_group = QGroupBox()
|
||||
@@ -634,7 +642,10 @@ class MonitoringTab(QWidget):
|
||||
tr("monitoring.col_agent"), tr("monitoring.col_active"), tr("monitoring.col_source"),
|
||||
])
|
||||
|
||||
self.ov_usage_group.setTitle(tr("monitoring.overview_usage_title"))
|
||||
# A QGroupBox title treats "&" as a mnemonic marker, so "token & Chi
|
||||
# phí" rendered as "token _Chi phí". Double it to show a literal "&".
|
||||
self.ov_usage_group.setTitle(
|
||||
tr("monitoring.overview_usage_title").replace("&", "&&"))
|
||||
self.ov_budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
|
||||
self.ov_budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
|
||||
|
||||
|
||||
+46
-3
@@ -10,8 +10,8 @@ from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
|
||||
QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
|
||||
QMessageBox, QPushButton, QScrollArea, QSpinBox, QTreeWidget, QTreeWidgetItem,
|
||||
QVBoxLayout, QWidget,
|
||||
QMessageBox, QPushButton, QScrollArea, QSizePolicy, QSpinBox, QTreeWidget,
|
||||
QTreeWidgetItem, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..config import PROVIDER_LABELS
|
||||
@@ -42,6 +42,10 @@ class SettingsDialog(QDialog):
|
||||
outer = QVBoxLayout(self)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
# Never sideways: the content must fit the width it is given and scroll
|
||||
# only downwards. Anything too wide has to shrink (see _model_combo and
|
||||
# _with_load), not push a second scrollbar onto the user.
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self._content = QWidget()
|
||||
root = QVBoxLayout(self._content)
|
||||
|
||||
@@ -59,6 +63,11 @@ class SettingsDialog(QDialog):
|
||||
self.notify_chk = QCheckBox(tr("settings.tray_notify"))
|
||||
self.notify_chk.setChecked(bool(data.get("tray", {}).get("notify_on_done", True)))
|
||||
top.addRow("", self.notify_chk)
|
||||
# Zero-height anchor so the index can scroll to this section, which is a
|
||||
# bare form rather than a group box.
|
||||
self._anchor_general = QWidget()
|
||||
self._anchor_general.setFixedHeight(0)
|
||||
root.addWidget(self._anchor_general)
|
||||
root.addLayout(top)
|
||||
|
||||
self._load_workers = []
|
||||
@@ -291,10 +300,31 @@ class SettingsDialog(QDialog):
|
||||
|
||||
note = QLabel(tr("settings.tip"))
|
||||
note.setObjectName("hint")
|
||||
note.setWordWrap(True) # otherwise this one line sets the dialog's width
|
||||
root.addWidget(note)
|
||||
|
||||
scroll.setWidget(self._content)
|
||||
outer.addWidget(scroll, 1)
|
||||
# Five group boxes in one column, with no way to see what was further
|
||||
# down — the index says what is in here and jumps straight to it.
|
||||
from .widgets import section_index
|
||||
self.section_list = section_index(scroll, [
|
||||
(tr("settings.group.general"), self._anchor_general),
|
||||
(tr("settings.group.provider"), prov_group),
|
||||
(tr("settings.group.sandbox"), self.sandbox_group),
|
||||
(tr("settings.group.parameter"), param_group),
|
||||
(tr("routing.settings_group"), routing_group),
|
||||
])
|
||||
body = QHBoxLayout()
|
||||
body.setSpacing(10)
|
||||
body.addWidget(self.section_list)
|
||||
body.addWidget(scroll, 1)
|
||||
outer.addLayout(body, 1)
|
||||
# Floor the dialog at the width its own content needs AT THE CURRENT
|
||||
# FONT. On a 125%/150% display everything is wider, and without this the
|
||||
# form was simply cut off (or scrolled sideways) instead of the window
|
||||
# refusing to get that small.
|
||||
self.setMinimumWidth(self.section_list.width()
|
||||
+ self._content.sizeHint().width() + 60)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||
buttons.accepted.connect(self._save)
|
||||
@@ -355,6 +385,13 @@ class SettingsDialog(QDialog):
|
||||
def _model_combo(value: str) -> QComboBox:
|
||||
combo = QComboBox()
|
||||
combo.setEditable(True)
|
||||
# A combo sizes itself to its longest entry by default; model ids are
|
||||
# long, so the row grew past the dialog and forced a sideways scrollbar
|
||||
# (worse at 125%/150% display scaling). Let it shrink and use a popup
|
||||
# wider than the closed box instead.
|
||||
combo.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
|
||||
combo.setMinimumContentsLength(8)
|
||||
combo.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||||
if value:
|
||||
combo.addItem(value)
|
||||
combo.setCurrentText(value)
|
||||
@@ -377,6 +414,12 @@ class SettingsDialog(QDialog):
|
||||
test_btn.clicked.connect(
|
||||
lambda: self._test_connection(self.provider_combo.currentData(), status))
|
||||
lay.addWidget(test_btn)
|
||||
# The two buttons keep their natural size; the combo gives way. Without
|
||||
# this the row's minimum was combo + both buttons and nothing could
|
||||
# shrink, so the dialog scrolled sideways instead.
|
||||
for b in (btn, test_btn):
|
||||
b.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
row.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed)
|
||||
return row
|
||||
|
||||
def _stash_provider_fields(self) -> None:
|
||||
|
||||
@@ -164,6 +164,9 @@ class HistorySidebar(QWidget):
|
||||
self._refresh_btn.setToolTip(tr("sidebar.refresh_tooltip"))
|
||||
self.refresh() # re-render group headers / running suffix in the new language
|
||||
|
||||
def is_collapsed(self) -> bool:
|
||||
return self._strip.isVisible()
|
||||
|
||||
def set_collapsed(self, collapsed: bool) -> None:
|
||||
"""Collapse to a thin line (kept visible) or restore the full panel."""
|
||||
self._content.setVisible(not collapsed)
|
||||
|
||||
@@ -86,12 +86,18 @@ class TaskEditorDialog(QDialog):
|
||||
outer = QVBoxLayout(self)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # down only
|
||||
content = QWidget()
|
||||
scroll.setWidget(content)
|
||||
outer.addWidget(scroll, 1)
|
||||
root = QVBoxLayout(content)
|
||||
|
||||
# ---- basics ----------------------------------------------------
|
||||
# Zero-height anchor: this block is a bare form, so the index needs
|
||||
# something to scroll to.
|
||||
self._anchor_basic = QWidget()
|
||||
self._anchor_basic.setFixedHeight(0)
|
||||
root.addWidget(self._anchor_basic)
|
||||
form = QFormLayout()
|
||||
self.title_edit = QLineEdit(self.task.get("title", ""))
|
||||
# Description is the source of truth. Its ✨ button GENERATES the Prompt
|
||||
@@ -427,6 +433,26 @@ class TaskEditorDialog(QDialog):
|
||||
eform.addRow("", self.approval_chk)
|
||||
root.addWidget(eg)
|
||||
|
||||
# Five groups in one long scroll — same problem, same fix as Settings.
|
||||
from .widgets import section_index
|
||||
self.section_list = section_index(scroll, [
|
||||
(tr("schedtask.g_basic"), self._anchor_basic),
|
||||
(tr("schedtask.g_schedule"), sg),
|
||||
(tr("schedtask.g_input"), ig),
|
||||
(tr("schedtask.g_dependency"), dg),
|
||||
(tr("schedtask.g_execution"), eg),
|
||||
])
|
||||
outer.removeWidget(scroll)
|
||||
body = QHBoxLayout()
|
||||
body.setSpacing(10)
|
||||
body.addWidget(self.section_list)
|
||||
body.addWidget(scroll, 1)
|
||||
outer.insertLayout(0, body, 1)
|
||||
# Same floor as Settings: the dialog may not be narrower than its own
|
||||
# content at the font in use, so nothing is ever cut off sideways.
|
||||
self.setMinimumWidth(self.section_list.width()
|
||||
+ content.sizeHint().width() + 60)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||
buttons.button(QDialogButtonBox.Save).setIcon(icon("save"))
|
||||
buttons.button(QDialogButtonBox.Cancel).setIcon(icon("close"))
|
||||
|
||||
+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.
|
||||
|
||||
|
||||
+161
-4
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user