presentation/scheduling/
calendar_view_widget.py 231 lịch tháng (chuyển từ ui/calendar_view.py)
ai_task_creator_dialog.py 208 tạo task bằng AI
task_actions.py 189 thêm/sửa/chạy/xoá/xem log một task
kanban_board_widget.py 98 cột Kanban + vùng thả file
run_history_dialog.py 82 lịch sử các lượt chạy
ai_task_import_dialog.py 81 nhập task từ file
ui/schedule_task_tab.py 297 dựng bảng + đổi chế độ xem
ui/calendar_view.py 10 vỏ chuyển tiếp
Plan ghi 4 file; thực tế cần 6. Hai file thêm là run_history_dialog.py và
task_actions.py — không tách thì schedule_task_tab.py còn 517 dòng, vẫn vượt
ngưỡng 400.
ai_task_import_dialog.py làm mixin chứ không phải hộp thoại rời: plan gọi nó
là dialog, nhưng thực tế nó là TAB THỨ HAI của cùng hộp thoại tạo task, dùng
chung phần xem trước và nút Xác nhận. Tách hẳn thì phải nhân đôi cả hai.
LẠI LỖI DECORATOR: script này tôi quên dùng bản có tính dòng @, nên một
@staticmethod bị bỏ lại mồ côi -> IndentationError. Đây là lần thứ tư cùng
một lỗi. Đã thêm bước dọn decorator mồ côi vào script.
756 test xanh. 16 checker chạy đều qua.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
298 lines
14 KiB
Python
298 lines
14 KiB
Python
"""Schedule Task tab — Kanban board for scheduled/automated tasks.
|
|
|
|
Columns: Backlog / Scheduled / Running / Waiting Input / Done / Failed /
|
|
Paused. Cards drag between columns (dropping = changing status), double-click
|
|
edits, right-click offers Run now / Edit / Duplicate / Pause / Delete / View
|
|
logs / Create-next-from-output. Header has search, a type filter, Add Task
|
|
and AI Create Task (preview first — nothing is created until confirmed).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from ..presentation.scheduling.ai_task_creator_dialog import _AiCreateDialog
|
|
from ..presentation.scheduling.run_history_dialog import _RunHistoryDialog
|
|
from ..presentation.scheduling.task_actions import TaskActionsMixin
|
|
from ..presentation.scheduling.kanban_board_widget import _DropZone, _KanbanColumn
|
|
|
|
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 .calendar_view import CalendarView
|
|
from .icons import icon
|
|
from .osutil import open_path
|
|
|
|
_VIEWS = ("kanban", "calendar")
|
|
|
|
# Priority shown as a plain text tag (no colored-emoji squares). Only the
|
|
# elevated priorities get a visible marker; low/medium stay unmarked as before.
|
|
_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"}
|
|
|
|
|
|
|
|
|
|
class ScheduleTaskTab(TaskActionsMixin, QWidget):
|
|
status_message = Signal(str)
|
|
|
|
def __init__(self, ctx: AppContext, scheduler=None):
|
|
super().__init__()
|
|
self.ctx = ctx
|
|
self.scheduler = scheduler # TaskScheduler (may be None in tests)
|
|
self._ai_worker: Optional[AgentWorker] = None
|
|
self._tasks_dir: Optional[Path] = None # None → default repo dir
|
|
|
|
root = QVBoxLayout(self)
|
|
|
|
# ---- header ----------------------------------------------------
|
|
header = QHBoxLayout()
|
|
self._title = QLabel()
|
|
self._title.setStyleSheet("font-weight:700; font-size:15px;")
|
|
self.counts_lbl = QLabel("")
|
|
self.counts_lbl.setObjectName("hint")
|
|
# A one-line summary of every lane's count. Left to size itself it
|
|
# reported a sizeHint wide enough to set the MINIMUM width of the whole
|
|
# screen — 1285px at 150% scaling, which then became the window's
|
|
# minimum and stopped the app fitting a 1280px laptop. It is a summary,
|
|
# and the same numbers are on each lane header, so it gives way first.
|
|
self.counts_lbl.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
|
|
self.counts_lbl.setMinimumWidth(0)
|
|
self.add_btn = QPushButton()
|
|
self.add_btn.setIcon(icon("plus"))
|
|
self.add_btn.setObjectName("primary")
|
|
self.add_btn.clicked.connect(self._add_task)
|
|
self.ai_btn = QPushButton()
|
|
self.ai_btn.setIcon(icon("sparkle"))
|
|
self.ai_btn.clicked.connect(self._ai_create)
|
|
# Two views of the same tasks, so they read as a pair of tabs rather
|
|
# than a drop-list you have to open to discover the Calendar exists.
|
|
self.view_tabs = QTabBar()
|
|
self.view_tabs.setObjectName("viewTabs")
|
|
self.view_tabs.setDrawBase(False)
|
|
self.view_tabs.setExpanding(False)
|
|
for _v in _VIEWS:
|
|
self.view_tabs.addTab("")
|
|
self.view_tabs.currentChanged.connect(self._on_view_changed)
|
|
header.addWidget(self._title)
|
|
header.addWidget(self.counts_lbl, 1)
|
|
header.addWidget(self.view_tabs)
|
|
header.addWidget(self.add_btn)
|
|
header.addWidget(self.ai_btn)
|
|
root.addLayout(header)
|
|
|
|
# ---- board / calendar (two views of the SAME tasks) -----------------
|
|
self._view_stack = QStackedWidget()
|
|
scroll = QScrollArea()
|
|
scroll.setWidgetResizable(True)
|
|
board = QWidget()
|
|
scroll.setWidget(board)
|
|
cols = QHBoxLayout(board)
|
|
# Gutters wide enough to read as a break between lanes without eating
|
|
# too much of the seven-way split — they still share the board equally
|
|
# (see _fit_lanes below), so a wider gutter narrows every lane by the
|
|
# same share automatically; nothing else to compute here.
|
|
cols.setSpacing(2)
|
|
self.columns: Dict[str, _KanbanColumn] = {}
|
|
self.column_headers: Dict[str, QLabel] = {}
|
|
for status in STATUSES:
|
|
box = QVBoxLayout()
|
|
# The per-lane holder's own margins were the style's default
|
|
# (~9px a side) on top of the inter-column gap — with seven lanes
|
|
# that outweighs the gap itself. Zero it out and let the lane's
|
|
# header/list fill the width _fit_lanes() hands them.
|
|
box.setContentsMargins(0, 0, 0, 0)
|
|
box.setSpacing(2)
|
|
head = QLabel()
|
|
head.setStyleSheet("font-weight:600;")
|
|
col = _KanbanColumn(status)
|
|
col.setObjectName("kanbanLane")
|
|
col.task_dropped.connect(self._on_task_dropped)
|
|
col.itemDoubleClicked.connect(self._on_double_click)
|
|
col.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
col.customContextMenuRequested.connect(
|
|
lambda pos, c=col: self._context_menu(c, pos))
|
|
box.addWidget(head)
|
|
box.addWidget(col, 1)
|
|
holder = QWidget()
|
|
holder.setLayout(box)
|
|
cols.addWidget(holder)
|
|
self.columns[status] = col
|
|
self.column_headers[status] = head
|
|
self._board_scroll = scroll
|
|
self._board_gap = cols.spacing()
|
|
scroll.viewport().installEventFilter(self)
|
|
self._view_stack.addWidget(scroll)
|
|
self.calendar = CalendarView()
|
|
self.calendar.edit_task.connect(self._edit_task)
|
|
self.calendar.add_task_on_date.connect(self._add_task_on_date)
|
|
self._view_stack.addWidget(self.calendar)
|
|
root.addWidget(self._view_stack, 1)
|
|
|
|
if self.scheduler is not None:
|
|
self.scheduler.tasks_changed.connect(self.refresh)
|
|
self.scheduler.task_started.connect(lambda _tid: self.refresh())
|
|
self.scheduler.task_finished.connect(lambda _tid, _ok: self.refresh())
|
|
|
|
# Belt-and-braces: also re-read the board every 10s so a card's lane
|
|
# ALWAYS reflects reality (Scheduled → Running → Done) even if some
|
|
# change slipped past the signals (e.g. task files edited externally).
|
|
from PySide6.QtCore import QTimer
|
|
self._refresh_timer = QTimer(self)
|
|
self._refresh_timer.setInterval(10_000)
|
|
self._refresh_timer.timeout.connect(self.refresh)
|
|
self._refresh_timer.start()
|
|
|
|
self.refresh()
|
|
on_language_changed(self._retranslate)
|
|
|
|
# ---- i18n ------------------------------------------------------------
|
|
def _retranslate(self) -> None:
|
|
self._title.setText(tr("schedtask.title"))
|
|
self.add_btn.setText(tr("schedtask.add_btn"))
|
|
self.add_btn.setToolTip(tr("schedtask.add_tooltip"))
|
|
self.ai_btn.setText(tr("schedtask.ai_btn"))
|
|
self.ai_btn.setToolTip(tr("schedtask.ai_tooltip"))
|
|
for i, v in enumerate(_VIEWS):
|
|
self.view_tabs.setTabText(i, tr(f"schedtask.view.{v}"))
|
|
for status, col in self.columns.items():
|
|
col.setToolTip(tr(f"schedtask.col_tip.{status}"))
|
|
self.refresh()
|
|
|
|
# ---- Kanban / Calendar view switch --------------------------------
|
|
def _on_view_changed(self) -> None:
|
|
self._view_stack.setCurrentIndex(self.view_tabs.currentIndex())
|
|
|
|
|
|
# ---- lane widths ------------------------------------------------------
|
|
#
|
|
# The seven lanes share the board equally — that is the layout's stretch
|
|
# doing the work, so the split is a proportion of whatever width there is,
|
|
# on any monitor. The only pixel question left is how narrow a lane may get
|
|
# before scrolling sideways beats squeezing, and that is a question about
|
|
# TEXT: roughly eight characters of a task title plus its padding. Reading
|
|
# it off the font keeps it right at 125%/150% scaling and at a user's own
|
|
# font size, where a constant would not be.
|
|
_LANE_FLOOR_CH = 8
|
|
|
|
def eventFilter(self, obj, event): # noqa: N802
|
|
from PySide6.QtCore import QEvent
|
|
|
|
if obj is self._board_scroll.viewport() and event.type() == QEvent.Resize:
|
|
self._fit_lanes()
|
|
return super().eventFilter(obj, event)
|
|
|
|
def _fit_lanes(self) -> None:
|
|
floor = self.fontMetrics().averageCharWidth() * self._LANE_FLOOR_CH + 24
|
|
for col in self.columns.values():
|
|
if col.minimumWidth() != floor:
|
|
col.setMinimumWidth(floor)
|
|
|
|
# ---- board rendering ---------------------------------------------------
|
|
def _card_text(self, t: dict) -> str:
|
|
prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "")
|
|
ai = "[AI] " if t.get("is_ai_generated") else ""
|
|
sched = t.get("schedule", {})
|
|
when = sched.get("run_at") if sched.get("enabled") else None
|
|
when_line = when or tr("schedtask.no_schedule")
|
|
chain = ""
|
|
if t.get("dependency", {}).get("next_task_id") or t.get("dependency", {}).get("previous_task_id"):
|
|
chain = " (linked)"
|
|
last = t.get("logs", {}).get("last_status")
|
|
last_line = {"success": tr("schedtask.last_success"),
|
|
"failed": tr("schedtask.last_failed")}.get(last, tr("schedtask.last_never"))
|
|
# Card shows ONLY the task's own title (plus the [AI] marker and chain
|
|
# note) — no "[Cowork]"/"[Code]" task-type tag cluttering it.
|
|
return (f"{ai}{t.get('title', '')}{chain}\n"
|
|
f"{when_line} {prio}\n{last_line}")
|
|
|
|
def refresh(self) -> None:
|
|
all_tasks = taskrepo.list_tasks(self._tasks_dir)
|
|
counts = {s: 0 for s in STATUSES}
|
|
for col in self.columns.values():
|
|
col.clear()
|
|
for t in all_tasks:
|
|
status = t.get("status", "backlog")
|
|
if status not in self.columns:
|
|
continue
|
|
counts[status] += 1
|
|
item = QListWidgetItem(self._card_text(t))
|
|
item.setData(Qt.UserRole, t["task_id"])
|
|
self.columns[status].addItem(item)
|
|
pal = current_palette()
|
|
for status, col in self.columns.items():
|
|
self.column_headers[status].setText(
|
|
f"{tr(f'schedtask.status.{status}')} ({counts[status]})")
|
|
# Dropping a card into Running STARTS the task for real, so that
|
|
# lane is outlined while it holds anything — the one column here
|
|
# with a side effect should not look like the other six.
|
|
if status == "running" and counts[status]:
|
|
col.setStyleSheet(
|
|
f"border: 1px solid {pal.warning}; border-radius: {pal.radius}px;")
|
|
self.column_headers[status].setStyleSheet(
|
|
f"font-weight:600; color: {pal.warning};")
|
|
else:
|
|
col.setStyleSheet("")
|
|
self.column_headers[status].setStyleSheet("font-weight:600;")
|
|
if col.count() == 0:
|
|
empty = QListWidgetItem(tr("schedtask.no_tasks"))
|
|
empty.setFlags(Qt.NoItemFlags)
|
|
col.addItem(empty)
|
|
summary = " ".join(
|
|
f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s])
|
|
self.counts_lbl.setText(summary)
|
|
self.counts_lbl.setToolTip(summary) # full text stays reachable if clipped
|
|
self.calendar.set_tasks(all_tasks)
|
|
|
|
# ---- actions --------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _on_task_dropped(self, task_id: str, new_status: str) -> None:
|
|
"""Dropping a card into a lane ACTS on the task, not just relabels it:
|
|
→ Running actually runs it now; → Done marks it completed; → Scheduled
|
|
puts it on the calendar (opening the editor if no time is set yet)."""
|
|
task = taskrepo.load_task(task_id, self._tasks_dir)
|
|
if not task:
|
|
return
|
|
if task.get("status") == "running":
|
|
self.refresh() # can't drag a running task
|
|
return
|
|
if new_status == "running":
|
|
# Dropping into Running = "run it now" (counts as manual approval).
|
|
self.refresh()
|
|
self._run_now(task)
|
|
return
|
|
if new_status == "done":
|
|
task["status"] = "done"
|
|
task["schedule"]["enabled"] = False # done by hand → don't re-fire
|
|
self._save_and_refresh(task)
|
|
return
|
|
task["status"] = new_status
|
|
if new_status == "scheduled" and not task["schedule"].get("enabled"):
|
|
if task["schedule"].get("run_at"):
|
|
task["schedule"]["enabled"] = True
|
|
else:
|
|
# No time set yet — a silently-disabled "Scheduled" card would
|
|
# never run and look broken. Open the editor so the user sets
|
|
# the schedule right away.
|
|
self._save_and_refresh(task)
|
|
self.status_message.emit(tr("schedtask.msg_set_schedule"))
|
|
self._edit_task(task_id)
|
|
return
|
|
self._save_and_refresh(task)
|
|
|