diff --git a/presentation/co4e/co4e_agents.py b/presentation/co4e/co4e_agents.py new file mode 100644 index 0000000..5cb5f91 --- /dev/null +++ b/presentation/co4e/co4e_agents.py @@ -0,0 +1,51 @@ +"""Agent và skill dùng trong flow — R08-T09. +""" +from __future__ import annotations + +import re +from typing import Dict, List +from PySide6.QtCore import QSize, Qt +from ...core import co4e, skills as skills_mod +from ...i18n import tr +from ...presentation.co4e.co4e_chat_view import _skill_names + + +class Co4EAgentsMixin: + 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 ...ui.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 ...ui.skills_dialog import SkillsDialog + + SkillsDialog(self, self.ctx).exec() + self._reload_sidebar() + 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 diff --git a/presentation/co4e/co4e_chat.py b/presentation/co4e/co4e_chat.py new file mode 100644 index 0000000..62fa71b --- /dev/null +++ b/presentation/co4e/co4e_chat.py @@ -0,0 +1,343 @@ +"""Khung chat của Co4E và việc đếm token — R08-T09. + +Khác chat của Cowork ở một điểm: ở đây câu người dùng gõ có thể mang chỉ thị +chọn agent (``_extract_agent_directive``), và mỗi lượt được định tuyến riêng +theo cấu hình routing của Co4E. +""" +from __future__ import annotations + +import re +from typing import Dict, List +from PySide6.QtCore import QSize, Qt +from PySide6.QtWidgets import QSplitter, QWidget +from ...core import co4e, skills as skills_mod +from ...core.co4e_builtins import BUILTIN_AGENTS +from ...core.worker import AgentWorker +from ...i18n import tr +from ...ui.chat_view import ChatView +from ...ui.icons import icon +from ...presentation.co4e.co4e_chat_view import ChatPanel + + +class Co4EChatMixin: + 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 + 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 + 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 ...ui.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 + 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() diff --git a/presentation/co4e/co4e_flow_tabs.py b/presentation/co4e/co4e_flow_tabs.py new file mode 100644 index 0000000..1dd7132 --- /dev/null +++ b/presentation/co4e/co4e_flow_tabs.py @@ -0,0 +1,180 @@ +"""Dải tab các flow đang mở — R08-T09. + +Mỗi flow người dùng mở là một tab. Đóng tab không đóng flow: nó chỉ rời khỏi +dải, flow vẫn còn trong thư viện bên trái. +""" +from __future__ import annotations + +import re +from typing import Dict, List, Optional +from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtWidgets import QPushButton, QTabBar +from ...core import co4e +from ...i18n import tr +from ...ui.icons import icon + + +class Co4EFlowTabsMixin: + 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 + 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")) diff --git a/presentation/co4e/co4e_layout.py b/presentation/co4e/co4e_layout.py new file mode 100644 index 0000000..4b1607e --- /dev/null +++ b/presentation/co4e/co4e_layout.py @@ -0,0 +1,308 @@ +"""Bố cục ba khung và bảng cấu hình node — R08-T09. + +Co4E có bốn lớp điều hướng chồng nhau (dải flow, tab icon bên phải, bảng cấu +hình, canvas). Phần quyết định cái nào hiện lúc nào nằm ở đây, tách khỏi phần +hành vi để sửa bố cục không phải đọc logic chạy flow. + +``_apply_narrow_layout`` là chỗ đáng chú ý: màn hẹp thì bảng cấu hình chuyển +từ khung cố định sang lớp phủ, vì ba khung cạnh nhau không vừa 1280px. +""" +from __future__ import annotations + +import re +from typing import List +from PySide6.QtCore import QSize, Qt +from PySide6.QtWidgets import QComboBox, QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QSizePolicy, QSpacerItem, QSplitter, QTabBar, QTabWidget, QVBoxLayout, QWidget +from ...core import co4e +from ...i18n import tr +from ...theme import current_palette +from ...ui.co4e_canvas import Co4ECanvas +from ...ui.icons import icon +from ...presentation.co4e.co4e_run_control_widget import RunsPagePanel + + +class Co4ELayoutMixin: + 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 + 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) diff --git a/presentation/co4e/co4e_runs.py b/presentation/co4e/co4e_runs.py new file mode 100644 index 0000000..9006f02 --- /dev/null +++ b/presentation/co4e/co4e_runs.py @@ -0,0 +1,364 @@ +"""Chạy flow và bảng lịch sử lượt chạy — R08-T09. + +Ba chế độ chạy: cả flow, một node, hoặc từng bước thủ công. ``_topo_order`` và +``_downstream`` là phần đồ thị — chạy node nào trước, node nào phụ thuộc node +nào. + +``_on_manager_event`` là nơi mọi tín hiệu từ bộ chạy nền đổ về; nó dài vì phải +phân nhánh theo loại sự kiện, không tách nhỏ được mà không làm khó đọc hơn. +""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import Dict, List, Optional +from PySide6.QtCore import QSize, Qt +from PySide6.QtWidgets import QInputDialog, QMenu, QMessageBox, QTableWidget, QTableWidgetItem +from ...core import co4e +from ...i18n import tr +from ...theme import current_palette + + +class Co4ERunsMixin: + 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 + 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))) + 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)) + def _open_run_output_folder(self, run_id) -> None: + """Open the workspace folder a specific run wrote its files into.""" + from ...ui.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 _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)) diff --git a/presentation/co4e/co4e_sidebar.py b/presentation/co4e/co4e_sidebar.py new file mode 100644 index 0000000..7418b82 --- /dev/null +++ b/presentation/co4e/co4e_sidebar.py @@ -0,0 +1,251 @@ +"""Cột trái: thư viện workflow, agent, skill — R08-T09. + +Bốn mục gập được (WORKFLOWS / AGENTS / SKILLS / FLOW STATUS). Trạng thái gập +của từng mục là thứ người dùng đặt rồi mong nó giữ nguyên, nên nó nằm trong +cấu hình chứ không phải trong widget. +""" +from __future__ import annotations + +import re +from typing import List +from PySide6.QtCore import QSize, Qt +from PySide6.QtWidgets import QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QSplitter, QVBoxLayout, QWidget +from ...core import co4e, skills as skills_mod +from ...i18n import tr +from ...ui.icons import icon +from ...presentation.co4e.agent_list_panel import AgentListPanel +from ...presentation.co4e.co4e_chat_view import _skill_names +from ...presentation.co4e.palette_list import _PaletteList +from ...presentation.co4e.skills_list_panel import SkillsListPanel + + +class Co4ESidebarMixin: + 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 + 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 diff --git a/presentation/co4e/co4e_workflow_crud.py b/presentation/co4e/co4e_workflow_crud.py new file mode 100644 index 0000000..71b3181 --- /dev/null +++ b/presentation/co4e/co4e_workflow_crud.py @@ -0,0 +1,154 @@ +"""Tạo, sửa, đổi tên, xoá, nhân bản workflow — R08-T09. + +Chỉ thao tác trên danh sách. Phần chạy một workflow nằm ở ``co4e_runs.py``, +phần vẽ node nằm ở canvas. +""" +from __future__ import annotations + +import re +from typing import List, Optional +from PySide6.QtCore import QSize, Qt +from PySide6.QtWidgets import QInputDialog, QMenu +from ...core import co4e +from ...i18n import tr +from ...ui.icons import icon +from ...presentation.co4e.co4e_chat_view import _skill_names + + +class Co4EWorkflowCrudMixin: + 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())) + 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() + 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 diff --git a/tools/check_probes_bite.py b/tools/check_probes_bite.py index 71652b5..efa2ba7 100644 --- a/tools/check_probes_bite.py +++ b/tools/check_probes_bite.py @@ -43,7 +43,8 @@ MUTATIONS = [ "self.section_list, self.section_stack = section_panels(pages[:1])", "check_dialogs.py"), ("noi lai dai tab flow Co4E", - "ui/co4e_tab.py", + # R08-T09 doi cho: Co4ETab tach thanh 7 mixin duoi presentation/co4e/. + "presentation/co4e/co4e_layout.py", "self.flow_scroll.setVisible(False)", "self.flow_scroll.setVisible(True)", "check_co4e.py"), diff --git a/ui/co4e_tab.py b/ui/co4e_tab.py index efbdaab..7a75fb8 100644 --- a/ui/co4e_tab.py +++ b/ui/co4e_tab.py @@ -85,7 +85,24 @@ class _EqualTabBar(QTabBar): self.updateGeometry() # re-hint tab widths when resized -class Co4ETab(QWidget): +from ..presentation.co4e.co4e_flow_tabs import Co4EFlowTabsMixin +from ..presentation.co4e.co4e_sidebar import Co4ESidebarMixin +from ..presentation.co4e.co4e_layout import Co4ELayoutMixin +from ..presentation.co4e.co4e_workflow_crud import Co4EWorkflowCrudMixin +from ..presentation.co4e.co4e_agents import Co4EAgentsMixin +from ..presentation.co4e.co4e_runs import Co4ERunsMixin +from ..presentation.co4e.co4e_chat import Co4EChatMixin + + +class Co4ETab( + Co4EFlowTabsMixin, + Co4ESidebarMixin, + Co4ELayoutMixin, + Co4EWorkflowCrudMixin, + Co4EAgentsMixin, + Co4ERunsMixin, + Co4EChatMixin, + QWidget): status_message = Signal(str) def __init__(self, ctx): @@ -163,628 +180,35 @@ class Co4ETab(QWidget): 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 @@ -797,652 +221,61 @@ class Co4ETab(QWidget): 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: @@ -1491,107 +324,11 @@ class Co4ETab(QWidget): 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. @@ -1607,250 +344,17 @@ class Co4ETab(QWidget): 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: