"""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:`` picks a persona and ``/skill:`` 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 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 ------------------------------------------------------ # Widget cua khu vuc nay da doi sang AgentListPanel (xem # presentation/co4e/agent_list_panel.py); o day chi con giu # ag_new_btn/agent_list/ag_edit_btn/ag_del_btn nhu 4 ten thuoc tinh cu # va tu noi signal - dung nguyen tac panel khong tu wire, Co4ETab moi # biet _new_agent/_edit_agent/_delete_agent. self._agent_panel = AgentListPanel() self.ag_new_btn = self._agent_panel.new_btn self.ag_new_btn.clicked.connect(self._new_agent) self.agent_list = self._agent_panel.list_widget self.ag_edit_btn = self._agent_panel.edit_btn self.ag_edit_btn.clicked.connect(self._edit_agent) self.ag_del_btn = self._agent_panel.del_btn self.ag_del_btn.clicked.connect(self._delete_agent) col.addWidget(self._section("co4e.tab_agents", self._agent_panel, self.ag_new_btn), 3) # --- SKILLS ------------------------------------------------------ # Widget cua khu vuc nay da doi sang SkillsListPanel (xem # presentation/co4e/skills_list_panel.py); o day chi con giu # sk_manage_btn/skill_list nhu 2 ten thuoc tinh cu va tu noi signal - # dung nguyen tac panel khong tu wire, Co4ETab moi biet _manage_skills. self._skills_panel = SkillsListPanel() self.sk_manage_btn = self._skills_panel.manage_btn self.sk_manage_btn.clicked.connect(self._manage_skills) self.skill_list = self._skills_panel.list_widget col.addWidget(self._section("co4e.tab_skills", self._skills_panel, self.sk_manage_btn), 2) # --- RUNS -------------------------------------------------------- # A short, always-visible view of the same runs the Flow Status page # 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. Widget construction lives in ``RunsPagePanel`` (presentation/co4e/ co4e_run_control_widget.py); this method just wires the panel's public attributes to the handler methods that know about ``self`` (``_show_runs``, ``_stop_selected_run``, ...) — the panel itself stays ignorant of ``Co4ETab``. """ panel = RunsPagePanel() self.runs_back_btn = panel.back_btn self.runs_back_btn.clicked.connect(lambda: self._show_runs(False)) self.runs_title = panel.title_label self.ws_folder_btn = panel.ws_folder_btn self.ws_folder_btn.clicked.connect(self._open_workspace_folder) self._refresh_ws_folder_btn() self.run_stop_btn = panel.stop_btn self.run_stop_btn.clicked.connect(self._stop_selected_run) self.run_rename_btn = panel.rename_btn self.run_rename_btn.clicked.connect(self._rename_selected_run) self.run_del_btn = panel.del_btn self.run_del_btn.clicked.connect(self._delete_selected_run) self.run_clear_btn = panel.clear_btn self.run_clear_btn.clicked.connect(lambda: self.manager.clear_finished()) self.runs_table = panel.table self.runs_table.itemDoubleClicked.connect(self._open_run_from_table) self.runs_table.customContextMenuRequested.connect(self._runs_context_menu) return panel 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: """Widget construction lives in ``ChatPanel`` (presentation/co4e/ co4e_chat_view.py); this method just wires the panel's public attributes to the handler methods that know about ``self`` (``_toggle_messages``, ``_chat_send``) and keeps the state that is NOT part of the panel's own construction (``_flow_logs`` — per-flow ChatView dict, ``_co4e_routed_provider`` — routing override, and ``_vsplit_sizes``/``_msgs_collapsed`` — used by ``_toggle_messages`` below to restore/collapse the splitter) — the panel itself stays ignorant of ``Co4ETab``. """ panel = ChatPanel(self.ctx) self._chat_widget = panel self.msgs_icon = panel.msgs_icon self.msgs_title = panel.msgs_title self.chat_toggle_btn = panel.chat_toggle_btn self.chat_toggle_btn.clicked.connect(self._toggle_messages) self._mhdr = panel.header self.chat_stack = panel.chat_stack self._flow_logs: Dict[str, ChatView] = {} self.chat_input_row = panel.chat_input_row self._usage_total_lbl = panel.usage_total_lbl self.chat_input = panel.chat_input self.chat_input.submit.connect(self._chat_send) self.chat_send_btn = panel.chat_send_btn self.chat_send_btn.clicked.connect(self._chat_send) self.co4e_routing_toggle = panel.co4e_routing_toggle self._co4e_routed_provider = None # routing provider override for the next turn self._vsplit_sizes = [540, 220] # sizes to restore when expanded self._msgs_collapsed = True return panel 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. R03-T05: the Off/Auto/Manual/Fallback rules are no longer re-implemented here — they come from the shared ``RoutingApplicationService``, so Co4E, the Cowork chat and AI-Edit can never drift apart again. This method only adapts between Co4E's state and the service's DTOs. Never raises — falls back to the default model on any error. """ self._co4e_routed_provider = None try: from ..application.model_routing import ( RoutingRequest, build_routing_application_service, ) from .routing_toggle import confirm_switch cur_provider = self.ctx.config.active_provider cur_model = self.ctx.config.provider_conf(cur_provider).get("model", "") outcome = build_routing_application_service(self.ctx).resolve( RoutingRequest( surface="co4e", prompt=request, current_provider=cur_provider, current_model=cur_model, ), confirm=lambda decision, timeout: confirm_switch(self, decision, timeout), ) if not outcome.switched: return "" # '' keeps the provider's configured default model # Remembered so the worker's build_provider_for() can follow a # cross-provider switch, not just a model change. self._co4e_routed_provider = outcome.provider self._append_chat("system", tr( "routing.switched_notice", model=outcome.model, task=outcome.task_type, gain=f"{outcome.score_gain:.2f}")) return outcome.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"(? 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("&", "&").replace("<", "<").replace(">", ">") def _qcolor(hex_str: str): from PySide6.QtGui import QColor return QColor(hex_str)