Files
cowork-local/ui/co4e_tab.py
T
Nam Pham Dinh ThanhandClaude Opus 5 0e00bf3c2f refactor(co4e): co4e_tab.py 1885 -> 389, Co4ETab tách thành 7 mixin
File to nhất còn lại của Gamma. Lâm bàn giao ở 1.885 dòng với 100 method
trong một lớp; chia theo bảy mối quan tâm:

    co4e_runs.py           364   chạy flow, 3 chế độ, bảng lịch sử lượt chạy
    co4e_chat.py           343   khung chat + đếm token + định tuyến riêng
    co4e_layout.py         308   ba khung, bảng cấu hình, bố cục màn hẹp
    co4e_sidebar.py        251   thư viện workflow/agent/skill, 4 mục gập
    co4e_flow_tabs.py      180   dải tab các flow đang mở
    co4e_workflow_crud.py  154   tạo/sửa/xoá/nhân bản workflow
    co4e_agents.py          51   agent và skill dùng trong flow
    ui/co4e_tab.py         389   __init__, set_project, thư mục output

Mọi file dưới 400 dòng.

MỘT LỖI SUÝT LÀM HỎNG FILE: bản đầu tôi cắt method theo m.lineno, mà lineno
trỏ vào dòng `def`, không tính dòng `@...` phía trên. Decorator bị bỏ lại
thành mồ côi ngay trên một hằng số lớp -> file hỏng cú pháp. Bắt được vì
script tự parse lại sau mỗi lần cắt; nếu chỉ cắt rồi ghi thì đã đẩy lên một
file không import nổi.

Ba vòng sửa mức import tương đối: co4e_tab.py nằm ở ui/ (1 cấp), file mới ở
presentation/co4e/ (2 cấp). Còn co4e_canvas / co4e_config_panel /
co4e_agent_dialog thì VẪN ở ui/, nên `.co4e_canvas` phải thành
`...ui.co4e_canvas` chứ không phải `.co4e_canvas` cùng thư mục.

714 test xanh — trong đó có ~4.000 dòng test đặc tả Lâm viết cho đúng vùng
này, nên việc tách được soi khá kỹ. check_co4e, check_controls_alive,
check_layout_geometry, check_probes_bite đều qua.

Cập nhật đích đột biến thứ ba của check_probes_bite: dải tab flow nay ở
presentation/co4e/co4e_layout.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 10:43:33 +09:00

390 lines
14 KiB
Python

