feat(co4e): tách 6 widget UI khỏi ui/co4e_tab.py sang presentation/co4e/*

Lane N3 (Co4E Studio) — dùng bộ workflow refactor-god-file, mỗi bước có
characterization test trước khi tách, hậu kiểm ranh giới tầng sau mỗi bước:

- skills_list_panel.py / agent_list_panel.py — 2 khu vực sidebar
- co4e_canvas_widget.py + canvas_items.py + canvas_interaction_mixin.py —
  Co4ECanvas tách 3 file (vượt 400 dòng nếu đứng một mình)
- node_property_panel.py + node_property_actions_mixin.py +
  step_config_section.py — StepConfigPanel, cùng lý do
- co4e_run_control_widget.py — RunsPagePanel (trang Flow Status)
- co4e_chat_view.py — ChatPanel + _ChatInput + helper autocomplete
- palette_list.py — _PaletteList dời khỏi ui/co4e_tab.py, hết import ngược
  presentation -> ui (agent/skills panel giờ import top-level)

ui/co4e_tab.py giảm 2089 -> 1878 dòng, chỉ còn phần wiring + business logic
(Co4ERunManager/AgentWorker chưa đổi — nằm ngoài phạm vi này, xem docstring
presentation/co4e/co4e_tab.py). ui/co4e_canvas.py và ui/co4e_config_panel.py
còn lại là compat shim re-export, không đổi API cho bên gọi.

Thêm tests/test_co4e_integration.py — dựng thật Co4ETab qua build_co4e_tab(),
lái luồng qua nhiều panel trong cùng instance (thêm node, mở/gập chat, chuyển
trang Flow Status rồi quay lại không mất state canvas) — bắt lỗi wiring
xuyên-panel mà characterization test từng panel riêng không thấy được.

Đã xác minh: pytest 348 passed/1 skipped, tools/check_co4e.py sạch, không
file nào >400 dòng, domain/application không import PySide6, và so pixel
before/after (git worktree tại HEAD cũ) ra 0/1.125.000 pixel khác biệt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 18:43:42 +09:00
co-authored by Claude Sonnet 5
parent ca7ea1479d
commit 0631abf85f
45 changed files with 22739 additions and 1578 deletions
+70 -281
View File
@@ -15,13 +15,11 @@ persona and ``/skill:<name>`` applies a skill — same as Cowork.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Dict, List, Optional
from PySide6.QtCore import QMimeData, QSize, Qt, Signal
from PySide6.QtGui import QDrag
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtWidgets import (
QComboBox, QFrame, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit,
QListView, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPushButton,
@@ -36,9 +34,16 @@ from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..theme import current_palette
from .chat_view import ChatView
from .co4e_canvas import CO4E_MIME, Co4ECanvas
from .co4e_canvas import Co4ECanvas
from .co4e_config_panel import StepConfigPanel
from .icons import icon
from ..presentation.co4e.agent_list_panel import AgentListPanel
from ..presentation.co4e.co4e_chat_view import (
ChatPanel, _ChatInput, _agent_names, _directive_token, _skill_names,
)
from ..presentation.co4e.co4e_run_control_widget import RunsPagePanel
from ..presentation.co4e.palette_list import _PaletteList
from ..presentation.co4e.skills_list_panel import SkillsListPanel
_PLAN_GLYPH = {"completed": "✓", "done": "✓", "in_progress": "▶", "running": "▶",
@@ -57,19 +62,6 @@ def _fmt_plan(steps) -> str:
return "\n".join(lines)
def _skill_names() -> List[str]:
try:
return [s.name for s in skills_mod.list_skills() + skills_mod.builtin_skills()]
except Exception: # noqa: BLE001
return []
def _agent_names() -> List[str]:
names = [a.name for a in co4e.list_custom_agents()]
names += [a.name for a in BUILTIN_AGENTS if a.name not in names]
return names
class _EqualTabBar(QTabBar):
"""Icon-only sidebar tabs (Workflows / Agents / Skills), all the same width,
sized to fill the sidebar with a comfortable minimum (~double the default
@@ -93,138 +85,6 @@ class _EqualTabBar(QTabBar):
self.updateGeometry() # re-hint tab widths when resized
class _PaletteList(QListWidget):
"""A list whose rows can be dragged onto the canvas. Each item carries a
JSON-able drag payload (a step dict, or a workflow dict) in ``payload_role``.
Workflow rows also keep a (kind, id) tuple in Qt.UserRole for load/delete."""
def __init__(self, parent=None, payload_role=Qt.UserRole):
super().__init__(parent)
self._payload_role = payload_role
self.setDragEnabled(True)
self.setDragDropMode(QListWidget.DragOnly)
def startDrag(self, _actions): # noqa: N802
item = self.currentItem()
if item is None:
return
payload = item.data(self._payload_role)
if not payload:
return
md = QMimeData()
md.setData(CO4E_MIME, json.dumps(payload).encode("utf-8"))
drag = QDrag(self)
drag.setMimeData(md)
drag.exec(Qt.CopyAction)
def _directive_token(text: str, pos: int):
"""Locate a ``/skill[:x]`` or ``/agent[:x]`` directive the cursor is on,
anywhere in the line. Returns ``(start, kind, partial)`` or ``None``."""
before = text[:pos]
start = re.search(r"\S*$", before).start()
token = before[start:]
m = re.match(r"^/(skill|agent):?([\w\-.]*)$", token)
if m:
return start, m.group(1), m.group(2)
for kind in ("skill", "agent"):
if len(token) >= 2 and ("/" + kind).startswith(token):
return start, kind, ""
return None
class _ChatInput(QLineEdit):
"""Chat box with ``/skill:`` and ``/agent:`` autocomplete (parity with the
Cowork composer). The popup never grabs focus, so typing keeps flowing."""
submit = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self._popup = QListWidget()
self._popup.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint
| Qt.WindowStaysOnTopHint | Qt.NoDropShadowWindowHint)
self._popup.setAttribute(Qt.WA_ShowWithoutActivating, True)
self._popup.setFocusPolicy(Qt.NoFocus)
self._popup.itemClicked.connect(lambda _i: self._accept())
self.textEdited.connect(self._maybe_popup)
def _maybe_popup(self, *_a) -> None:
tok = _directive_token(self.text(), self.cursorPosition())
if tok is None:
self._popup.hide()
return
_start, kind, partial = tok
f = partial.lower()
self._popup.clear()
if kind == "skill":
for name in _skill_names():
if f in name.lower():
self._add_row(name, f"/skill:{co4e.slugify(name)} ", name)
else:
for name in _agent_names():
if f in name.lower():
self._add_row(name, f"/agent:{name} ", name)
if self._popup.count() == 0:
self._popup.hide()
return
self._popup.setCurrentRow(0)
rows = min(7, self._popup.count())
h = 8 + rows * 22
self._popup.resize(max(280, self.width()), h)
tl = self.mapToGlobal(self.rect().topLeft())
self._popup.move(tl.x(), tl.y() - h - 2)
self._popup.show()
def _add_row(self, label: str, replacement: str, tip: str) -> None:
it = QListWidgetItem(label)
it.setData(Qt.UserRole, replacement)
it.setToolTip(tip)
self._popup.addItem(it)
def _accept(self) -> None:
item = self._popup.currentItem()
self._popup.hide()
if item is None:
return
replacement = item.data(Qt.UserRole)
tok = _directive_token(self.text(), self.cursorPosition())
start = tok[0] if tok else self.cursorPosition()
pos = self.cursorPosition()
full = self.text()
new_text = full[:start] + replacement + full[pos:]
self.setText(new_text)
self.setCursorPosition(start + len(replacement))
self.setFocus()
def focusOutEvent(self, e): # noqa: N802
if not self._popup.underMouse():
self._popup.hide()
super().focusOutEvent(e)
def keyPressEvent(self, e): # noqa: N802
if self._popup.isVisible():
k = e.key()
n = self._popup.count()
if k in (Qt.Key_Down, Qt.Key_Up) and n:
step = 1 if k == Qt.Key_Down else -1
self._popup.setCurrentRow((self._popup.currentRow() + step) % n)
return
if k in (Qt.Key_Tab,):
self._accept()
return
if k == Qt.Key_Escape:
self._popup.hide()
return
if k in (Qt.Key_Return, Qt.Key_Enter):
self._accept()
return
if e.key() in (Qt.Key_Return, Qt.Key_Enter):
self.submit.emit()
return
super().keyPressEvent(e)
class Co4ETab(QWidget):
status_message = Signal(str)
@@ -546,38 +406,31 @@ class Co4ETab(QWidget):
col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3)
# --- 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.setObjectName("co4eSectionAction")
self.ag_new_btn.setFlat(True)
self.ag_new_btn.setCursor(Qt.PointingHandCursor)
# Widget cua khu vuc nay da doi sang AgentListPanel (xem
# presentation/co4e/agent_list_panel.py); o day chi con giu
# ag_new_btn/agent_list/ag_edit_btn/ag_del_btn nhu 4 ten thuoc tinh cu
# va tu noi signal - dung nguyen tac panel khong tu wire, Co4ETab moi
# biet _new_agent/_edit_agent/_delete_agent.
self._agent_panel = AgentListPanel()
self.ag_new_btn = self._agent_panel.new_btn
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_edit_btn)
ag_btns.addWidget(self.ag_del_btn)
ag_btns.addStretch(1)
al.addLayout(ag_btns)
col.addWidget(self._section("co4e.tab_agents", ag_body, self.ag_new_btn), 3)
self.agent_list = self._agent_panel.list_widget
self.ag_edit_btn = self._agent_panel.edit_btn
self.ag_edit_btn.clicked.connect(self._edit_agent)
self.ag_del_btn = self._agent_panel.del_btn
self.ag_del_btn.clicked.connect(self._delete_agent)
col.addWidget(self._section("co4e.tab_agents", self._agent_panel, self.ag_new_btn), 3)
# --- SKILLS ------------------------------------------------------
self.sk_manage_btn = QPushButton(tr("co4e.manage_skills"))
self.sk_manage_btn.setToolTip(tr("co4e.tt_manage_skills"))
self.sk_manage_btn.setObjectName("co4eSectionAction")
self.sk_manage_btn.setFlat(True)
self.sk_manage_btn.setCursor(Qt.PointingHandCursor)
# Widget cua khu vuc nay da doi sang SkillsListPanel (xem
# presentation/co4e/skills_list_panel.py); o day chi con giu
# sk_manage_btn/skill_list nhu 2 ten thuoc tinh cu va tu noi signal -
# dung nguyen tac panel khong tu wire, Co4ETab moi biet _manage_skills.
self._skills_panel = SkillsListPanel()
self.sk_manage_btn = self._skills_panel.manage_btn
self.sk_manage_btn.clicked.connect(self._manage_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)
self.skill_list = self._skills_panel.list_widget
col.addWidget(self._section("co4e.tab_skills", self._skills_panel, self.sk_manage_btn), 2)
# --- RUNS --------------------------------------------------------
# A short, always-visible view of the same runs the Flow Status page
@@ -874,63 +727,32 @@ class Co4ETab(QWidget):
def _build_runs_page(self) -> QWidget:
"""The pinned 'Runs' tab: a table of every flow run (name · status · steps
done/total · creator · created) for tracking. Double-click a run to open
that flow's tab with its live status."""
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"))
that flow's tab with its live status.
Widget construction lives in ``RunsPagePanel`` (presentation/co4e/
co4e_run_control_widget.py); this method just wires the panel's public
attributes to the handler methods that know about ``self`` (``_show_runs``,
``_stop_selected_run``, ...) — the panel itself stays ignorant of ``Co4ETab``.
"""
panel = RunsPagePanel()
self.runs_back_btn = panel.back_btn
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)
# Show + open the workspace folder where flow outputs land (below the tab,
# next to the title) so the files a flow produced are easy to find.
self.ws_folder_btn = QPushButton()
self.ws_folder_btn.setIcon(icon("folder"))
self.ws_folder_btn.setFlat(True)
self.ws_folder_btn.setCursor(Qt.PointingHandCursor)
self.runs_title = panel.title_label
self.ws_folder_btn = panel.ws_folder_btn
self.ws_folder_btn.clicked.connect(self._open_workspace_folder)
self._refresh_ws_folder_btn()
hdr.addWidget(self.ws_folder_btn)
hdr.addStretch(1)
self.run_stop_btn = QPushButton(tr("co4e.stop"))
self.run_stop_btn.setIcon(icon("stop"))
self.run_stop_btn.setObjectName("danger")
self.run_stop_btn.setToolTip(tr("co4e.tt_stop_run"))
self.run_stop_btn = panel.stop_btn
self.run_stop_btn.clicked.connect(self._stop_selected_run)
self.run_rename_btn = QPushButton(tr("co4e.rename_run"))
self.run_rename_btn.setIcon(icon("edit"))
self.run_rename_btn.setToolTip(tr("co4e.tt_rename_run"))
self.run_rename_btn = panel.rename_btn
self.run_rename_btn.clicked.connect(self._rename_selected_run)
self.run_del_btn = QPushButton(tr("co4e.delete_run"))
self.run_del_btn.setIcon(icon("trash"))
self.run_del_btn.setToolTip(tr("co4e.tt_delete_run"))
self.run_del_btn = panel.del_btn
self.run_del_btn.clicked.connect(self._delete_selected_run)
self.run_clear_btn = QPushButton(tr("co4e.clear_done"))
self.run_clear_btn.setToolTip(tr("co4e.tt_clear_runs"))
self.run_clear_btn = panel.clear_btn
self.run_clear_btn.clicked.connect(lambda: self.manager.clear_finished())
hdr.addWidget(self.run_stop_btn)
hdr.addWidget(self.run_rename_btn)
hdr.addWidget(self.run_del_btn)
hdr.addWidget(self.run_clear_btn)
v.addLayout(hdr)
self.runs_table = QTableWidget(0, 5)
self.runs_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.runs_table.verticalHeader().setVisible(False)
self.runs_table.setEditTriggers(QTableWidget.NoEditTriggers)
self.runs_table.setSelectionBehavior(QTableWidget.SelectRows)
self.runs_table.setToolTip(tr("co4e.tt_runs_list"))
self.runs_table = panel.table
self.runs_table.itemDoubleClicked.connect(self._open_run_from_table)
# Right-click a run → Open / Delete (delete a single old run from history).
self.runs_table.setContextMenuPolicy(Qt.CustomContextMenu)
self.runs_table.customContextMenuRequested.connect(self._runs_context_menu)
v.addWidget(self.runs_table, 1)
return w
return panel
def _wrap_config(self) -> QWidget:
"""Wrap the step-config panel with a header that has an expand/collapse
@@ -1063,69 +885,36 @@ class Co4ETab(QWidget):
self.canvas.add_overlay(bar)
def _build_chat(self) -> QWidget:
w = QWidget()
self._chat_widget = w
lay = QVBoxLayout(w)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(0)
# "Messages" header at the TOP, above the chat box. Toggling it shows or
# hides the WHOLE chat box (message list + composer) below it.
self._mhdr = QWidget(); self._mhdr.setObjectName("msgHeader")
mh = QHBoxLayout(self._mhdr); mh.setContentsMargins(6, 3, 6, 3); mh.setSpacing(6)
self.msgs_icon = QLabel(); self.msgs_icon.setPixmap(icon("message").pixmap(14, 14))
self.msgs_title = QLabel(tr("co4e.messages")); self.msgs_title.setObjectName("hint")
self.chat_toggle_btn = QPushButton()
self.chat_toggle_btn.setObjectName("msgToggle")
self.chat_toggle_btn.setFlat(True)
self.chat_toggle_btn.setIcon(icon("chevron-up")) # collapsed → points up (click to expand)
self.chat_toggle_btn.setFixedSize(22, 22)
"""Widget construction lives in ``ChatPanel`` (presentation/co4e/
co4e_chat_view.py); this method just wires the panel's public
attributes to the handler methods that know about ``self``
(``_toggle_messages``, ``_chat_send``) and keeps the state that is
NOT part of the panel's own construction (``_flow_logs`` — per-flow
ChatView dict, ``_co4e_routed_provider`` — routing override, and
``_vsplit_sizes``/``_msgs_collapsed`` — used by ``_toggle_messages``
below to restore/collapse the splitter) — the panel itself stays
ignorant of ``Co4ETab``.
"""
panel = ChatPanel(self.ctx)
self._chat_widget = panel
self.msgs_icon = panel.msgs_icon
self.msgs_title = panel.msgs_title
self.chat_toggle_btn = panel.chat_toggle_btn
self.chat_toggle_btn.clicked.connect(self._toggle_messages)
mh.addWidget(self.msgs_icon)
mh.addWidget(self.msgs_title)
mh.addStretch(1)
mh.addWidget(self.chat_toggle_btn)
lay.addWidget(self._mhdr) # header on top
# Point-conversation (message bubbles) like Cowork, not a flat textbox.
# ONE ChatView PER FLOW (keyed by workflow id) inside a stack, so each flow
# tab has its OWN separate conversation and they never bleed into each other.
from PySide6.QtWidgets import QStackedWidget
self.chat_stack = QStackedWidget()
self._mhdr = panel.header
self.chat_stack = panel.chat_stack
self._flow_logs: Dict[str, ChatView] = {}
lay.addWidget(self.chat_stack, 1)
self.chat_input_row = QWidget()
crow = QVBoxLayout(self.chat_input_row)
crow.setContentsMargins(0, 4, 0, 0); crow.setSpacing(3)
# Running conversation token/cost TOTAL for this flow (↓in ↑out ▤ctx
# $cost) at the bottom, exactly like Cowork's conversation total.
self._usage_total_lbl = QLabel("")
self._usage_total_lbl.setObjectName("hint")
self._usage_total_lbl.setStyleSheet(
f"color: {current_palette().text_faint}; font-size: 11px;")
crow.addWidget(self._usage_total_lbl)
_inp = QWidget(); row = QHBoxLayout(_inp); row.setContentsMargins(0, 0, 0, 0)
self.chat_input = _ChatInput()
self.chat_input.setPlaceholderText(tr("co4e.chat_placeholder"))
self.chat_input_row = panel.chat_input_row
self._usage_total_lbl = panel.usage_total_lbl
self.chat_input = panel.chat_input
self.chat_input.submit.connect(self._chat_send)
self.chat_send_btn = QPushButton(tr("co4e.send")); self.chat_send_btn.setIcon(icon("send"))
self.chat_send_btn = panel.chat_send_btn
self.chat_send_btn.clicked.connect(self._chat_send)
row.addWidget(self.chat_input, 1)
# Off/Auto/Manual routing toggle for Co4E (surface key "co4e").
from .routing_toggle import RoutingToggle
self.co4e_routing_toggle = RoutingToggle(self.ctx, "co4e")
self.co4e_routing_toggle = panel.co4e_routing_toggle
self._co4e_routed_provider = None # routing provider override for the next turn
row.addWidget(self.co4e_routing_toggle)
row.addWidget(self.chat_send_btn)
crow.addWidget(_inp)
lay.addWidget(self.chat_input_row)
# Default = COLLAPSED: only the "Messages" header shows; the chat box is
# hidden and the canvas gets the room until the user expands it.
self._vsplit_sizes = [540, 220] # sizes to restore when expanded
self._msgs_collapsed = True
self.chat_stack.hide()
self.chat_input_row.hide()
self.chat_toggle_btn.setToolTip(tr("co4e.tt_expand_msgs"))
w.setMaximumHeight(self._mhdr.sizeHint().height() + 6)
return w
return panel
def _toggle_messages(self) -> None:
"""Show/hide the WHOLE chat box (message list + composer) below the