File to nhất còn lại của Gamma. Lâm bàn giao ở 1.885 dòng với 100 method
trong một lớp; chia theo bảy mối quan tâm:
co4e_runs.py 364 chạy flow, 3 chế độ, bảng lịch sử lượt chạy
co4e_chat.py 343 khung chat + đếm token + định tuyến riêng
co4e_layout.py 308 ba khung, bảng cấu hình, bố cục màn hẹp
co4e_sidebar.py 251 thư viện workflow/agent/skill, 4 mục gập
co4e_flow_tabs.py 180 dải tab các flow đang mở
co4e_workflow_crud.py 154 tạo/sửa/xoá/nhân bản workflow
co4e_agents.py 51 agent và skill dùng trong flow
ui/co4e_tab.py 389 __init__, set_project, thư mục output
Mọi file dưới 400 dòng.
MỘT LỖI SUÝT LÀM HỎNG FILE: bản đầu tôi cắt method theo m.lineno, mà lineno
trỏ vào dòng `def`, không tính dòng `@...` phía trên. Decorator bị bỏ lại
thành mồ côi ngay trên một hằng số lớp -> file hỏng cú pháp. Bắt được vì
script tự parse lại sau mỗi lần cắt; nếu chỉ cắt rồi ghi thì đã đẩy lên một
file không import nổi.
Ba vòng sửa mức import tương đối: co4e_tab.py nằm ở ui/ (1 cấp), file mới ở
presentation/co4e/ (2 cấp). Còn co4e_canvas / co4e_config_panel /
co4e_agent_dialog thì VẪN ở ui/, nên `.co4e_canvas` phải thành
`...ui.co4e_canvas` chứ không phải `.co4e_canvas` cùng thư mục.
714 test xanh — trong đó có ~4.000 dòng test đặc tả Lâm viết cho đúng vùng
này, nên việc tách được soi khá kỹ. check_co4e, check_controls_alive,
check_layout_geometry, check_probes_bite đều qua.
Cập nhật đích đột biến thứ ba của check_probes_bite: dải tab flow nay ở
presentation/co4e/co4e_layout.py.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
181 lines
8.4 KiB
Python
181 lines
8.4 KiB
Python
"""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"))
|