Soát lại từng dòng plan thì thấy tôi báo R08 xong hơi sớm. Ba chỗ thiếu thật:
T06 ChatPanel vẫn ở ui/, plan đòi presentation/chat/chat_panel.py
T08 agents_admin_tab.py (498) và tools_admin_tab.py (245) vẫn ở ui/
presentation/chat/chat_panel.py 346
presentation/monitoring/tabs/agents_admin_tab.py 383
presentation/monitoring/tabs/agent_edit_dialog.py 143
presentation/monitoring/tabs/tools_admin_tab.py 245
ui/chat_panel.py / agents_admin_tab.py / tools_admin_tab.py ~10 mỗi cái
agents_admin_tab.py 498 dòng nên tách thêm agent_edit_dialog.py: bảng danh
sách và hộp thoại sửa là hai việc, và hộp thoại còn tự đi hỏi provider xem có
model nào — thứ bảng không cần biết.
BA CHỖ CÒN LẠI KHÔNG PHẢI THIẾU, đã kiểm từng cái:
* audio_recorder_widget.py (T04) — repo KHÔNG có chức năng ghi âm nào.
* connector_settings_widget.py (T07) — UI Connector đã dời khỏi Cài đặt.
* sandbox_status_tab.py / mcp_history_tab.py (T08) — Hiệp đặt tên sandbox_tab
và mcp_tab, nội dung đủ.
R08: 14/14 task, 0 file thiếu thật sự.
756 test xanh. 24/24 checker qua.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
195 lines
9.3 KiB
Python
195 lines
9.3 KiB
Python
"""Các thao tác trên một task: thêm, sửa, chạy ngay, xoá, xem log — R08-T11.
|
|
|
|
Tách khỏi ``ScheduleTaskTab`` để phần dựng bảng và phần hành động không nằm
|
|
lẫn nhau. ``_context_menu`` là chỗ tập trung: nó quyết định mục nào hiện ra
|
|
tuỳ theo đang chọn một hay nhiều thẻ.
|
|
|
|
Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
# Import muộn trong hàm ở chỗ dùng: ba lớp này nằm cùng gói và một trong số
|
|
# chúng trộn ngược mixin này vào, nên import ở mức module là vòng.
|
|
|
|
import copy
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
from PySide6.QtCore import Qt, Signal
|
|
from PySide6.QtWidgets import (
|
|
QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout,
|
|
QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox,
|
|
QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget,
|
|
QTabBar, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
|
)
|
|
from ...core import tasks as taskrepo
|
|
from ...core.projects import list_projects
|
|
from ...core.tasks import STATUSES, chain_error, duplicate_task, new_task
|
|
from ...core.worker import AgentWorker
|
|
from ...i18n import on_language_changed, tr
|
|
from ...state import AppContext
|
|
from ...theme import current_palette
|
|
from ...ui.calendar_view import CalendarView
|
|
from ...ui.icons import icon
|
|
from ...ui.osutil import open_path
|
|
|
|
|
|
class TaskActionsMixin:
|
|
"""Thao tác trên task. Trộn vào ScheduleTaskTab."""
|
|
|
|
def _add_task_on_date(self, date_str: str) -> None:
|
|
"""Create a task pre-filled with the clicked calendar date (default
|
|
09:00) — same editor Add Task opens, nothing is saved until confirmed."""
|
|
from ...ui.task_editor_dialog import TaskEditorDialog
|
|
|
|
t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"})
|
|
dlg = TaskEditorDialog(t, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
|
if dlg.exec() and dlg.edited_task:
|
|
self._save_and_refresh(dlg.edited_task)
|
|
self.status_message.emit(tr("schedtask.msg_created"))
|
|
def _save_and_refresh(self, task: dict) -> None:
|
|
taskrepo.save_task(task, self._tasks_dir)
|
|
self.refresh()
|
|
def _add_task(self) -> None:
|
|
from ...ui.task_editor_dialog import TaskEditorDialog
|
|
|
|
dlg = TaskEditorDialog(None, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
|
if dlg.exec() and dlg.edited_task:
|
|
self._save_and_refresh(dlg.edited_task)
|
|
self.status_message.emit(tr("schedtask.msg_created"))
|
|
def _edit_task(self, task_id: str) -> None:
|
|
from ...ui.task_editor_dialog import TaskEditorDialog
|
|
|
|
task = taskrepo.load_task(task_id, self._tasks_dir)
|
|
if not task:
|
|
return
|
|
dlg = TaskEditorDialog(task, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
|
if dlg.exec() and dlg.edited_task:
|
|
self._save_and_refresh(dlg.edited_task)
|
|
def _on_double_click(self, item: QListWidgetItem) -> None:
|
|
tid = item.data(Qt.UserRole)
|
|
if tid:
|
|
self._edit_task(tid)
|
|
def _is_multi_selection(item, selected) -> bool:
|
|
"""True when the right-clicked card is part of an existing multi-item
|
|
selection — pure boolean, kept separate from _context_menu so it's
|
|
testable without ever invoking Qt's (modal, event-loop-blocking) menu."""
|
|
return len(selected) > 1 and item in selected
|
|
def _context_menu(self, col: _KanbanColumn, pos) -> None:
|
|
item = col.itemAt(pos)
|
|
if item is None or not item.data(Qt.UserRole):
|
|
return
|
|
selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)]
|
|
if self._is_multi_selection(item, selected):
|
|
self._bulk_delete_menu(col, pos, selected)
|
|
return
|
|
tid = item.data(Qt.UserRole)
|
|
task = taskrepo.load_task(tid, self._tasks_dir)
|
|
if not task:
|
|
return
|
|
menu = QMenu(col)
|
|
run_act = menu.addAction(tr("schedtask.menu_run"))
|
|
edit_act = menu.addAction(tr("schedtask.menu_edit"))
|
|
dup_act = menu.addAction(tr("schedtask.menu_duplicate"))
|
|
paused = task.get("status") == "paused"
|
|
pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause"))
|
|
logs_act = menu.addAction(tr("schedtask.menu_logs"))
|
|
hist_act = menu.addAction(tr("schedtask.menu_history"))
|
|
next_act = menu.addAction(tr("schedtask.menu_create_next"))
|
|
menu.addSeparator()
|
|
del_act = menu.addAction(tr("schedtask.menu_delete"))
|
|
chosen = menu.exec(col.viewport().mapToGlobal(pos))
|
|
if chosen == run_act:
|
|
self._run_now(task)
|
|
elif chosen == edit_act:
|
|
self._edit_task(tid)
|
|
elif chosen == dup_act:
|
|
self._save_and_refresh(duplicate_task(task))
|
|
elif chosen == pause_act:
|
|
task["status"] = "backlog" if paused else "paused"
|
|
self._save_and_refresh(task)
|
|
elif chosen == logs_act:
|
|
self._view_logs(task)
|
|
elif chosen == hist_act:
|
|
from .run_history_dialog import _RunHistoryDialog
|
|
_RunHistoryDialog(task, self).exec()
|
|
elif chosen == next_act:
|
|
self._create_next_from_output(task)
|
|
elif chosen == del_act:
|
|
if QMessageBox.question(self, tr("schedtask.menu_delete"),
|
|
tr("schedtask.delete_confirm", title=task.get("title", ""))
|
|
) == QMessageBox.Yes:
|
|
taskrepo.delete_task(tid, self._tasks_dir)
|
|
self.refresh()
|
|
def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None:
|
|
"""Right-click on a multi-selection within one column (Shift/Ctrl-click
|
|
several cards first): one action deletes every selected task. The
|
|
popup itself is a thin wrapper — see _confirm_and_delete_selected for
|
|
the actual (independently testable) confirm+delete logic."""
|
|
menu = QMenu(col)
|
|
del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected)))
|
|
chosen = menu.exec(col.viewport().mapToGlobal(pos))
|
|
if chosen == del_act:
|
|
self._confirm_and_delete_selected(selected)
|
|
def _confirm_and_delete_selected(self, selected) -> bool:
|
|
"""Confirm, then delete every task in ``selected``. Split out of
|
|
_bulk_delete_menu so tests can drive it directly without having to
|
|
fake a real (modal, event-loop-blocking) QMenu popup."""
|
|
if QMessageBox.question(
|
|
self, tr("schedtask.menu_delete"),
|
|
tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
|
|
return False
|
|
for item in selected:
|
|
tid = item.data(Qt.UserRole)
|
|
if tid:
|
|
taskrepo.delete_task(tid, self._tasks_dir)
|
|
self.refresh()
|
|
return True
|
|
def _run_now(self, task: dict) -> None:
|
|
if task.get("task_type") == "manual":
|
|
self.status_message.emit(tr("schedtask.msg_manual_norun"))
|
|
return
|
|
if self.scheduler is None:
|
|
self.status_message.emit(tr("schedtask.msg_no_scheduler"))
|
|
return
|
|
if self.scheduler.run_now(task["task_id"]):
|
|
self.status_message.emit(tr("schedtask.msg_running", title=task.get("title", "")))
|
|
self.refresh()
|
|
def _view_logs(self, task: dict) -> None:
|
|
run_id = task.get("logs", {}).get("last_run_id")
|
|
if not run_id:
|
|
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
|
|
return
|
|
folder = taskrepo.ARTIFACTS_DIR / task["task_id"] / run_id
|
|
if folder.exists():
|
|
open_path(str(folder))
|
|
else:
|
|
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
|
|
def _create_next_from_output(self, task: dict) -> None:
|
|
"""Scaffold a follow-up task pre-wired to consume this task's output."""
|
|
nxt = new_task(tr("schedtask.next_of", title=task.get("title", "")))
|
|
nxt["task_type"] = "cowork"
|
|
nxt["input"]["mode"] = "previous_task_output"
|
|
nxt["input"]["previous_task_id"] = task["task_id"]
|
|
nxt["dependency"]["previous_task_id"] = task["task_id"]
|
|
err = chain_error(taskrepo.list_tasks(self._tasks_dir) + [nxt],
|
|
task["task_id"], nxt["task_id"])
|
|
if err:
|
|
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
|
|
return
|
|
taskrepo.save_task(nxt, self._tasks_dir)
|
|
task["dependency"]["next_task_id"] = nxt["task_id"]
|
|
task["dependency"]["pass_output_to_next"] = True
|
|
if task["dependency"].get("run_next_mode", "none") == "none":
|
|
task["dependency"]["run_next_mode"] = "run_after_success"
|
|
taskrepo.save_task(task, self._tasks_dir)
|
|
self.refresh()
|
|
self._edit_task(nxt["task_id"])
|
|
def _ai_create(self) -> None:
|
|
from .ai_task_creator_dialog import _AiCreateDialog
|
|
dlg = _AiCreateDialog(self.ctx, self)
|
|
if dlg.exec() and dlg.created_tasks:
|
|
for t in dlg.created_tasks:
|
|
taskrepo.save_task(t, self._tasks_dir)
|
|
self.refresh()
|
|
self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks)))
|