Co4E step config as collapsible sections; tighter Kanban lanes; Output panel fills its column
CI / test (pull_request) Canceled after 0s

- 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).
This commit is contained in:
Hiep Ha Van
2026-08-20 01:09:01 +09:00
parent 2ea20a8548
commit d68d91ae05
8 changed files with 202 additions and 32 deletions
+7 -2
View File
@@ -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)
+3
View File
@@ -2765,6 +2765,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
"ja": "フローとチャット — /agent:<name> または /skill:<name>",
"vi": "Chat với flow — dùng /agent:<name> hoặc /skill:<name>"},
"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"},
+4
View File
@@ -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;
+6 -3
View File
@@ -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).
+155 -23
View File
@@ -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:
+5
View File
@@ -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)
+12 -1
View File
@@ -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)
+10 -3
View File
@@ -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()