Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
195 lines
9.4 KiB
Python
195 lines
9.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:
|
||
"""Dải tab kiểu trình duyệt của Co4E Studio: mỗi luồng đang mở là một tab.
|
||
|
||
Tab chỉ số 0 luôn là trang Flow Status được ghim, nên luồng thứ ``i`` nằm ở
|
||
tab ``i + 1`` — mọi phép đổi chỉ số trong file này đều theo quy ước đó.
|
||
"""
|
||
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
|
||
"""Đổi tab: lưu luồng vừa rời đi rồi nạp luồng mới lên khung vẽ.
|
||
|
||
Phải lưu TRƯỚC khi chuyển, nếu không thay đổi chưa lưu của luồng cũ sẽ bị
|
||
khung vẽ ghi đè khi nạp luồng mới.
|
||
"""
|
||
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:
|
||
"""Tìm tab ứng với nút ✕ vừa bấm rồi đóng tab đó."""
|
||
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:
|
||
"""Đóng một tab luồng. Tab Flow Status (chỉ số 0) được ghim, không đóng được."""
|
||
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:
|
||
"""Cập nhật nhãn tab đang mở theo tên luồng; không bao giờ đổi tên tab Flow Status."""
|
||
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:
|
||
"""Nhãn nút Chạy đổi theo trạng thái luồng đang mở: đang chạy thì thành "Dừng"."""
|
||
self.run_btn.setText(tr("co4e.interrupt") if self._cur_run_id() is not None
|
||
else tr("co4e.run"))
|