"""Co4E — node-graph workflow studio (a Workspace sub-tab).
Layout (3 columns): left sidebar (Workflows / Agents / Skills, with CRUD,
drag-to-canvas, run-in-background + a live "Running flows" status list) | center
(compact toolbar + node canvas + bottom Chat/Output) | right (step config panel).
Runs: pick a mode — Auto (each step's agent plans then executes), Plan
(read-only, each step only drafts a plan), or Manual (step-by-step, advance with
"Next step"). Adjacent steps feed the next automatically (no manual wiring).
Several flows can run at once (foreground on the canvas + background from the
Workflows list); the run manager keeps their status live across sub-tab switches.
Chat supports inline directives with autocomplete: ``/agent:<name>`` picks a
persona and ``/skill:<name>`` applies a skill — same as Cowork.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Dict, List, Optional
from PySide6.QtCore import QSize, Qt, Signal
from PySide6.QtWidgets import (
QComboBox, QFrame, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit,
QListView, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPushButton,
QScrollArea, QSizePolicy, QSpacerItem, QSplitter, QTabBar, QTableWidget,
QTableWidgetItem, QTabWidget, QTextBrowser, QVBoxLayout, QWidget,
)
from ..core import co4e, skills as skills_mod
from ..core.co4e_builtins import BUILTIN_AGENTS
from ..core.co4e_run_manager import Co4ERunManager
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 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": "▶",
"error": "✗", "pending": "○", "todo": "○"}
def _fmt_plan(steps) -> str:
"""Render plan steps ``[{title,status}]`` as a ticked-off checklist."""
lines = []
for s in steps or []:
title = str((s or {}).get("title", "")).strip()
if not title:
continue
glyph = _PLAN_GLYPH.get(str((s or {}).get("status", "pending")).lower(), "○")
lines.append(f"{glyph} {title}")
return "\n".join(lines)
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
icon-only width so they read as proper buttons) and an even gap between them.
The icon sits centered in each tab."""
_GAP = 6 # px between tabs — matches the QSS margin-right below
def tabSizeHint(self, index): # noqa: N802
base = super().tabSizeHint(index)
n = self.count() or 1
avail = self.width()
if avail <= 1: # width not resolved yet → use parent
p = self.parentWidget()
avail = p.width() if p is not None else 0
share = (avail - n * self._GAP) // n if avail > 1 else 0
return QSize(max(56, share), max(30, base.height()))
def resizeEvent(self, e): # noqa: N802
super().resizeEvent(e)
self.updateGeometry() # re-hint tab widths when resized
from ..presentation.co4e.co4e_flow_tabs import Co4EFlowTabsMixin
from ..presentation.co4e.co4e_sidebar import Co4ESidebarMixin
from ..presentation.co4e.co4e_layout import Co4ELayoutMixin
from ..presentation.co4e.co4e_workflow_crud import Co4EWorkflowCrudMixin
from ..presentation.co4e.co4e_agents import Co4EAgentsMixin
from ..presentation.co4e.co4e_runs import Co4ERunsMixin
from ..presentation.co4e.co4e_chat import Co4EChatMixin
class Co4ETab(
Co4EFlowTabsMixin,
Co4ESidebarMixin,
Co4ELayoutMixin,
Co4EWorkflowCrudMixin,
Co4EAgentsMixin,
Co4ERunsMixin,
Co4EChatMixin,
QWidget):
status_message = Signal(str)
def __init__(self, ctx):
super().__init__()
self.ctx = ctx
self._wf: co4e.Workflow = co4e.new_workflow(tr("co4e.untitled"))
self._chat_worker: Optional[AgentWorker] = None
# run state
self.manager = Co4ERunManager(ctx)
self.manager.changed.connect(self._refresh_runs)
self.manager.event.connect(self._on_manager_event)
# Per-flow run state so any number of flow tabs run in PARALLEL without
# mixing (bounded only by the machine — each run is its own QThread).
self._flow_runs: Dict[str, str] = {} # wf_id -> its active canvas run id
self._run_logs: Dict[str, "ChatView"] = {} # run_id -> that flow's chat log
self._flow_outputs: Dict[str, Dict[str, str]] = {} # wf_id -> {node_id: output}
# Running token/cost total per flow (↓in ↑out ▤ctx $cost), shown in the
# Messages header like Cowork's conversation total.
self._flow_usage: Dict[str, Dict[str, float]] = {}
self._project_id: str = "" # selected Workspace project
self._project_dir: Optional[Path] = None # its workspace folder (flow output goes here)
# manual mode
self._manual_active = False
self._manual_order: List[str] = []
self._manual_idx = 0
# open flows shown as browser-style tabs (each its own graph; runs are
# independent via the run manager)
self._flows: List[co4e.Workflow] = []
self._active_flow_idx = -1
root = QHBoxLayout(self)
self._split = QSplitter(Qt.Horizontal)
root.addWidget(self._split)
sidebar = self._build_sidebar()
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)
self.config.setMinimumWidth(300) # so fields (incl. the model row) are never clipped
self.config.changed.connect(self._on_config_changed)
self.config.run_node.connect(lambda nid: self._run_single(nid))
self.config.run_from.connect(self._run_from)
self.config.delete_node.connect(self.canvas.delete_node)
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)
self._split.setSizes([250, 780, 360])
self._split.setChildrenCollapsible(True)
self.canvas.node_selected.connect(self._on_node_selected)
self.canvas.node_activated.connect(self._on_node_selected)
self.canvas.graph_changed.connect(self._autosave)
self._reload_sidebar()
self._open_flow(self._wf) # first browser-style flow tab
self._retranslate() # set Runs table headers etc.
self._refresh_runs()
on_language_changed(self._retranslate)
# ---- flow tabs (browser-style: several open flows, independent) --------
# ---- per-flow run helpers (parallel, isolated per flow) ---------------
# ---- sidebar ----------------------------------------------------------
_SIDE_RUNS = 6
# ---- center -----------------------------------------------------------
# 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()
# ---- per-flow chat logs (each flow = its own conversation) ------------
# ---- workflow load/save ----------------------------------------------
# ---- node selection / config -----------------------------------------
# ---- custom agents ----------------------------------------------------
# ---- running ----------------------------------------------------------
# ---- manual mode (step-by-step) --------------------------------------
# ---- run-manager events ----------------------------------------------
# ---- workspace binding + output folder (where flow files land) --------
def set_project(self, project_id: str) -> None:
"""Bind Co4E to the SELECTED Workspace project so flow output (and per-run
folders) land in THAT project's workspace — mirroring how Cowork writes to
the project folder — instead of the global/config output dir."""
from ..core.projects import load_project
self._project_id = project_id or ""
self._project_dir = None
if project_id and project_id not in ("", "default"):
proj = load_project(project_id)
if proj is not None:
self._project_dir = Path(proj.workspace_dir())
# Route the run manager's output at the selected workspace, and filter
# Flow Status to this workspace's runs.
self.manager.set_output_root(self._flow_output_root())
self.manager.set_current_project(self._project_id)
if hasattr(self, "ws_folder_btn"):
self._refresh_ws_folder_btn()
def _flow_output_root(self) -> Path:
"""The workspace folder flow outputs are written under (one subfolder per
flow). Uses the SELECTED project's workspace when one is bound, else the
global Cowork output dir. Mirrors co4e_run_manager._out_dir's base."""
if self._project_dir is not None:
return self._project_dir / "co4e"
try:
base = self.ctx.config.cowork_output_dir()
except Exception: # noqa: BLE001
base = co4e.CO4E_DIR / "runs"
return Path(base) / "co4e"
def _refresh_ws_folder_btn(self) -> None:
root = self._flow_output_root()
parts = root.parts
short = "…/" + "/".join(parts[-2:]) if len(parts) > 2 else str(root)
self.ws_folder_btn.setText(short)
self.ws_folder_btn.setToolTip(tr("co4e.tt_open_workspace", path=str(root)))
def _open_workspace_folder(self) -> None:
from .osutil import open_location
root = self._flow_output_root()
try:
root.mkdir(parents=True, exist_ok=True)
except OSError:
pass
open_location(str(root))
def showEvent(self, e): # noqa: N802
# Guarantee the status list is current whenever the tab is shown again.
self._refresh_runs()
super().showEvent(e)
def _out_dir(self) -> Path:
# Save chat/flow deliverables into the SELECTED workspace (the active
# project's folder via _flow_output_root), so files land where the user
# works with them — not in the config/install folder.
d = self._flow_output_root() / co4e.slugify(self._wf.name or "flow")
d.mkdir(parents=True, exist_ok=True)
return d
# ---- chat (with /agent /skill directives) -----------------------------
# ---- token / cost accounting (shown per-message + as a flow total) ------
# ---- i18n -------------------------------------------------------------
def _retranslate(self) -> None:
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"))
self.run_del_btn.setText(tr("co4e.delete_run"))
self.run_clear_btn.setText(tr("co4e.clear_done"))
self._refresh_ws_folder_btn()
self.runs_table.setHorizontalHeaderLabels([
tr("co4e.runs_col_flow"), tr("co4e.runs_col_status"), tr("co4e.runs_col_steps"),
tr("co4e.runs_col_by"), tr("co4e.runs_col_at")])
self._reload_sidebar()
self._refresh_runs()
def _html_escape(text: str) -> str:
return (text or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def _qcolor(hex_str: str):
from PySide6.QtGui import QColor
return QColor(hex_str)