Files
cowork-local/ui/co4e_tab.py
T
1419587401
CI / test (push) Canceled after 0s
Feature/fsg gamma team ui fix (#3)
## Summary

What changed and why?

## Change Type

- [ ] Cowork feature
- [ ] Bug fix
- [x] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Co-authored-by: NamPDT <minhanhpkpro@gmail.com>
Reviewed-on: #3
2026-08-20 12:12:56 +00:00

2090 lines
97 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 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.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 CO4E_MIME, Co4ECanvas
from .co4e_config_panel import StepConfigPanel
from .icons import icon
_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)
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
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
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)
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) --------
def _open_flow(self, wf: co4e.Workflow) -> None:
"""Open ``wf`` in a tab — reuse its tab if already open (like a browser),
else add a new one and switch to it. Bar index 0 is the pinned Runs tab,
so flow ``i`` lives at bar index ``i + 1``. If the flow has an active run,
its live status is reflected on the canvas."""
for i, f in enumerate(self._flows):
if f.id == wf.id:
self._flows[i] = wf
bar_idx = i + 1
self.flow_bar.setTabText(bar_idx, wf.name or tr("co4e.untitled"))
if self.flow_bar.currentIndex() == bar_idx:
self._active_flow_idx = -1 # force reload of same tab
self._on_flow_tab_changed(bar_idx)
else:
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"))
self._add_tab_close_button(bar_idx)
self.flow_bar.blockSignals(False)
if self.flow_bar.currentIndex() == bar_idx:
self._on_flow_tab_changed(bar_idx) # already current → load manually
else:
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 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
if not (0 <= flow_idx < len(self._flows)):
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
across themes, unlike the CSS-positioned default which looked detached)."""
btn = QPushButton("×") # ×
btn.setObjectName("flowTabClose")
btn.setFlat(True)
btn.setFixedSize(16, 16)
btn.setCursor(Qt.PointingHandCursor)
btn.clicked.connect(lambda: self._close_flow_tab_button(btn))
self.flow_bar.setTabButton(idx, QTabBar.RightSide, btn)
def _close_flow_tab_button(self, btn) -> None:
for i in range(self.flow_bar.count()):
if self.flow_bar.tabButton(i, QTabBar.RightSide) is btn:
self._close_flow_tab(i)
return
def _close_flow_tab(self, idx: int) -> None:
if idx <= 0: # Runs tab is pinned
return
flow_idx = idx - 1
if not (0 <= flow_idx < len(self._flows)):
return
closing = self._flows[flow_idx]
# Stop mirroring the closed flow's run onto the canvas — the run itself
# keeps going in the background and stays in Flow Status. (Per-flow run
# tracking: only this flow's entry is dropped; other flows keep running.)
rid = self._flow_runs.pop(closing.id, None)
if rid is not None:
self._run_logs.pop(rid, None)
if getattr(self, "_wf", None) is not None and self._wf.id == closing.id:
self._manual_active = False
self.run_btn.setText(tr("co4e.run"))
self._flows.pop(flow_idx)
self.flow_bar.blockSignals(True)
self.flow_bar.removeTab(idx)
self.flow_bar.blockSignals(False)
self._active_flow_idx = -1
if not self._flows:
self._open_flow(co4e.new_workflow(tr("co4e.untitled")))
else:
new_bar = min(idx, len(self._flows)) # clamp to the last flow tab
self.flow_bar.blockSignals(True)
self.flow_bar.setCurrentIndex(new_bar)
self.flow_bar.blockSignals(False)
self._on_flow_tab_changed(new_bar)
def _sync_active_flow_tab_text(self) -> None:
i = self.flow_bar.currentIndex()
if i >= 1: # never rename the Runs tab
self.flow_bar.setTabText(i, self._wf.name or tr("co4e.untitled"))
def _reflect_active_run(self, wf_id: str) -> None:
"""If a run for this flow is active, mirror its live node statuses onto the
canvas and keep tracking it so updates continue to show."""
for h in self.manager.all_runs():
if h.wf_id == wf_id and h.running:
self._flow_runs[wf_id] = h.id
for nid, st in h.node_status.items():
self.canvas.update_node_status(nid, st)
return
# ---- per-flow run helpers (parallel, isolated per flow) ---------------
def _cur_run_id(self) -> Optional[str]:
"""The active canvas run of the CURRENTLY-shown flow, or None. Clears a
stale entry if that run already finished."""
wf = getattr(self, "_wf", None)
if wf is None:
return None
rid = self._flow_runs.get(wf.id)
if rid is None:
return None
h = self.manager.get(rid)
if h is None or not h.running:
self._flow_runs.pop(wf.id, None)
return None
return rid
def _outputs_for(self, wf_id: str) -> Dict[str, str]:
"""This flow's accumulated step outputs (kept separate per flow so parallel
runs never seed each other's context)."""
return self._flow_outputs.setdefault(wf_id, {})
def _update_run_btn(self) -> None:
self.run_btn.setText(tr("co4e.interrupt") if self._cur_run_id() is not None
else tr("co4e.run"))
# ---- sidebar ----------------------------------------------------------
def _build_sidebar(self) -> QWidget:
# 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.setObjectName("co4eSectionAction")
self.wf_new_btn.setFlat(True)
self.wf_new_btn.setCursor(Qt.PointingHandCursor)
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 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)
self.wf_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.wf_list.customContextMenuRequested.connect(self._wf_context_menu)
wl.addWidget(self.wf_list, 1)
wf_btns = QHBoxLayout(); wf_btns.setSpacing(4)
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)
wl.addWidget(self.wf_runbg_btn)
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)
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)
# --- 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)
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)
# --- 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)
b.clicked.connect(slot)
return b
def _reload_sidebar(self) -> None:
self.wf_list.clear()
for wf in co4e.list_workflows():
tag = tr("co4e.template") if wf.is_template else tr("co4e.saved")
it = QListWidgetItem(icon("workspaces"), f"{wf.name} · {tag}")
it.setData(Qt.UserRole, ("saved", wf.id))
it.setData(Qt.UserRole + 2, {"kind": "workflow", "workflow": co4e.workflow_to_dict(wf)})
self.wf_list.addItem(it)
# Agents: only the Parallel fan-out node + the user's own custom agents
# (create your own with "+ New agent"; drag onto the canvas). The blank
# "New Step" palette entry was removed — use the toolbar "+ Add" instead.
self.agent_list.clear()
self.agent_list.addItem(self._palette_item(
tr("co4e.parallel_node"), "server",
{"variant": "parallel", "label": "Parallel", "role": "PARALLEL", "icon": "server",
"sub_agents": []}))
for ca in co4e.list_custom_agents():
step = co4e.Step(label=ca.name, agent_slug=co4e.slugify(ca.name), role=ca.role or "AGENT",
icon=ca.icon, instructions=ca.instructions,
context=getattr(ca, "context", ""), model=ca.model,
permission_preset=ca.permission_preset, skills=list(ca.skills),
attachments=list(getattr(ca, "attachments", []) or []))
it = self._palette_item(f"{ca.name} · {ca.role} · {tr('co4e.custom')}", ca.icon or "robot",
co4e._step_dict(step))
it.setData(Qt.UserRole + 1, ca.id)
self.agent_list.addItem(it)
# Skills
self.skill_list.clear()
for name in _skill_names():
content = skills_mod.skill_prefix_for(name)
payload = co4e._step_dict(co4e.Step(
label=name, agent_slug=co4e.slugify(name), role="SKILL", icon="sparkle",
instructions=content, skills=[name]))
self.skill_list.addItem(self._palette_item(name, "sparkle", payload))
@staticmethod
def _palette_item(text: str, icon_name: str, payload: dict) -> QListWidgetItem:
it = QListWidgetItem(icon(icon_name), text)
it.setData(Qt.UserRole, payload)
return it
# ---- center -----------------------------------------------------------
def _build_center(self) -> QWidget:
from PySide6.QtWidgets import QStackedWidget, QTabBar
page = QWidget()
lay = QVBoxLayout(page)
# Flow tab bar: a pinned "Runs" tab first (manage every flow run), then a
# browser-style tab per open flow — each keeps its own graph (no mixing).
self.flow_bar = QTabBar()
self.flow_bar.setObjectName("flowTabs")
self.flow_bar.setTabsClosable(True)
self.flow_bar.setMovable(True)
self.flow_bar.setExpanding(False)
self.flow_bar.setDrawBase(False)
# No arrow scroll buttons — when the tabs overflow they scroll inside a
# frameless horizontal scroller you drag left/right (see flow_row below).
self.flow_bar.setUsesScrollButtons(False)
# Tab colours + layout live in theme.py (QTabBar#flowTabs — theme-aware,
# flush, centred). Here we only style the per-tab close (✕) button, which
# QTabBar places centred on the tab's right (see _add_tab_close_button).
_fp = current_palette()
self.flow_bar.setStyleSheet(
"QPushButton#flowTabClose {"
f" border: none; background: transparent; color: {_fp.text_muted};"
" font-size: 13px; font-weight: bold; padding: 0; margin: 0;"
f" border-radius: {_fp.radius_sm}px; }}"
"QPushButton#flowTabClose:hover {"
f" background: {_fp.danger_soft}; color: {_fp.danger}; }}")
runs_idx = self.flow_bar.addTab(icon("monitoring"), tr("co4e.runs_tab")) # 0 = Runs
self.flow_bar.setTabButton(runs_idx, QTabBar.RightSide, None) # pinned
self.flow_bar.currentChanged.connect(self._on_flow_tab_changed)
self.flow_bar.tabCloseRequested.connect(self._close_flow_tab)
# "+" new-flow button styled as the last tab in the strip (browser-style)
# — the + glyph sits inside a tab-shaped button flush with the tabs.
self.flow_add_btn = QPushButton("+")
self.flow_add_btn.setObjectName("flowAddBtn")
self.flow_add_btn.setFixedWidth(34)
self.flow_add_btn.setToolTip(tr("co4e.tt_new_wf"))
self.flow_add_btn.clicked.connect(self._new_workflow)
# Frameless horizontal scroller around the tab strip: overflowing tabs
# scroll (drag) left/right instead of being boxed with arrow buttons.
# The tab bar AND the "+" button are pinned to the SAME fixed height —
# giving the scroll area extra height for its scrollbar (as a previous
# version did) left the tabs top-anchored inside a taller box while the
# "+" button centered across that whole (taller) box, so the two drifted
# out of alignment. Same height on both = always aligned, no centering
# math needed; the scrollbar only appears on overflow (rare) and briefly
# overlaps the tab strip's bottom edge in that case.
_tab_h = self.flow_bar.sizeHint().height()
self.flow_bar.setFixedHeight(_tab_h)
self.flow_add_btn.setFixedHeight(_tab_h)
self.flow_scroll = QScrollArea()
self.flow_scroll.setObjectName("flowTabScroll")
self.flow_scroll.setWidget(self.flow_bar)
self.flow_scroll.setWidgetResizable(True)
self.flow_scroll.setFrameShape(QScrollArea.NoFrame) # no outer frame
self.flow_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.flow_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.flow_scroll.setFixedHeight(_tab_h)
self.flow_scroll.setStyleSheet(
"QScrollArea#flowTabScroll { background: transparent; border: none; }"
"QScrollArea#flowTabScroll QScrollBar:horizontal { height: 8px; background: transparent; margin: 0; }"
"QScrollArea#flowTabScroll QScrollBar::handle:horizontal {"
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; }")
# 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()
lay.addWidget(self.center_stack, 1)
self.center_stack.addWidget(self._build_runs_page()) # stack 0 = Runs
flow_page = QWidget()
lay = QVBoxLayout(flow_page)
lay.setContentsMargins(0, 0, 0, 0)
bar = QHBoxLayout(); bar.setSpacing(5)
self.name_edit = QLineEdit(self._wf.name)
self.name_edit.setToolTip(tr("co4e.tt_flow_name"))
self.name_edit.textChanged.connect(self._on_name_changed)
# "Add" is a labelled button (not a "+" icon) so it isn't mistaken for
# the zoom-in control, which now lives in the canvas's bottom-left overlay.
self.add_step_btn = QPushButton(tr("co4e.add")); self.add_step_btn.setIcon(icon("plus"))
self.add_step_btn.setToolTip(tr("co4e.tt_add_step"))
self.add_step_btn.clicked.connect(self._add_blank_step)
self.save_btn = QPushButton(tr("co4e.save")); self.save_btn.setIcon(icon("save"))
self.save_btn.setObjectName("primary")
self.save_btn.setToolTip(tr("co4e.tt_save"))
self.save_btn.clicked.connect(lambda: self._save(as_template=False))
self.save_tpl_btn = self._icon_btn("star", "co4e.tt_save_template",
lambda: self._save(as_template=True))
self.mode_combo = QComboBox()
self.mode_combo.setToolTip(tr("co4e.tt_mode"))
for m in co4e.RUN_MODES:
self.mode_combo.addItem(tr(f"co4e.mode.{m}"), m)
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
self.run_btn = QPushButton(tr("co4e.run")); self.run_btn.setIcon(icon("play"))
self.run_btn.setObjectName("primary")
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)
bar.addWidget(self.save_btn)
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()
self._build_canvas_overlay()
vsplit = QSplitter(Qt.Vertical)
vsplit.addWidget(self.canvas)
chat_widget = self._build_chat() # default-collapsed (see _build_chat)
vsplit.addWidget(chat_widget)
vsplit.setStretchFactor(0, 1)
self._vsplit = vsplit # so the message panel can collapse/expand
# Messages start collapsed — give the canvas the room from the start,
# not the [540, 220] split that assumed an expanded chat box.
collapsed_h = chat_widget.maximumHeight()
vsplit.setSizes([max(0, 760 - collapsed_h), collapsed_h])
lay.addWidget(vsplit, 1)
self.center_stack.addWidget(flow_page) # stack 1 = flow editor
self.center_stack.setCurrentIndex(1)
return page
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"))
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.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.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.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.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.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.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
def _wrap_config(self) -> QWidget:
"""Wrap the step-config panel with a header that has an expand/collapse
toggle, so it can be folded away to give the canvas more room."""
container = QWidget()
container.setObjectName("configContainer")
v = QVBoxLayout(container)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(0)
header = QWidget()
hb = QHBoxLayout(header)
hb.setContentsMargins(4, 3, 4, 3)
hb.setSpacing(4)
self.config_toggle_btn = QPushButton()
self.config_toggle_btn.setIcon(icon("chevron-right"))
self.config_toggle_btn.setToolTip(tr("co4e.tt_collapse_config"))
self.config_toggle_btn.setFixedSize(26, 24)
self.config_toggle_btn.clicked.connect(self._toggle_config)
self.config_title = QLabel(tr("co4e.config_title"))
self.config_title.setObjectName("hint")
hb.addWidget(self.config_toggle_btn)
hb.addWidget(self.config_title, 1)
v.addWidget(header)
v.addWidget(self.config, 1)
self._cfg_vlayout = v
# Spacers used ONLY while collapsed, to keep the lone toggle icon
# vertically CENTERED in the thin strip (its position no longer jumps to
# the top after collapsing).
self._cfg_top_spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
self._cfg_bot_spacer = QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding)
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: # noqa: D401
"""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
if self._config_collapsed:
w = self.config_container.width()
if w > 60:
self._config_expanded_w = w
self.config.hide()
self.config_title.hide()
self.config_container.setMaximumWidth(34)
self.config_toggle_btn.setIcon(icon("chevron-left"))
self.config_toggle_btn.setToolTip(tr("co4e.tt_expand_config"))
# center the toggle vertically in the collapsed strip
v.insertItem(0, self._cfg_top_spacer)
v.addItem(self._cfg_bot_spacer)
# A maximumWidth alone doesn't make the splitter hand the freed width
# to the canvas — set sizes explicitly so the panel folds to the right.
sizes = self._split.sizes()
if len(sizes) == 3:
freed = sizes[2] - 34
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)
self.config_container.setMaximumWidth(16777215)
self.config.show()
self.config_title.show()
self.config_toggle_btn.setIcon(icon("chevron-right"))
self.config_toggle_btn.setToolTip(tr("co4e.tt_collapse_config"))
sizes = self._split.sizes()
if len(sizes) == 3:
want = self._config_expanded_w
delta = want - sizes[2]
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
bottom-left, stacked vertically. The frame is transparent (so it follows
the dark/light theme — only the buttons carry a themed background) and the
buttons are half-size."""
from PySide6.QtCore import QSize
bar = QFrame()
bar.setObjectName("canvasOverlay")
bar.setStyleSheet("QFrame#canvasOverlay { background: transparent; border: none; }")
v = QVBoxLayout(bar)
v.setContentsMargins(2, 2, 2, 2)
v.setSpacing(3)
self.zoom_in_btn = self._icon_btn("plus", "co4e.tt_zoom_in", lambda: self.canvas.zoom_in())
self.zoom_out_btn = self._icon_btn("minus", "co4e.tt_zoom_out", lambda: self.canvas.zoom_out())
self.fit_btn = self._icon_btn("search", "co4e.fit_tooltip", lambda: self.canvas.fit_view())
for b in (self.zoom_in_btn, self.zoom_out_btn, self.fit_btn):
b.setFixedSize(16, 16) # ~half the previous size
b.setIconSize(QSize(11, 11))
b.setStyleSheet("QPushButton { padding: 0px; }") # keep themed bg, drop padding
v.addWidget(b)
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)
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._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.submit.connect(self._chat_send)
self.chat_send_btn = QPushButton(tr("co4e.send")); self.chat_send_btn.setIcon(icon("send"))
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_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
def _toggle_messages(self) -> None:
"""Show/hide the WHOLE chat box (message list + composer) below the
header. Collapsing hands the freed height to the canvas.
A QSplitter's ``setMaximumHeight`` on one side does NOT automatically
redistribute the freed space to the other side — it just shrinks the
splitter's own total height, leaving the canvas frozen at its old size
and blank space below it. So this explicitly calls ``setSizes`` on both
the collapse AND the expand path, computed from the splitter's CURRENT
total (not a hardcoded guess) — that total stays constant; only how
it's split between canvas/chat changes."""
self._msgs_collapsed = not self._msgs_collapsed
collapsed_h = self._mhdr.sizeHint().height() + 6
if self._msgs_collapsed:
if hasattr(self, "_vsplit"):
self._vsplit_sizes = self._vsplit.sizes() # remember to restore
self.chat_stack.hide()
self.chat_input_row.hide()
self._chat_widget.setMaximumHeight(collapsed_h)
self.chat_toggle_btn.setIcon(icon("chevron-up")) # collapsed → click to expand
self.chat_toggle_btn.setToolTip(tr("co4e.tt_expand_msgs"))
if hasattr(self, "_vsplit"):
total = sum(self._vsplit.sizes()) or (self._vsplit_sizes and sum(self._vsplit_sizes)) or 760
self._vsplit.setSizes([max(0, total - collapsed_h), collapsed_h])
else:
self._chat_widget.setMaximumHeight(16777215)
self.chat_stack.show()
self.chat_input_row.show()
self.chat_toggle_btn.setIcon(icon("chevron-down")) # expanded → click to collapse
self.chat_toggle_btn.setToolTip(tr("co4e.tt_collapse_msgs"))
if getattr(self, "_vsplit_sizes", None) and hasattr(self, "_vsplit"):
self._vsplit.setSizes(self._vsplit_sizes)
return
# ---- per-flow chat logs (each flow = its own conversation) ------------
def _ensure_flow_log(self, wf_id: str) -> ChatView:
"""The ChatView for a flow, created + added to the stack on first use so
each flow tab keeps a SEPARATE conversation."""
log = self._flow_logs.get(wf_id)
if log is None:
log = ChatView()
log._co4e_plan_bubble = None # per-flow 'current plan' bubble
self._flow_logs[wf_id] = log
self.chat_stack.addWidget(log)
return log
def _active_log(self) -> ChatView:
wf = getattr(self, "_wf", None)
return self._ensure_flow_log(wf.id if wf is not None else "__none__")
@property
def chat_log(self) -> ChatView:
"""The conversation of the CURRENTLY-shown flow (all append/stream calls
go here). Assignment is not supported — logs are per-flow now."""
return self._active_log()
@property
def _plan_bubble(self):
return getattr(self._active_log(), "_co4e_plan_bubble", None)
@_plan_bubble.setter
def _plan_bubble(self, value) -> None:
self._active_log()._co4e_plan_bubble = value
# ---- workflow load/save ----------------------------------------------
def _apply_workflow(self, wf: co4e.Workflow) -> None:
self._wf = wf
# Per-flow outputs are kept in self._flow_outputs[wf.id] — do NOT clear
# here (switching tabs must not wipe another flow's accumulated context).
# Switch the visible conversation to THIS flow's own log.
self.chat_stack.setCurrentWidget(self._ensure_flow_log(wf.id))
self.name_edit.setText(wf.name)
self.canvas.load(wf.nodes, wf.edges)
self.config.clear_step()
if wf.nodes:
self.canvas.relayout_if_vertical() # convert old top-down flows to left→right
self.canvas.fit_view()
self._update_run_btn() # reflect THIS flow's run state
self._refresh_usage_total() # show THIS flow's token/cost total
def _new_workflow(self) -> None:
self._open_flow(co4e.new_workflow(tr("co4e.untitled")))
# Pressing this while the canvas already holds an empty untitled flow
# produced an identical empty untitled flow — correct, and completely
# invisible, so the button read as broken. Say what happened and put the
# cursor where the next thing to do is: naming it.
self.name_edit.setFocus()
self.name_edit.selectAll()
self.status_message.emit(tr("co4e.new_flow_ready"))
def _selected_wf(self) -> Optional[co4e.Workflow]:
"""Materialise the selected saved-flow row into a Workflow."""
item = self.wf_list.currentItem()
if item is None:
return None
_kind, ident = item.data(Qt.UserRole)
return co4e.get_workflow(ident)
def _load_selected_workflow(self, *_a) -> None:
wf = self._selected_wf()
if wf is not None:
self._open_flow(wf) # open (or focus) its browser-style tab
def _edit_selected_workflow(self) -> None:
wf = self._selected_wf()
if wf is None:
self.status_message.emit(tr("co4e.select_flow"))
return
self._open_flow(wf)
def _duplicate_selected_workflow(self) -> None:
wf = self._selected_wf()
if wf is None:
self.status_message.emit(tr("co4e.select_flow"))
return
dup = co4e.duplicate_workflow(wf)
self._reload_sidebar()
self.status_message.emit(tr("co4e.duplicated_msg", name=dup.name))
def _wf_context_menu(self, pos) -> None:
lw = self.wf_list
item = lw.itemAt(pos)
if item is None:
return
lw.setCurrentItem(item)
_kind, ident = item.data(Qt.UserRole)
menu = QMenu(lw)
act_edit = menu.addAction(icon("edit"), tr("co4e.edit"))
act_rename = menu.addAction(icon("edit"), tr("co4e.rename"))
act_dup = menu.addAction(icon("branch"), tr("co4e.duplicate"))
act_run = menu.addAction(icon("play"), tr("co4e.run_bg"))
act_del = menu.addAction(icon("trash"), tr("co4e.delete"))
chosen = menu.exec(lw.viewport().mapToGlobal(pos))
if chosen is act_edit:
self._edit_selected_workflow()
elif chosen is act_rename:
self._rename_workflow(ident)
elif chosen is act_dup:
self._duplicate_selected_workflow()
elif chosen is act_run:
self._run_selected_in_background()
elif chosen is act_del:
self._delete_selected_workflow()
def _rename_workflow(self, ident: str) -> None:
"""Rename a saved flow in place (e.g. to match its function/task)."""
wf = co4e.get_workflow(ident)
if wf is None:
return
name, ok = QInputDialog.getText(self, tr("co4e.rename"), tr("co4e.rename_prompt"),
text=wf.name)
name = (name or "").strip()
if not ok or not name:
return
wf.name = name
co4e.save_workflow(wf)
if self._wf.id == ident:
self.name_edit.setText(name)
self._wf.name = name
self._reload_sidebar()
self.status_message.emit(tr("co4e.renamed_msg", name=name))
def _delete_selected_workflow(self) -> None:
item = self.wf_list.currentItem()
if item is None:
return
_kind, ident = item.data(Qt.UserRole)
co4e.delete_workflow(ident)
self._reload_sidebar()
def _sync_wf_from_canvas(self) -> None:
self._wf.nodes = self.canvas.nodes()
self._wf.edges = self.canvas.edges()
self._wf.name = self.name_edit.text().strip() or tr("co4e.untitled")
def _save(self, as_template: bool) -> None:
self._sync_wf_from_canvas()
self._wf.is_template = as_template
co4e.save_workflow(self._wf)
self._reload_sidebar()
self.status_message.emit(tr("co4e.saved_msg", name=self._wf.name))
def _autosave(self) -> None:
if co4e.get_workflow(self._wf.id) is not None:
self._sync_wf_from_canvas()
co4e.save_workflow(self._wf)
def _on_name_changed(self, text: str) -> None:
self._wf.name = text.strip() or tr("co4e.untitled")
self._sync_active_flow_tab_text()
def _add_blank_step(self) -> None:
self.canvas.add_palette_step(co4e.Step(label="New Step"),
self.canvas.mapToScene(self.canvas.rect().center()))
# ---- node selection / config -----------------------------------------
def _on_node_selected(self, node_id: str) -> None:
for n in self.canvas.nodes():
if n.id == node_id:
self.config.load_step(node_id, n.data, _skill_names())
if self._config_collapsed:
self._toggle_config()
return
def _on_config_changed(self) -> None:
for n in self.canvas.nodes():
self.canvas.refresh_node(n.id)
self._autosave()
# ---- custom agents ----------------------------------------------------
def _new_agent(self) -> None:
self._edit_agent_dialog(co4e.new_custom_agent(""))
def _edit_agent(self) -> None:
item = self.agent_list.currentItem()
cid = item.data(Qt.UserRole + 1) if item else None
if not cid:
self.status_message.emit(tr("co4e.select_custom_agent"))
return
agent = next((a for a in co4e.list_custom_agents() if a.id == cid), None)
if agent is not None:
self._edit_agent_dialog(agent)
def _edit_agent_dialog(self, agent: co4e.CustomAgent) -> None:
from .co4e_agent_dialog import Co4EAgentDialog
dlg = Co4EAgentDialog(self.ctx, agent, _skill_names(), self)
if dlg.exec():
co4e.save_custom_agent(dlg.result_agent())
self._reload_sidebar()
def _delete_agent(self) -> None:
item = self.agent_list.currentItem()
cid = item.data(Qt.UserRole + 1) if item else None
if not cid:
self.status_message.emit(tr("co4e.select_custom_agent"))
return
co4e.delete_custom_agent(cid)
self._reload_sidebar()
def _manage_skills(self) -> None:
from .skills_dialog import SkillsDialog
SkillsDialog(self, self.ctx).exec()
self._reload_sidebar()
# ---- running ----------------------------------------------------------
def _skill_map(self) -> Dict[str, str]:
out = {}
for name in _skill_names():
block = skills_mod.skill_prefix_for(name)
if block:
out[name] = block.split("\n", 1)[1] if "\n" in block else block
return out
def _current_mode(self) -> str:
return self.mode_combo.currentData() or "auto"
def _on_mode_changed(self, *_a) -> None:
# switching mode resets any in-progress manual sequence
self._manual_active = False
self._manual_order = []
self._manual_idx = 0
if self._cur_run_id() is None:
self.run_btn.setText(tr("co4e.run"))
def _on_run_clicked(self) -> None:
# THIS flow's run is active → interrupt it (other flows keep running).
cur = self._cur_run_id()
if cur is not None:
self.manager.stop(cur)
return
mode = self._current_mode()
if mode == "manual":
self._manual_run_or_advance()
else:
self._start_canvas_run(plan_mode=(mode == "plan"))
def _start_canvas_run(self, *, plan_mode: bool, only: Optional[set] = None,
seed: Optional[Dict[str, str]] = None) -> None:
self._sync_wf_from_canvas()
if not self._wf.nodes:
self.status_message.emit(tr("co4e.no_steps"))
return
wf_id = self._wf.id
if only is None:
self.canvas.reset_statuses()
self._outputs_for(wf_id).clear()
self._plan_bubble = None
self._append_chat("system", tr("co4e.run_started", name=self._wf.name))
run_id = self.manager.start(
self._wf, skill_map=self._skill_map(), plan_mode=plan_mode,
only_nodes=only, seed_outputs=seed or dict(self._outputs_for(wf_id)))
self._flow_runs[wf_id] = run_id # track THIS flow's run (parallel-safe)
self._run_logs[run_id] = self.chat_log # route its events to THIS flow's log
self.run_btn.setText(tr("co4e.interrupt"))
def _run_single(self, node_id: str) -> None:
"""Run one step (config panel "Run this step") with upstream context."""
if self._cur_run_id() is not None:
return
self._start_canvas_run(plan_mode=(self._current_mode() == "plan"),
only={node_id}, seed=dict(self._outputs_for(self._wf.id)))
def _run_from(self, node_id: str) -> None:
if self._cur_run_id() is not None:
return
self._start_canvas_run(plan_mode=(self._current_mode() == "plan"),
only=self._downstream(node_id), seed=dict(self._outputs_for(self._wf.id)))
def _downstream(self, node_id: str) -> set:
adj: Dict[str, List[str]] = {}
for e in self.canvas.edges():
adj.setdefault(e.source, []).append(e.target)
seen, stack = set(), [node_id]
while stack:
cur = stack.pop()
if cur in seen:
continue
seen.add(cur)
stack.extend(adj.get(cur, []))
return seen
# ---- manual mode (step-by-step) --------------------------------------
def _manual_run_or_advance(self) -> None:
if not self._manual_active:
self._sync_wf_from_canvas()
if not self._wf.nodes:
self.status_message.emit(tr("co4e.no_steps"))
return
self.canvas.reset_statuses()
self._outputs_for(self._wf.id).clear()
self._plan_bubble = None
self._manual_order = self._topo_order()
self._manual_idx = 0
self._manual_active = True
self._append_chat("system", tr("co4e.manual_started", name=self._wf.name))
self._manual_step()
def _manual_step(self) -> None:
if self._manual_idx >= len(self._manual_order):
self._manual_active = False
self.run_btn.setText(tr("co4e.run"))
self._append_chat("system", tr("co4e.run_done"))
return
nid = self._manual_order[self._manual_idx]
label = next((n.data.label for n in self.canvas.nodes() if n.id == nid), nid)
self._append_chat("system", tr("co4e.manual_step",
i=self._manual_idx + 1, n=len(self._manual_order), label=label))
run_id = self.manager.start(
self._wf, skill_map=self._skill_map(),
plan_mode=False, only_nodes={nid}, seed_outputs=dict(self._outputs_for(self._wf.id)),
manual=True)
self._flow_runs[self._wf.id] = run_id
self._run_logs[run_id] = self.chat_log
self.run_btn.setText(tr("co4e.interrupt"))
def _topo_order(self) -> List[str]:
nodes = self.canvas.nodes()
edges = self.canvas.edges()
waves = co4e.compute_waves(nodes, edges)
y = {n.id: n.y for n in nodes}
return sorted((n.id for n in nodes), key=lambda nid: (waves.get(nid, 0), y.get(nid, 0)))
# ---- run-manager events ----------------------------------------------
def _on_manager_event(self, run_id: str, ev: dict) -> None:
# Per-flow routing: every run's events go to ITS OWN flow log (so parallel
# runs never mix), and the canvas mirrors ONLY the run whose flow is the
# one currently shown. Flow Status refreshes on its own via `changed`.
h = self.manager.get(run_id)
run_wf = h.wf_id if h is not None else None
log = self._run_logs.get(run_id) or self.chat_log
shown = getattr(self, "_wf", None) is not None and run_wf == self._wf.id
t = ev.get("type")
if t == "node_status":
if shown:
self.canvas.update_node_status(ev.get("node_id"), ev.get("status"))
elif t == "node_output":
if run_wf is not None:
self._outputs_for(run_wf)[ev["node_id"]] = ev.get("output", "")
label = ev["node_id"]
if shown:
label = next((n.data.label for n in self.canvas.nodes() if n.id == ev["node_id"]),
ev["node_id"])
elif h is not None and h.wf is not None:
label = next((n.data.label for n in h.wf.nodes if n.id == ev["node_id"]), ev["node_id"])
if ev.get("output"):
bub = self._append_chat("assistant", f"**{label}**\n\n{ev['output']}", log=log)
# Per-message token/cost footer (↓in ↑out ▤ctx $cost), like Cowork.
self._apply_usage(bub, run_wf, ev.get("usage"))
elif t == "node_diff":
self._append_diff(ev.get("title", ""), ev.get("diff", ""), log=log)
elif t == "node_plan":
self._append_plan(ev.get("steps") or [], log=log)
elif t == "node_tool":
if not ev.get("ok", True):
# A single failed tool call isn't a step failure — the agent is told
# to recover and continue, so show it as a neutral notice (not a red
# "Error" that reads like the whole flow crashed).
self._append_chat("system", "⚠ " + tr("co4e.tool_failed", name=ev.get("name", "")), log=log)
elif t in ("run_done", "run_error"):
# Drop THIS flow's run tracking (other flows keep running in parallel).
if run_wf is not None and self._flow_runs.get(run_wf) == run_id:
self._flow_runs.pop(run_wf, None)
self._run_logs.pop(run_id, None)
if self._manual_active and shown:
self._manual_idx += 1
self._manual_step()
else:
if shown:
self.run_btn.setText(tr("co4e.run"))
self._append_chat("system", tr("co4e.run_done"), log=log)
# Clickable link to the output folder so files are one click away.
out = (h.out_dir if h is not None and h.out_dir else "") or str(self._flow_output_root())
try:
log.add_folder_link(out, tr("co4e.open_output_link"))
log.scroll_to_bottom()
except Exception: # noqa: BLE001 - link is a nicety, never fatal
pass
self._notify_run_finished(run_id) # popup: the flow finished
if not shown and h is not None:
self.status_message.emit(tr("co4e.bg_done", name=h.name, status=h.status))
def _notify_run_finished(self, run_id: str) -> None:
"""Show a non-blocking popup when a flow finishes (done / error / stopped),
so the user is notified even if they're on another screen."""
h = self.manager.get(run_id)
if h is None:
return
from PySide6.QtWidgets import QMessageBox
if not hasattr(self, "_run_popups"):
self._run_popups = []
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning if h.status == "error" else QMessageBox.Information)
box.setWindowTitle(tr("co4e.run_done_title"))
box.setText(tr("co4e.run_done_popup", name=h.name,
status=tr("co4e.status." + h.status)))
box.setStandardButtons(QMessageBox.Ok)
box.setModal(False) # non-blocking notification
box.setAttribute(Qt.WA_DeleteOnClose, True)
box.finished.connect(
lambda _r=0, b=box: self._run_popups.remove(b) if b in self._run_popups else None)
self._run_popups.append(box) # keep a ref so it isn't GC'd
box.show()
def _refresh_runs(self) -> None:
# Rebuild the always-fresh Runs table from the manager (single source of truth).
if not hasattr(self, "runs_table"):
return
p = current_palette()
color = {"running": p.accent, "done": p.success, "error": p.danger,
"stopped": p.text_muted}
dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
# Most-recent run at the TOP, oldest at the bottom (manager keeps runs in
# chronological insertion order, so reverse it for display).
runs = list(reversed(self.manager.runs()))
t = self.runs_table
# Preserve the selected run across the rebuild by its id (row indices shift
# as runs are added/deleted, so a row-index restore would jump).
sel_item = t.item(t.currentRow(), 0) if t.currentRow() >= 0 else None
sel_id = sel_item.data(Qt.UserRole) if sel_item is not None else None
t.setRowCount(len(runs))
sel_row = -1
for r, h in enumerate(runs):
vals = [f"{dots.get(h.status, '•')} {h.name}", tr("co4e.status." + h.status),
h.progress_text(), h.created_by or "-", h.created_at or "-"]
for c, val in enumerate(vals):
it = QTableWidgetItem(str(val))
if c == 0:
it.setData(Qt.UserRole, h.id)
if c == 1:
it.setForeground(_qcolor(color.get(h.status, p.text)))
t.setItem(r, c, it)
if h.id == sel_id:
sel_row = r
if sel_row >= 0:
t.setCurrentCell(sel_row, 0)
# 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"):
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()
it = self.runs_table.item(row, 0) if row >= 0 else None
if it is None:
self.manager.stop_all()
return
self.manager.stop(it.data(Qt.UserRole))
def _delete_selected_run(self) -> None:
"""Delete the selected run from the Flow Status history (a running one is
stopped first). Removes just that single entry."""
row = self.runs_table.currentRow()
it = self.runs_table.item(row, 0) if row >= 0 else None
if it is None:
self.status_message.emit(tr("co4e.select_run"))
return
run_id = it.data(Qt.UserRole)
h = self.manager.get(run_id) # stop tracking it per-flow if we were
if h is not None and self._flow_runs.get(h.wf_id) == run_id:
self._flow_runs.pop(h.wf_id, None)
self._run_logs.pop(run_id, None)
self.manager.remove(run_id) # emits `changed` → _refresh_runs
def _runs_context_menu(self, pos) -> None:
from PySide6.QtWidgets import QMenu
item = self.runs_table.itemAt(pos)
if item is None:
return
self.runs_table.selectRow(item.row())
menu = QMenu(self)
menu.addAction(tr("co4e.open_run"),
lambda: self._open_run_from_table(self.runs_table.item(item.row(), 0)))
it0 = self.runs_table.item(item.row(), 0)
rid = it0.data(Qt.UserRole) if it0 is not None else None
menu.addAction(tr("co4e.open_output"), lambda: self._open_run_output_folder(rid))
menu.addAction(tr("co4e.rename_run"), self._rename_selected_run)
menu.addAction(tr("co4e.delete_run"), self._delete_selected_run)
menu.exec(self.runs_table.viewport().mapToGlobal(pos))
# ---- 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 _open_run_output_folder(self, run_id) -> None:
"""Open the workspace folder a specific run wrote its files into."""
from .osutil import open_location
h = self.manager.get(run_id) if run_id else None
path = Path(h.out_dir) if (h is not None and h.out_dir) else self._flow_output_root()
if not path.exists():
path = self._flow_output_root()
try:
path.mkdir(parents=True, exist_ok=True)
except OSError:
pass
open_location(str(path))
def _rename_selected_run(self) -> None:
"""Rename the selected run in Flow Status — updates the run entry AND its
underlying saved flow / open tab so the name stays consistent everywhere."""
row = self.runs_table.currentRow()
it = self.runs_table.item(row, 0) if row >= 0 else None
if it is None:
self.status_message.emit(tr("co4e.select_run"))
return
run_id = it.data(Qt.UserRole)
h = self.manager.get(run_id)
if h is None:
return
from PySide6.QtWidgets import QInputDialog
new, ok = QInputDialog.getText(self, tr("co4e.rename_run"),
tr("co4e.rename_run_label"), text=h.name)
new = (new or "").strip()
if not ok or not new or new == h.name:
return
self.manager.rename(run_id, new) # run entry + snapshot (→ refresh)
# Keep the underlying saved flow + any open tab in sync.
wf = co4e.get_workflow(h.wf_id)
if wf is not None:
wf.name = new
co4e.save_workflow(wf)
self._reload_sidebar()
for i, f in enumerate(self._flows):
if f.id == h.wf_id:
f.name = new
self.flow_bar.setTabText(i + 1, new)
break
if self._wf.id == h.wf_id and self.name_edit.text() != new:
self.name_edit.setText(new) # updates _wf.name + active tab text
def _run_selected_in_background(self) -> None:
wf = self._selected_wf()
if wf is None:
self.status_message.emit(tr("co4e.select_flow"))
return
self.manager.start(wf, skill_map=self._skill_map(),
plan_mode=(self._current_mode() == "plan"))
# 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]:
"""Resolve a flow id to a Workflow — saved, or the open canvas."""
wf = co4e.get_workflow(wf_id)
if wf is not None:
return wf
if self._wf.id == wf_id:
self._sync_wf_from_canvas()
return self._wf
return None
def _rerun_run_item(self, item) -> None:
"""Double-click a run in the history → run that flow again (in background)."""
h = self.manager.get(item.data(Qt.UserRole))
if h is None:
return
wf = self._wf_by_id(h.wf_id)
if wf is None:
self.status_message.emit(tr("co4e.flow_gone"))
return
self.manager.start(wf, skill_map=self._skill_map(),
plan_mode=(self._current_mode() == "plan"))
self.status_message.emit(tr("co4e.bg_started", name=wf.name))
def _open_run_from_table(self, item) -> None:
"""Double-click a run row in the Runs tab → open that flow's tab and show
its live status (opens/focuses the tab; _open_flow reflects the run)."""
id_item = self.runs_table.item(item.row(), 0)
if id_item is None:
return
h = self.manager.get(id_item.data(Qt.UserRole))
if h is None:
return
# Prefer the flow the run kept a reference to (works even after its tab was
# closed or if it was never saved); fall back to resolving by id.
wf = getattr(h, "wf", None) or self._wf_by_id(h.wf_id)
if wf is None:
self.status_message.emit(tr("co4e.flow_gone"))
return
self._open_flow(wf)
# reflect this run's step statuses (done/error/running) on the canvas
for nid, st in h.node_status.items():
self.canvas.update_node_status(nid, st)
self.status_message.emit(tr("co4e.viewing_flow", name=wf.name))
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) -----------------------------
def _chat_send(self) -> None:
text = self.chat_input.text().strip()
if not text or self._chat_worker is not None:
return
self.chat_input.clear()
self._append_chat("user", text)
skill_prefix, request, info = skills_mod.parse_skill_command(text)
if info is not None:
self._append_chat("system", info)
return
system_parts = []
if skill_prefix:
system_parts.append(skill_prefix)
agent_name, request = self._extract_agent_directive(request)
model = ""
if agent_name:
persona = self._resolve_agent(agent_name)
if persona is None:
self._append_chat("system", tr("co4e.agent_not_found", name=agent_name))
return
system_parts.append(persona[0])
model = persona[1]
# Auto Model Routing — only when the user hasn't pinned an agent's own
# model (an explicit pin wins). May switch provider+model for this turn.
if not model:
model = self._apply_co4e_routing(request)
self._run_chat_turn(system_parts, request, model)
def _apply_co4e_routing(self, request: str) -> str:
"""Route this Co4E turn to the best-fit model. Returns the model id to
use ('' → provider default) and sets ``self._co4e_routed_provider`` when
a cross-provider switch is chosen. Off → no-op. Manual → confirm first.
Never raises — falls back to the default model on any error."""
self._co4e_routed_provider = None
if not (request or "").strip():
return ""
try:
mode = self.ctx.project_routing_mode("co4e") # per-workspace mode
if mode == "off":
return ""
service = self.ctx.routing()
cur_provider = self.ctx.config.active_provider
cur_model = self.ctx.config.provider_conf(cur_provider).get("model", "")
result = service.route("co4e", request, cur_provider, cur_model, mode_override=mode)
if not result.should_switch:
return ""
target = result.target()
if target is None:
return ""
to_provider, to_model = target
if mode == "manual":
from .routing_toggle import confirm_switch
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
if not confirm_switch(self, result.decision, timeout):
return ""
self._co4e_routed_provider = to_provider
self._append_chat("system", tr(
"routing.switched_notice",
model=to_model, task=result.task_type.value,
gain=f"{result.decision.score_gain:.2f}"))
return to_model
except Exception: # noqa: BLE001 — routing must never block a Co4E turn
self._co4e_routed_provider = None
return ""
def _extract_agent_directive(self, text: str):
m = re.search(r"(?<!\S)/agent:([\w\-.]+)", text)
if not m:
return "", text
name = m.group(1)
rest = (text[:m.start()] + " " + text[m.end():]).strip()
return name, rest
def _resolve_agent(self, name: str):
low = name.lower()
for a in BUILTIN_AGENTS:
if a.slug == low or a.name.lower() == low:
return (f"You are the {a.role} agent — {a.name}.\n{a.instructions}", "")
for ca in co4e.list_custom_agents():
if co4e.slugify(ca.name) == low or ca.name.lower() == low:
return (f"You are the {ca.role} agent — {ca.name}.\n{ca.instructions}", ca.model)
return None
def _run_chat_turn(self, system_parts: List[str], request: str, model: str) -> None:
self.chat_send_btn.setEnabled(False)
log = self.chat_log # THIS flow's conversation (captured)
log._co4e_plan_bubble = None # a fresh plan for this turn
ctx = self.ctx
out_dir = self._out_dir()
sys_text = "\n\n".join(p for p in system_parts if p)
prompt = f"{sys_text}\n\n{request}" if sys_text else request
assistant = log.add_assistant() # stream into this live bubble
state = {"text": ""}
wf = getattr(self, "_wf", None)
wf_id = wf.id if wf is not None else None
flow_label = wf.name if wf is not None else "flow"
def job(worker: AgentWorker):
from ..core import agent_roles, usage_tracker as ut
from ..core.chat_agent import run_cowork
from ..core.co4e_runner import _usage_delta
# An Auto/Manual routing switch may target a different provider.
provider = ctx.build_provider_for(getattr(self, "_co4e_routed_provider", None), model or None)
messages = [{"role": "user", "content": prompt}]
ut.set_context("co4e", flow_label) # attribute + measure this turn's usage
ut.begin_accumulation()
base = ut.accumulated()
def _emit(ev):
if not isinstance(ev, dict):
return
t = ev.get("type")
if t == "text":
worker.emit_event({"type": "text", "delta": ev.get("delta", "")})
elif t == "plan_set":
worker.emit_event({"type": "plan_set", "steps": ev.get("steps") or []})
try:
run_cowork(provider, messages, out_dir, _emit, worker.is_cancelled,
security_config=ctx.config, agent_role=agent_roles.COWORK,
run_to_completion=True, enforce_rules=False)
usage = _usage_delta(base, ctx.config)
finally:
ut.end_accumulation()
for m in reversed(messages):
if m.get("role") == "assistant" and m.get("content"):
return {"text": str(m["content"]), "usage": usage}
return {"text": "", "usage": usage}
def on_event(ev):
if ev.get("type") == "text":
state["text"] += ev.get("delta", "")
assistant.set_markdown(state["text"])
log.scroll_to_bottom()
elif ev.get("type") == "plan_set":
self._append_plan(ev.get("steps") or [], log=log)
def done(result: dict):
self._chat_worker = None
self.chat_send_btn.setEnabled(True)
final = result.get("text") or state["text"]
assistant.set_markdown(final or "(no output)")
self._apply_usage(assistant, wf_id, result.get("usage"))
log.scroll_to_bottom()
def failed(err: str):
self._chat_worker = None
self.chat_send_btn.setEnabled(True)
self._append_chat("error", f"[error: {err}]", log=log)
w = AgentWorker(job)
w.event.connect(on_event)
w.finished_ok.connect(done)
w.failed.connect(failed)
self._chat_worker = w
w.start()
def _append_chat(self, role: str, text: str, log: "ChatView" = None) -> None:
"""Add one message bubble to a flow's conversation. ``log`` defaults to the
active flow's log; a run/stream passes its OWN captured log so events land
in the right flow even if the user switches tabs mid-run."""
log = log or self.chat_log
if role == "user":
bub = log.add_user(text)
elif role == "assistant":
bub = log.add_assistant()
bub.set_markdown(text)
elif role == "error":
bub = log.add_error(text)
else: # system status marker
bub = log.add_status(text)
log.scroll_to_bottom()
return bub
# ---- token / cost accounting (shown per-message + as a flow total) ------
def _fmt_usage(self, d_in: int, d_out: int, d_cache: int, cost_usd: float) -> str:
"""The Cowork-style footer string: ↓in ↑out ▤(in+out+cache) $cost, priced
with the Monitoring model-price table in the app's display currency."""
from ..core import model_pricing as mp, usage_tracker as ut
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
return (f"↓{mp.format_tokens(d_in)} ↑{mp.format_tokens(d_out)} "
f"▤{mp.format_tokens(d_in + d_out + d_cache)} "
f"{ut.format_cost(cost_usd, pricing)}")
def _apply_usage(self, bub, wf_id, usage) -> None:
"""Attach a token/cost footer to a step's bubble and add it to the flow's
running total (mirrors Cowork's per-message + conversation-total display)."""
if not isinstance(usage, dict):
return
d_in = int(usage.get("in", 0) or 0)
d_out = int(usage.get("out", 0) or 0)
d_cache = int(usage.get("cache", 0) or 0)
cost = float(usage.get("cost_usd", 0.0) or 0.0)
if bub is not None and (d_in or d_out):
try:
bub.add_usage(self._fmt_usage(d_in, d_out, d_cache, cost))
except Exception: # noqa: BLE001 - a usage footer must never break the run
pass
if wf_id is not None:
tot = self._flow_usage.setdefault(wf_id, {"in": 0, "out": 0, "cache": 0, "cost": 0.0})
tot["in"] += d_in; tot["out"] += d_out; tot["cache"] += d_cache; tot["cost"] += cost
self._refresh_usage_total(wf_id)
def _refresh_usage_total(self, only_wf: str = None) -> None:
"""Update the bottom conversation total to the CURRENT flow's running
usage (skip if the event is for a different, background flow)."""
lbl = getattr(self, "_usage_total_lbl", None)
if lbl is None:
return
wf = getattr(self, "_wf", None)
wf_id = wf.id if wf is not None else None
if only_wf is not None and only_wf != wf_id:
return
tot = self._flow_usage.get(wf_id) if wf_id else None
if not tot or not (tot["in"] or tot["out"]):
lbl.setText("")
return
lbl.setText(self._fmt_usage(int(tot["in"]), int(tot["out"]),
int(tot["cache"]), float(tot["cost"])))
def _append_diff(self, title: str, diff: str, log: "ChatView" = None) -> None:
"""Render a before/after diff as a collapsible colored diff bubble."""
log = log or self.chat_log
log.add_diff(f"▤ {title}", diff)
log.scroll_to_bottom()
def _append_plan(self, steps, log: "ChatView" = None) -> None:
"""Show the plan INLINE in the conversation as an expandable block; update
the same (per-flow) bubble in place so steps tick off (✓) as they complete."""
log = log or self.chat_log
body = _fmt_plan(steps)
if not body:
return
if getattr(log, "_co4e_plan_bubble", None) is None:
log._co4e_plan_bubble = log.add_plan(body)
else:
log._co4e_plan_bubble.set_plain(body)
log.scroll_to_bottom()
# ---- 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)