From d68d91ae0507a9f583a8abcf1d92e95dfd53bceb Mon Sep 17 00:00:00 2001 From: Hiep Ha Van Date: Thu, 20 Aug 2026 01:01:06 +0900 Subject: [PATCH] Co4E step config as collapsible sections; tighter Kanban lanes; Output panel fills its column - co4e_config_panel: StepConfigPanel reorganized into four collapsible, borderless sections (Co ban / Model & Quyen / Skills & Tep / Agent song song) instead of one long form. Agent song song is its own section, shown only for parallel-variant steps. Fixes a QVBoxLayout without a trailing stretch that was handing every section an equal share of the scroll area's leftover height instead of sizing to content. - schedule_task_tab / theme: Kanban board lane gutters and per-lane margins trimmed so the seven lanes read their card content more clearly on a laptop-width window. - chat_panel / widgets: the Output Files list in Cowork/Code no longer stops at a fixed height with empty panel below it - it now fills the column down to the composer. - composer: Attach/Send/Stop buttons match the input box's height and align to its top/bottom edge instead of floating centered beside it. - app.py: the sidebar's "All projects" link reads as a link (italic, accent color) instead of a plain row. - i18n: labels for the three renamed Co4E section headers (VI/EN/JA). --- app.py | 9 +- i18n.py | 3 + theme.py | 4 + ui/chat_panel.py | 9 +- ui/co4e_config_panel.py | 178 ++++++++++++++++++++++++++++++++++------ ui/composer.py | 5 ++ ui/schedule_task_tab.py | 13 ++- ui/widgets.py | 13 ++- 8 files changed, 202 insertions(+), 32 deletions(-) diff --git a/app.py b/app.py index c8b8202..49c974b 100644 --- a/app.py +++ b/app.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import List from PySide6.QtCore import Qt, QTimer -from PySide6.QtGui import QGuiApplication, QIcon +from PySide6.QtGui import QColor, QGuiApplication, QIcon from PySide6.QtWidgets import ( QStyledItemDelegate, QApplication, QComboBox, QHBoxLayout, QLabel, QMainWindow, QMenu, @@ -747,9 +747,14 @@ class MainWindow(QMainWindow): it = QTreeWidgetItem([tr("sidebar.empty")]) it.setDisabled(True) tree.addTopLevelItem(it) - # The way back to everything the rail cannot show. + # The way back to everything the rail cannot show — styled as a link + # (italic, accent-colored) so it reads as "go elsewhere", not another row. more = QTreeWidgetItem([tr("app.nav.all_projects")]) more.setData(0, Qt.UserRole, {"all": True}) + more_font = more.font(0) + more_font.setItalic(True) + more.setFont(0, more_font) + more.setForeground(0, QColor(current_palette().accent)) tree.addTopLevelItem(more) tree.blockSignals(blocked) self.nav_recents_hdr.setVisible(not self._nav_collapsed) diff --git a/i18n.py b/i18n.py index 4e23960..e3b8b2e 100644 --- a/i18n.py +++ b/i18n.py @@ -2765,6 +2765,9 @@ STRINGS: Dict[str, Dict[str, str]] = { "ja": "フローとチャット — /agent: または /skill:", "vi": "Chat với flow — dùng /agent: hoặc /skill:"}, "co4e.send": {"en": "Send", "ja": "送信", "vi": "Gửi"}, + "co4e.tab_basic": {"en": "Basic", "ja": "基本", "vi": "Cơ bản"}, + "co4e.tab_model_perm": {"en": "Model & Permission", "ja": "モデルと権限", "vi": "Model & Quyền"}, + "co4e.tab_skills_files": {"en": "Skills & Files", "ja": "スキルとファイル", "vi": "Skills & Tệp"}, "co4e.f_label": {"en": "Label", "ja": "ラベル", "vi": "Nhãn"}, "co4e.f_role": {"en": "Role", "ja": "ロール", "vi": "Vai trò"}, "co4e.f_name": {"en": "Name", "ja": "名前", "vi": "Tên"}, diff --git a/theme.py b/theme.py index 4f2b279..fd6160c 100644 --- a/theme.py +++ b/theme.py @@ -641,6 +641,10 @@ QTreeView::item:selected, QListView::item:selected, QTableView::item:selected { cell on top of the selection tint above — visible as a stray light border on a click. The selection tint already marks "current row"; drop the rect. */ QTreeView::item:focus, QListView::item:focus, QTableView::item:focus { outline: none; border: none; } +/* Kanban lanes (Schedule Task): seven lanes share the board width, so a card's + own inset competes with the other six for space the same way the inter-lane + gap did — trimmed to match. */ +QListWidget#kanbanLane::item { padding: 3px 2px; } QHeaderView::section { background: $bg; color: $text_muted; border: none; border-bottom: 1px solid $border; padding: 7px 6px; font-weight: 600; diff --git a/ui/chat_panel.py b/ui/chat_panel.py index c578f10..9457d13 100644 --- a/ui/chat_panel.py +++ b/ui/chat_panel.py @@ -214,7 +214,11 @@ class ChatPanel(QWidget): # Right sidebar: Output files only (see below — Input is tracked but # not shown). self.input_section = CollapsibleSection(tr("widgets.input_files")) - self.output_section = CollapsibleSection(tr("widgets.output_files").upper()) + # No cap: this section owns the whole right panel (its header is + # hoisted into io_hdr below), so the list should fill the space down + # to the composer instead of stopping at a fixed height with empty + # panel below it. + self.output_section = CollapsibleSection(tr("widgets.output_files").upper(), max_height=None) # Input files are NOT shown in Cowork's UI anymore — but they're still # fully tracked (add/remove/paths()) exactly as before, since that list # is what gets written into the conversation's own "inputs" field on @@ -273,8 +277,7 @@ class ChatPanel(QWidget): bl = QVBoxLayout(bl_host) bl.setContentsMargins(0, 0, 0, 0) bl.setSpacing(4) - bl.addWidget(self.output_section) # Output only — Input is tracked but hidden - bl.addStretch(1) + bl.addWidget(self.output_section, 1) # Output only — Input is tracked but hidden; fills down to the composer iol.addWidget(bl_host, 1) # Collapsing shrinks the panel to a thin clickable line (not hidden). diff --git a/ui/co4e_config_panel.py b/ui/co4e_config_panel.py index 327220d..fd2e928 100644 --- a/ui/co4e_config_panel.py +++ b/ui/co4e_config_panel.py @@ -11,18 +11,124 @@ from __future__ import annotations from typing import List, Optional -from PySide6.QtCore import Qt, Signal +from PySide6.QtCore import Qt, QEasingCurve, QPropertyAnimation, Signal from PySide6.QtWidgets import ( - QCheckBox, QComboBox, QFormLayout, QHBoxLayout, QInputDialog, QLabel, - QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit, QPushButton, - QScrollArea, QSpinBox, QVBoxLayout, QWidget, + QCheckBox, QComboBox, QFormLayout, QHBoxLayout, QInputDialog, + QLabel, QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit, + QPushButton, QScrollArea, QSpinBox, QVBoxLayout, QWidget, ) from ..config import PROVIDER_LABELS from ..core.co4e import PERMISSION_PRESETS, Step, SubAgent from ..i18n import tr +from ..theme import current_palette from .icons import icon, icon_picker_combo +_SECTION_ANIM_MS = 180 + + +class _SectionHeader(QLabel): + """A clickable label — a QPushButton's own style chrome (border, native + button margin, focus rect) always leaves a taller minimum height than a + plain label, even once its QSS padding is zeroed out, so the header that + needs to sit tight against its neighbours is a label, not a button.""" + + clicked = Signal() + + def mousePressEvent(self, event) -> None: # noqa: N802 + if event.button() == Qt.LeftButton: + self.clicked.emit() + super().mousePressEvent(event) + + def showEvent(self, event) -> None: # noqa: N802 + # fontMetrics() at construction time (before this label is ever part + # of a shown top-level window) reflects the QSS font-size only if the + # style has fully polished by then — on the very FIRST paint of the + # Co4E screen it sometimes hasn't, so the fixed height computed in + # _add_section is briefly wrong (too tall) until something else + # triggers a relayout. Recomputing here, every time the label + # actually becomes visible, means the first paint is never stale. + self.setFixedHeight(self.fontMetrics().height()) + super().showEvent(event) + + +def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]: + """One group of fields, collapsed to just its heading by default and + independently expandable, so a long step config reads as a short list of + group names until you open the one you need. Deliberately bare — no card + border/background/box — the ▶/▼ marker and the heading text are the only + things separating one group from the next; opening one never closes + another (not an accordion, not a tab bar). Returns ``(form, card)``: add + the group's rows to ``form``; ``card`` is the whole section (header + + body) — hide it to remove the group entirely (e.g. for a section that + only applies to some steps), rather than hiding individual rows inside + an always-visible header.""" + p = current_palette() + card = QWidget() + card_lay = QVBoxLayout(card) + card_lay.setContentsMargins(0, 0, 0, 0) + card_lay.setSpacing(0) + + header = _SectionHeader() + header.setCursor(Qt.PointingHandCursor) + header.setStyleSheet(f"font-weight:400; font-size:13px; color:{p.text}; padding:0; margin:0;") + header.setContentsMargins(0, 0, 0, 0) + # QSS font-size only lands on the widget's actual QFont (and therefore + # its fontMetrics()) once the style sheet is polished — ensurePolished() + # forces that now, so the fixed height below is computed from the 12px + # font just set above, not the default one this label was constructed + # with. A label's natural sizeHint still reserves font leading above/ + # below the glyphs on top of the (now zeroed) QSS padding — pinning the + # height to the text's actual cap-to-baseline span is what closes that + # last gap without clipping the ▶ glyph, the title, or Vietnamese + # diacritics. + header.ensurePolished() + header.setFixedHeight(header.fontMetrics().height()) + header.setText(f"▶ {title}") + card_lay.addWidget(header) + + body = QWidget() + body.setVisible(False) + body.setMaximumHeight(0) + form = QFormLayout(body) + form.setContentsMargins(0, 6, 0, 0) + card_lay.addWidget(body) + + anim = QPropertyAnimation(body, b"maximumHeight", body) + anim.setDuration(_SECTION_ANIM_MS) + anim.setEasingCurve(QEasingCurve.InOutCubic) + + is_open = False + + def _on_finished() -> None: + if is_open: + # Uncapped once open, so switching to a step whose fields make + # this section taller/shorter (e.g. a parallel node's sub-agent + # list appearing) is never clipped by the height this animation + # last landed on. + body.setMaximumHeight(16_777_215) + else: + body.setVisible(False) + anim.finished.connect(_on_finished) + + def _toggle() -> None: + nonlocal is_open + is_open = not is_open + header.setText(f"{'▼' if is_open else '▶'} {title}") + anim.stop() + if is_open: + body.setVisible(True) + anim.setStartValue(body.height()) + anim.setEndValue(body.sizeHint().height()) + else: + anim.setStartValue(body.height()) + anim.setEndValue(0) + anim.start() + header.clicked.connect(_toggle) + + outer.addWidget(card) + return form, card + class StepConfigPanel(QScrollArea): changed = Signal() # any field edited → repaint node + autosave @@ -39,7 +145,16 @@ class StepConfigPanel(QScrollArea): self.setWidgetResizable(True) host = QWidget() self.setWidget(host) - form = QFormLayout(host) + outer = QVBoxLayout(host) + outer.setSpacing(1) + + # Grouped sections stacked on one scrolling page — same fields as + # before, grouped by what they're for: identity, execution + # (model/permission), and the extra resources fed to the step + # (skills/files/sub-agents). No tabs/accordion: every group's border + # and heading are what separate it from its neighbours, and all three + # are on screen (or one scroll away) at once. + form, _basic_card = _add_section(outer, tr("co4e.tab_basic")) self.label_edit = QLineEdit() self.label_edit.textChanged.connect(self._on_edit) @@ -80,6 +195,8 @@ class StepConfigPanel(QScrollArea): self.context_edit.textChanged.connect(self._on_edit) form.addRow(tr("co4e.f_context"), self.context_edit) + form2, _model_card = _add_section(outer, tr("co4e.tab_model_perm")) + model_row = QHBoxLayout() self.model_combo = QComboBox() self.model_combo.setEditable(True) @@ -92,13 +209,13 @@ class StepConfigPanel(QScrollArea): model_row.addWidget(self.model_combo, 1) model_row.addWidget(self.load_models_btn) mrow = QWidget(); mrow.setLayout(model_row) - form.addRow(tr("co4e.f_model"), mrow) + form2.addRow(tr("co4e.f_model"), mrow) self.perm_combo = QComboBox() for preset in PERMISSION_PRESETS: self.perm_combo.addItem(tr(f"co4e.perm.{preset}"), preset) self.perm_combo.currentIndexChanged.connect(self._on_edit) - form.addRow(tr("co4e.f_permission"), self.perm_combo) + form2.addRow(tr("co4e.f_permission"), self.perm_combo) verify_row = QHBoxLayout() self.verify_chk = QCheckBox(tr("co4e.f_self_verify")) @@ -111,13 +228,15 @@ class StepConfigPanel(QScrollArea): verify_row.addWidget(self.rounds_spin) verify_row.addStretch(1) vrow = QWidget(); vrow.setLayout(verify_row) - form.addRow("", vrow) + form2.addRow("", vrow) + + form3, _skills_card = _add_section(outer, tr("co4e.tab_skills_files")) # Skills checklist (registry skills) self.skills_list = QListWidget() self.skills_list.setMaximumHeight(110) self.skills_list.itemChanged.connect(self._on_edit) - form.addRow(tr("co4e.f_skills"), self.skills_list) + form3.addRow(tr("co4e.f_skills"), self.skills_list) # Attachments — files whose extracted text is fed to this step at run time. self.attach_list = QListWidget() @@ -133,11 +252,15 @@ class StepConfigPanel(QScrollArea): att_btns.addWidget(self.attach_del_btn) att_btns.addStretch(1) abtn = QWidget(); abtn.setLayout(att_btns) - form.addRow(tr("co4e.f_attachments"), self.attach_list) - form.addRow("", abtn) + form3.addRow(tr("co4e.f_attachments"), self.attach_list) + form3.addRow("", abtn) - # Parallel sub-agents (only shown for parallel nodes) - self.parallel_label = QLabel(tr("co4e.f_subagents")) + # Parallel sub-agents get their OWN section — same header style as + # Cơ bản/Model & Quyền/Skills & Tệp — rather than a row buried inside + # Skills & Tệp, since it's really a distinct group, just one that + # only applies to parallel-variant steps. load_step() hides the whole + # card for a non-parallel step (see is_par below). + form4, self._parallel_card = _add_section(outer, tr("co4e.f_subagents")) self.sub_list = QListWidget() self.sub_list.setMaximumHeight(90) self.sub_list.itemDoubleClicked.connect(self._edit_subagent) # re-pick agent @@ -152,10 +275,11 @@ class StepConfigPanel(QScrollArea): sub_btns.addWidget(self.sub_del_btn) sub_btns.addStretch(1) sbtn = QWidget(); sbtn.setLayout(sub_btns) - form.addRow(self.parallel_label, self.sub_list) - form.addRow("", sbtn) + form4.addRow(self.sub_list) + form4.addRow("", sbtn) - # Footer actions — one compact row (Run · Run from here · Delete). + # Footer actions — one compact row (Run · Run from here · Delete), + # kept below every section, not inside one of the cards. self.run_btn = QPushButton(tr("co4e.run")) self.run_btn.setIcon(icon("play")) self.run_btn.setToolTip(tr("co4e.run_this_step")) @@ -170,12 +294,21 @@ class StepConfigPanel(QScrollArea): self.del_btn.setFixedWidth(38) self.del_btn.clicked.connect(lambda: self.delete_node.emit(self._node_id)) foot = QHBoxLayout() - foot.setContentsMargins(0, 0, 0, 0) foot.addWidget(self.run_btn, 1) foot.addWidget(self.run_from_btn, 1) foot.addWidget(self.del_btn) foot_w = QWidget(); foot_w.setLayout(foot) - form.addRow("", foot_w) + outer.addWidget(foot_w) + # Without this, QVBoxLayout hands every child widget an EQUAL share of + # whatever extra height the scroll area's viewport has beyond the + # content's own sizeHint (setWidgetResizable(True) stretches `host` to + # fill it) — each collapsed header's card was measuring a true + # sizeHint of ~17px but rendering over 100px taller, and no amount of + # margin/padding/spacing on the header itself could touch that: the + # surplus was being spent on the cards, not around them. One trailing + # stretch absorbs all of it instead, so every section (and the + # footer) renders at exactly its own natural height. + outer.addStretch(1) self.setEnabled(False) @@ -209,12 +342,11 @@ class StepConfigPanel(QScrollArea): item = QListWidgetItem(_P(path).name) item.setToolTip(path) self.attach_list.addItem(item) - # parallel sub-agents + # parallel sub-agents — the whole "Agent song song" section only + # applies to parallel-variant steps, so the entire card (header + # included) is hidden for any other step, not just its rows. is_par = step.is_parallel - self.parallel_label.setVisible(is_par) - self.sub_list.setVisible(is_par) - self.sub_add_btn.setVisible(is_par) - self.sub_del_btn.setVisible(is_par) + self._parallel_card.setVisible(is_par) self.sub_list.clear() if is_par: for sub in step.sub_agents: diff --git a/ui/composer.py b/ui/composer.py index 9311144..0f41822 100644 --- a/ui/composer.py +++ b/ui/composer.py @@ -452,7 +452,12 @@ class Composer(QWidget): self.stop_btn.setObjectName("danger") self.stop_btn.setVisible(False) self.stop_btn.clicked.connect(self.stop_requested.emit) + # Attach pinned to the input's top edge, Send (and Stop, once a turn + # is running) pinned to its bottom edge — the gap between them is + # absorbed by this stretch instead of splitting evenly above/below + # the whole button column, which is what centering it did before. btns.addWidget(self.attach_btn) + btns.addStretch(1) btns.addWidget(self.send_btn) btns.addWidget(self.stop_btn) row.addLayout(btns) diff --git a/ui/schedule_task_tab.py b/ui/schedule_task_tab.py index c0ee2a1..bcc2a58 100644 --- a/ui/schedule_task_tab.py +++ b/ui/schedule_task_tab.py @@ -133,14 +133,25 @@ class ScheduleTaskTab(QWidget): board = QWidget() scroll.setWidget(board) cols = QHBoxLayout(board) - cols.setSpacing(8) + # Gutters wide enough to read as a break between lanes without eating + # too much of the seven-way split — they still share the board equally + # (see _fit_lanes below), so a wider gutter narrows every lane by the + # same share automatically; nothing else to compute here. + cols.setSpacing(2) self.columns: Dict[str, _KanbanColumn] = {} self.column_headers: Dict[str, QLabel] = {} for status in STATUSES: box = QVBoxLayout() + # The per-lane holder's own margins were the style's default + # (~9px a side) on top of the inter-column gap — with seven lanes + # that outweighs the gap itself. Zero it out and let the lane's + # header/list fill the width _fit_lanes() hands them. + box.setContentsMargins(0, 0, 0, 0) + box.setSpacing(2) head = QLabel() head.setStyleSheet("font-weight:600;") col = _KanbanColumn(status) + col.setObjectName("kanbanLane") col.task_dropped.connect(self._on_task_dropped) col.itemDoubleClicked.connect(self._on_double_click) col.setContextMenuPolicy(Qt.CustomContextMenu) diff --git a/ui/widgets.py b/ui/widgets.py index 4835354..c980d00 100644 --- a/ui/widgets.py +++ b/ui/widgets.py @@ -739,7 +739,11 @@ class CollapsibleSection(QWidget): activated = Signal(str) # emits the path of a clicked item - def __init__(self, title: str, max_height: int = 130): + def __init__(self, title: str, max_height: int | None = 130): + """``max_height`` caps the list so it scrolls instead of growing + (the default, e.g. for a section sharing space with siblings). + ``None`` instead lets it expand to fill whatever room its parent + layout hands it — for a section that owns the whole panel.""" super().__init__() self._title = title self._paths: list[str] = [] @@ -756,11 +760,14 @@ class CollapsibleSection(QWidget): lay.addWidget(self.header) self.list = QListWidget() - self.list.setMaximumHeight(max_height) # scrolls when longer + if max_height is not None: + self.list.setMaximumHeight(max_height) # scrolls when longer + else: + self.list.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) self.list.setVisible(False) self.list.itemActivated.connect(self._emit) self.list.itemClicked.connect(self._emit) - lay.addWidget(self.list) + lay.addWidget(self.list, 1 if max_height is None else 0) self.setVisible(False) self._update_header()