refactor(scheduling): R08-T11 — schedule_task_tab.py 794 -> 297, tách 6 file
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4fef41481b
commit
982fecc8dc
@@ -0,0 +1,208 @@
|
|||||||
|
"""Hộp thoại tạo task bằng AI, và nhập task từ file — R08-T11.
|
||||||
|
|
||||||
|
Hai tab trong một hộp thoại vì cùng trả lời một câu: "làm sao có task mà
|
||||||
|
không phải điền tay từng ô".
|
||||||
|
|
||||||
|
* **Tạo bằng AI** — gõ một câu tiếng Việt, kèm được file và liên kết; AI sinh
|
||||||
|
ra cấu hình task và lịch chạy. Người dùng xem trước rồi mới xác nhận.
|
||||||
|
* **Nhập từ file** — xem ``ai_task_import_dialog.py``; phần nhập tách ra đó,
|
||||||
|
hộp thoại này chỉ đặt nó vào tab thứ hai.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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
|
||||||
|
from .ai_task_import_dialog import TaskImportMixin
|
||||||
|
from .kanban_board_widget import _DropZone
|
||||||
|
|
||||||
|
|
||||||
|
class _AiCreateDialog(TaskImportMixin, QDialog):
|
||||||
|
"""Create tasks two ways, one tab each (both preview first — nothing is
|
||||||
|
saved until the user confirms): ✨ AI gen from a natural-language
|
||||||
|
description, or 📥 Import from a filled Excel template (pick or drag)."""
|
||||||
|
|
||||||
|
def __init__(self, ctx: AppContext, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
from PySide6.QtWidgets import QTabWidget
|
||||||
|
|
||||||
|
self.ctx = ctx
|
||||||
|
self.created_tasks: List[dict] = []
|
||||||
|
self._planned: List[dict] = []
|
||||||
|
self._worker: Optional[AgentWorker] = None
|
||||||
|
self.setWindowTitle(tr("schedtask.ai_btn"))
|
||||||
|
self.resize(600, 520)
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
ws_row = QHBoxLayout()
|
||||||
|
ws_row.addWidget(QLabel(tr("schedtask.f_workspace")))
|
||||||
|
self.workspace_combo = QComboBox()
|
||||||
|
self.workspace_combo.addItem(tr("schedtask.no_workspace"), "")
|
||||||
|
for p in list_projects():
|
||||||
|
self.workspace_combo.addItem(p.name, p.project_id)
|
||||||
|
self.workspace_combo.setToolTip(tr("schedtask.hint_workspace"))
|
||||||
|
ws_row.addWidget(self.workspace_combo, 1)
|
||||||
|
root.addLayout(ws_row)
|
||||||
|
self.tabs = QTabWidget()
|
||||||
|
root.addWidget(self.tabs, 1)
|
||||||
|
|
||||||
|
# ---- tab 1: AI gen ------------------------------------------------
|
||||||
|
ai_page = QWidget()
|
||||||
|
al = QVBoxLayout(ai_page)
|
||||||
|
al.addWidget(QLabel(tr("schedtask.ai_desc_label")))
|
||||||
|
self.desc_edit = QPlainTextEdit()
|
||||||
|
self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph"))
|
||||||
|
self.desc_edit.setMaximumHeight(110)
|
||||||
|
al.addWidget(self.desc_edit)
|
||||||
|
# Attachments (files + links) — merged into every task this generates,
|
||||||
|
# AND into the planning prompt so the AI knows they exist.
|
||||||
|
attach_row = QHBoxLayout()
|
||||||
|
self.ai_files_edit = QLineEdit()
|
||||||
|
self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder"))
|
||||||
|
ai_pick_btn = QPushButton(tr("schedtask.pick_files"))
|
||||||
|
ai_pick_btn.setIcon(icon("folder"))
|
||||||
|
ai_pick_btn.clicked.connect(self._ai_pick_files)
|
||||||
|
attach_row.addWidget(self.ai_files_edit, 1)
|
||||||
|
attach_row.addWidget(ai_pick_btn)
|
||||||
|
al.addWidget(QLabel(tr("schedtask.f_files")))
|
||||||
|
al.addLayout(attach_row)
|
||||||
|
self.ai_links_edit = QLineEdit()
|
||||||
|
self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder"))
|
||||||
|
al.addWidget(QLabel(tr("schedtask.f_links")))
|
||||||
|
al.addWidget(self.ai_links_edit)
|
||||||
|
self.gen_btn = QPushButton(tr("schedtask.ai_generate"))
|
||||||
|
self.gen_btn.setIcon(icon("sparkle"))
|
||||||
|
self.gen_btn.setObjectName("primary")
|
||||||
|
self.gen_btn.clicked.connect(self._generate)
|
||||||
|
al.addWidget(self.gen_btn)
|
||||||
|
al.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
||||||
|
self.preview = QPlainTextEdit()
|
||||||
|
self.preview.setReadOnly(True)
|
||||||
|
al.addWidget(self.preview, 1)
|
||||||
|
self.tabs.addTab(ai_page, tr("schedtask.tab_ai"))
|
||||||
|
|
||||||
|
# ---- tab 2: Import from Excel --------------------------------------
|
||||||
|
imp_page = QWidget()
|
||||||
|
il = QVBoxLayout(imp_page)
|
||||||
|
tpl_btn = QPushButton(tr("schedtask.export_template_btn"))
|
||||||
|
tpl_btn.setIcon(icon("upload"))
|
||||||
|
tpl_btn.clicked.connect(self._export_template)
|
||||||
|
il.addWidget(tpl_btn)
|
||||||
|
pick_row = QHBoxLayout()
|
||||||
|
pick_btn = QPushButton(tr("schedtask.import_pick_btn"))
|
||||||
|
pick_btn.setIcon(icon("folder"))
|
||||||
|
pick_btn.clicked.connect(self._pick_import_file)
|
||||||
|
pick_row.addWidget(pick_btn)
|
||||||
|
pick_row.addStretch(1)
|
||||||
|
il.addLayout(pick_row)
|
||||||
|
self.drop_zone = _DropZone()
|
||||||
|
self.drop_zone.setText(tr("schedtask.drop_hint"))
|
||||||
|
self.drop_zone.file_dropped.connect(self._load_import_file)
|
||||||
|
il.addWidget(self.drop_zone)
|
||||||
|
il.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
||||||
|
self.import_preview = QPlainTextEdit()
|
||||||
|
self.import_preview.setReadOnly(True)
|
||||||
|
il.addWidget(self.import_preview, 1)
|
||||||
|
self.tabs.addTab(imp_page, tr("schedtask.tab_import"))
|
||||||
|
|
||||||
|
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||||
|
self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm"))
|
||||||
|
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||||
|
self.buttons.accepted.connect(self._confirm)
|
||||||
|
self.buttons.rejected.connect(self.reject)
|
||||||
|
root.addWidget(self.buttons)
|
||||||
|
|
||||||
|
# ---- Import tab ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_pick_files(self) -> None:
|
||||||
|
from PySide6.QtWidgets import QFileDialog
|
||||||
|
|
||||||
|
files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files"))
|
||||||
|
if files:
|
||||||
|
existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()]
|
||||||
|
self.ai_files_edit.setText("; ".join(existing + files))
|
||||||
|
|
||||||
|
def _attached_files(self) -> List[str]:
|
||||||
|
return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()]
|
||||||
|
|
||||||
|
def _attached_links(self) -> List[str]:
|
||||||
|
return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()]
|
||||||
|
|
||||||
|
def _generate(self) -> None:
|
||||||
|
description = self.desc_edit.toPlainText().strip()
|
||||||
|
if not description or self._worker is not None:
|
||||||
|
return
|
||||||
|
files, links = self._attached_files(), self._attached_links()
|
||||||
|
self.gen_btn.setEnabled(False)
|
||||||
|
self.gen_btn.setText(tr("schedtask.ai_generating"))
|
||||||
|
|
||||||
|
def job(worker: AgentWorker):
|
||||||
|
from ..core.ai_task_planner import plan_tasks
|
||||||
|
|
||||||
|
provider = self.ctx.build_active_provider()
|
||||||
|
full_desc = description
|
||||||
|
if files or links:
|
||||||
|
attach_note = "; ".join(files + links)
|
||||||
|
full_desc += f"\n\n(Attached references available: {attach_note})"
|
||||||
|
planned = plan_tasks(provider, full_desc, cancel=worker.is_cancelled)
|
||||||
|
# Attachments apply to every generated task so they're available
|
||||||
|
# at RUN time too, not just visible to the planner.
|
||||||
|
for t in planned:
|
||||||
|
t["input"]["file_paths"] = list(files)
|
||||||
|
t["input"]["links"] = list(links)
|
||||||
|
return {"tasks": planned}
|
||||||
|
|
||||||
|
w = AgentWorker(job)
|
||||||
|
w.finished_ok.connect(self._on_planned)
|
||||||
|
w.failed.connect(self._on_failed)
|
||||||
|
self._worker = w
|
||||||
|
w.start()
|
||||||
|
|
||||||
|
def _on_planned(self, result: dict) -> None:
|
||||||
|
self._worker = None
|
||||||
|
self.gen_btn.setEnabled(True)
|
||||||
|
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
||||||
|
self._planned = result.get("tasks") or []
|
||||||
|
lines = []
|
||||||
|
for i, t in enumerate(self._planned, 1):
|
||||||
|
sched = t.get("schedule", {})
|
||||||
|
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
||||||
|
dep = t.get("dependency", {})
|
||||||
|
chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else ""
|
||||||
|
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
||||||
|
f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n"
|
||||||
|
f" {t.get('description', '')[:150]}")
|
||||||
|
self.preview.setPlainText("\n\n".join(lines))
|
||||||
|
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned))
|
||||||
|
|
||||||
|
def _on_failed(self, err: str) -> None:
|
||||||
|
self._worker = None
|
||||||
|
self.gen_btn.setEnabled(True)
|
||||||
|
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
||||||
|
self.preview.setPlainText(str(err))
|
||||||
|
|
||||||
|
def _confirm(self) -> None:
|
||||||
|
project_id = self.workspace_combo.currentData() or ""
|
||||||
|
for t in self._planned:
|
||||||
|
t["project_id"] = project_id
|
||||||
|
self.created_tasks = self._planned
|
||||||
|
self.accept()
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Nhập task từ file — R08-T11.
|
||||||
|
|
||||||
|
Tab thứ hai của hộp thoại tạo task: tải mẫu về, điền, rồi kéo file vào hoặc
|
||||||
|
chọn từ máy. Xem trước nội dung đọc được trước khi tạo, vì một file sai định
|
||||||
|
dạng có thể sinh ra hàng chục task rác.
|
||||||
|
|
||||||
|
Là mixin chứ không phải hộp thoại rời: nó dùng chung phần xem trước và nút
|
||||||
|
Xác nhận với tab tạo bằng AI, tách hẳn thì phải nhân đôi cả hai.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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
|
||||||
|
from .kanban_board_widget import _DropZone
|
||||||
|
|
||||||
|
|
||||||
|
class TaskImportMixin:
|
||||||
|
"""Nhập task từ file. Trộn vào _AiCreateDialog."""
|
||||||
|
|
||||||
|
def _export_template(self) -> None:
|
||||||
|
from PySide6.QtWidgets import QFileDialog
|
||||||
|
|
||||||
|
from ..core.task_excel import export_template
|
||||||
|
|
||||||
|
path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self, tr("schedtask.export_template_btn"),
|
||||||
|
"cowork_tasks_template.xlsx", "Excel (*.xlsx)")
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
export_template(path)
|
||||||
|
open_path(str(Path(path).parent))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc))
|
||||||
|
def _pick_import_file(self) -> None:
|
||||||
|
from PySide6.QtWidgets import QFileDialog
|
||||||
|
|
||||||
|
from ..core.task_import import IMPORT_FILTER
|
||||||
|
|
||||||
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self, tr("schedtask.import_pick_btn"), "", IMPORT_FILTER)
|
||||||
|
if path:
|
||||||
|
self._load_import_file(path)
|
||||||
|
def _load_import_file(self, path: str) -> None:
|
||||||
|
from ..core.task_import import import_tasks
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._planned = import_tasks(path)
|
||||||
|
except ValueError as exc:
|
||||||
|
self.import_preview.setPlainText(str(exc))
|
||||||
|
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||||
|
return
|
||||||
|
by_id = {t["task_id"]: t["title"] for t in self._planned}
|
||||||
|
lines = []
|
||||||
|
for i, t in enumerate(self._planned, 1):
|
||||||
|
sched = t.get("schedule", {})
|
||||||
|
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
||||||
|
deps = t.get("dependency", {}).get("depends_on") or []
|
||||||
|
dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else ""
|
||||||
|
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
||||||
|
f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}")
|
||||||
|
self.import_preview.setPlainText("\n\n".join(lines))
|
||||||
|
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned))
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""Calendar view for Schedule Task — an alternative to the Kanban board:
|
||||||
|
Week / Month / Year granularity, each task placed on its scheduled date
|
||||||
|
(``schedule.run_at``). Click a task to edit it (same editor the Kanban
|
||||||
|
board's double-click opens); click a day's "+" to create a task pre-filled
|
||||||
|
with that date. All grid/date math lives in ``core/calendar_grid.py`` (no Qt,
|
||||||
|
directly unit-testable) — this module is just the Qt rendering of it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, Signal
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QComboBox, QFrame, QGridLayout, QHBoxLayout, QLabel, QListWidget,
|
||||||
|
QListWidgetItem, QPushButton, QScrollArea, QVBoxLayout, QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ...core.calendar_grid import (
|
||||||
|
GRANULARITIES, group_tasks_by_date, month_grid, month_task_counts, shift_period, week_days,
|
||||||
|
)
|
||||||
|
from ...i18n import on_language_changed, tr
|
||||||
|
from ...theme import current_palette
|
||||||
|
from ...ui.icons import icon
|
||||||
|
|
||||||
|
_WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
||||||
|
|
||||||
|
|
||||||
|
class _DayCell(QFrame):
|
||||||
|
add_requested = Signal(str) # "YYYY-MM-DD"
|
||||||
|
task_clicked = Signal(str) # task_id
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setObjectName("dayCell")
|
||||||
|
self.setFrameShape(QFrame.StyledPanel)
|
||||||
|
self._date_str = ""
|
||||||
|
lay = QVBoxLayout(self)
|
||||||
|
lay.setContentsMargins(4, 4, 4, 4)
|
||||||
|
lay.setSpacing(2)
|
||||||
|
head = QHBoxLayout()
|
||||||
|
self.date_lbl = QLabel()
|
||||||
|
self.add_btn = QPushButton("+")
|
||||||
|
self.add_btn.setFixedSize(20, 20)
|
||||||
|
self.add_btn.clicked.connect(lambda: self.add_requested.emit(self._date_str))
|
||||||
|
head.addWidget(self.date_lbl, 1)
|
||||||
|
head.addWidget(self.add_btn)
|
||||||
|
lay.addLayout(head)
|
||||||
|
self.list = QListWidget()
|
||||||
|
self.list.setFrameShape(QFrame.NoFrame)
|
||||||
|
# Transparent so the cell's today/weekend tint shows through the task area.
|
||||||
|
self.list.setStyleSheet("background: transparent;")
|
||||||
|
self.list.itemClicked.connect(self._on_item_clicked)
|
||||||
|
lay.addWidget(self.list, 1)
|
||||||
|
|
||||||
|
def set_day(self, d: date, tasks: List[dict], dim: bool,
|
||||||
|
today: bool = False, weekend: bool = False) -> None:
|
||||||
|
self._date_str = d.isoformat()
|
||||||
|
self.date_lbl.setText(str(d.day))
|
||||||
|
p = current_palette()
|
||||||
|
num_color = p.accent if today else (p.text_faint if dim else p.text)
|
||||||
|
self.date_lbl.setStyleSheet(f"font-weight:600; color:{num_color};")
|
||||||
|
# Today is the only cell that gets a filled surface + accent border;
|
||||||
|
# weekends are set apart by a recessed surface alone, so the eye lands
|
||||||
|
# on "today" first and on the weekend block only when scanning.
|
||||||
|
r = p.radius
|
||||||
|
if today:
|
||||||
|
css = (f"#dayCell {{ background: {p.accent_soft}; "
|
||||||
|
f"border: 1px solid {p.accent}; border-radius: {r}px; }}")
|
||||||
|
elif weekend:
|
||||||
|
css = (f"#dayCell {{ background: {p.surface}; "
|
||||||
|
f"border: 1px solid {p.border}; border-radius: {r}px; }}")
|
||||||
|
else:
|
||||||
|
css = f"#dayCell {{ border: 1px solid {p.border}; border-radius: {r}px; }}"
|
||||||
|
self.setStyleSheet(css)
|
||||||
|
self.list.clear()
|
||||||
|
for t in tasks:
|
||||||
|
item = QListWidgetItem(t.get("title") or tr("schedtask.no_title"))
|
||||||
|
item.setData(Qt.UserRole, t.get("task_id"))
|
||||||
|
self.list.addItem(item)
|
||||||
|
|
||||||
|
def _on_item_clicked(self, item: QListWidgetItem) -> None:
|
||||||
|
tid = item.data(Qt.UserRole)
|
||||||
|
if tid:
|
||||||
|
self.task_clicked.emit(tid)
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarView(QWidget):
|
||||||
|
add_task_on_date = Signal(str) # "YYYY-MM-DD"
|
||||||
|
edit_task = Signal(str) # task_id
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.granularity = "month"
|
||||||
|
self.anchor = date.today()
|
||||||
|
self._tasks: List[dict] = []
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
head = QHBoxLayout()
|
||||||
|
self.prev_btn = QPushButton()
|
||||||
|
self.prev_btn.setIcon(icon("chevron-left"))
|
||||||
|
self.prev_btn.clicked.connect(lambda: self._shift(-1))
|
||||||
|
self.today_btn = QPushButton()
|
||||||
|
self.today_btn.clicked.connect(self._go_today)
|
||||||
|
self.next_btn = QPushButton()
|
||||||
|
self.next_btn.setIcon(icon("chevron-right"))
|
||||||
|
self.next_btn.clicked.connect(lambda: self._shift(1))
|
||||||
|
self.period_lbl = QLabel()
|
||||||
|
self.period_lbl.setStyleSheet("font-weight:700;")
|
||||||
|
self.granularity_combo = QComboBox()
|
||||||
|
for g in GRANULARITIES:
|
||||||
|
self.granularity_combo.addItem("", g)
|
||||||
|
self.granularity_combo.currentIndexChanged.connect(self._on_granularity_changed)
|
||||||
|
head.addWidget(self.prev_btn)
|
||||||
|
head.addWidget(self.today_btn)
|
||||||
|
head.addWidget(self.next_btn)
|
||||||
|
head.addWidget(self.period_lbl, 1)
|
||||||
|
head.addWidget(self.granularity_combo)
|
||||||
|
root.addLayout(head)
|
||||||
|
|
||||||
|
scroll = QScrollArea()
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
self._grid_host = QWidget()
|
||||||
|
self._grid = QGridLayout(self._grid_host)
|
||||||
|
self._grid.setSpacing(4)
|
||||||
|
scroll.setWidget(self._grid_host)
|
||||||
|
root.addWidget(scroll, 1)
|
||||||
|
|
||||||
|
on_language_changed(self._retranslate)
|
||||||
|
self._retranslate()
|
||||||
|
|
||||||
|
def _retranslate(self) -> None:
|
||||||
|
self.today_btn.setText(tr("schedtask.cal_today"))
|
||||||
|
self.prev_btn.setToolTip(tr("schedtask.cal_prev"))
|
||||||
|
self.next_btn.setToolTip(tr("schedtask.cal_next"))
|
||||||
|
for i, g in enumerate(GRANULARITIES):
|
||||||
|
self.granularity_combo.setItemText(i, tr(f"schedtask.cal_gran.{g}"))
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
# ---- public ------------------------------------------------------
|
||||||
|
def set_tasks(self, tasks: List[dict]) -> None:
|
||||||
|
self._tasks = tasks
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def show_month(self, year: int, month: int) -> None:
|
||||||
|
"""Switch to Month view centered on (year, month) — used when the
|
||||||
|
user drills down from a Year-view row."""
|
||||||
|
self.anchor = date(year, month, 1)
|
||||||
|
self.granularity = "month"
|
||||||
|
idx = self.granularity_combo.findData("month")
|
||||||
|
if idx >= 0:
|
||||||
|
self.granularity_combo.blockSignals(True)
|
||||||
|
self.granularity_combo.setCurrentIndex(idx)
|
||||||
|
self.granularity_combo.blockSignals(False)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
# ---- navigation ---------------------------------------------------
|
||||||
|
def _shift(self, direction: int) -> None:
|
||||||
|
self.anchor = shift_period(self.anchor, self.granularity, direction)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _go_today(self) -> None:
|
||||||
|
self.anchor = date.today()
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _on_granularity_changed(self) -> None:
|
||||||
|
data = self.granularity_combo.currentData()
|
||||||
|
if data:
|
||||||
|
self.granularity = data
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
# ---- rendering ------------------------------------------------------
|
||||||
|
def _clear_grid(self) -> None:
|
||||||
|
while self._grid.count():
|
||||||
|
item = self._grid.takeAt(0)
|
||||||
|
w = item.widget()
|
||||||
|
if w is not None:
|
||||||
|
w.deleteLater()
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
self._update_period_label()
|
||||||
|
self._clear_grid()
|
||||||
|
by_date = group_tasks_by_date(self._tasks)
|
||||||
|
if self.granularity == "week":
|
||||||
|
self._render_days(week_days(self.anchor), by_date)
|
||||||
|
elif self.granularity == "year":
|
||||||
|
self._render_year(by_date)
|
||||||
|
else:
|
||||||
|
self._render_days(sum(month_grid(self.anchor), []), by_date, mark_month=self.anchor.month)
|
||||||
|
|
||||||
|
def _render_days(self, days: List[date], by_date: Dict[str, List[dict]],
|
||||||
|
mark_month: Optional[int] = None) -> None:
|
||||||
|
for col, key in enumerate(_WEEKDAY_KEYS):
|
||||||
|
lbl = QLabel(tr(f"schedtask.cal_weekday.{key}"))
|
||||||
|
lbl.setStyleSheet("font-weight:600;")
|
||||||
|
lbl.setAlignment(Qt.AlignCenter)
|
||||||
|
self._grid.addWidget(lbl, 0, col)
|
||||||
|
today = date.today()
|
||||||
|
rows = [days[i:i + 7] for i in range(0, len(days), 7)]
|
||||||
|
for r, week in enumerate(rows, start=1):
|
||||||
|
for c, d in enumerate(week):
|
||||||
|
cell = _DayCell()
|
||||||
|
dim = mark_month is not None and d.month != mark_month
|
||||||
|
# _WEEKDAY_KEYS is Mon..Sun → columns 5 (Sat) and 6 (Sun) are the weekend.
|
||||||
|
cell.set_day(d, by_date.get(d.isoformat(), []), dim,
|
||||||
|
today=(d == today), weekend=(c in (5, 6)))
|
||||||
|
cell.add_requested.connect(self.add_task_on_date.emit)
|
||||||
|
cell.task_clicked.connect(self.edit_task.emit)
|
||||||
|
self._grid.addWidget(cell, r, c)
|
||||||
|
|
||||||
|
def _render_year(self, by_date: Dict[str, List[dict]]) -> None:
|
||||||
|
counts = month_task_counts(by_date, self.anchor.year)
|
||||||
|
lst = QListWidget()
|
||||||
|
for m in range(1, 13):
|
||||||
|
label = date(self.anchor.year, m, 1).strftime("%B")
|
||||||
|
n = counts[m]
|
||||||
|
text = tr("schedtask.cal_month_count", month=label, n=n) if n else label
|
||||||
|
item = QListWidgetItem(text)
|
||||||
|
item.setData(Qt.UserRole, m)
|
||||||
|
lst.addItem(item)
|
||||||
|
lst.itemClicked.connect(lambda item: self.show_month(self.anchor.year, item.data(Qt.UserRole)))
|
||||||
|
self._grid.addWidget(lst, 0, 0)
|
||||||
|
|
||||||
|
def _update_period_label(self) -> None:
|
||||||
|
if self.granularity == "week":
|
||||||
|
days = week_days(self.anchor)
|
||||||
|
self.period_lbl.setText(f"{days[0].isoformat()} - {days[-1].isoformat()}")
|
||||||
|
elif self.granularity == "year":
|
||||||
|
self.period_lbl.setText(str(self.anchor.year))
|
||||||
|
else:
|
||||||
|
self.period_lbl.setText(self.anchor.strftime("%Y-%m"))
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Bảng Kanban 7 cột kéo thả — R08-T11.
|
||||||
|
|
||||||
|
Bảy trạng thái task xếp thành bảy cột. Kéo thẻ sang cột khác là **đổi trạng
|
||||||
|
thái thật**, không phải chỉ dời chỗ trên màn hình — thả vào cột "Đang chạy"
|
||||||
|
là task chạy ngay.
|
||||||
|
|
||||||
|
``_DropZone`` là vùng nhận file kéo vào, dùng chung với hộp thoại nhập task.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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 _KanbanColumn(QListWidget):
|
||||||
|
"""One status lane. Accepts drops from sibling columns; a drop means
|
||||||
|
'move this task to my status'."""
|
||||||
|
|
||||||
|
task_dropped = Signal(str, str) # task_id, new_status
|
||||||
|
|
||||||
|
def __init__(self, status: str):
|
||||||
|
super().__init__()
|
||||||
|
self.status = status
|
||||||
|
self.setDragDropMode(QAbstractItemView.DragDrop)
|
||||||
|
self.setDefaultDropAction(Qt.MoveAction)
|
||||||
|
# Shift/Ctrl-click several cards in the SAME column, then right-click
|
||||||
|
# → "Delete N selected" to bulk-remove tasks instead of one at a time.
|
||||||
|
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||||
|
self.setWordWrap(True)
|
||||||
|
# Cards wrap, so there is never anything to reach by scrolling sideways
|
||||||
|
# — but QListWidget's own column hint runs 1-6px past the viewport, and
|
||||||
|
# a lane sprouted a horizontal scrollbar at 36 of 38 window widths I
|
||||||
|
# measured. Which lanes grew one changed with the width, which is why it
|
||||||
|
# looked like it depended on the screen.
|
||||||
|
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||||
|
self.setResizeMode(QListWidget.Adjust) # re-wrap on every resize
|
||||||
|
# No pixel floor here. A fixed one is always wrong on some screen:
|
||||||
|
# 190 lost the seventh lane, 150 still wanted 1242px where a 1280
|
||||||
|
# window leaves 1091 — so the 1280 monitor scrolled sideways and the
|
||||||
|
# 1920 one did not, same app, same build. The board divides whatever
|
||||||
|
# width it has by seven instead; see _fit_lanes().
|
||||||
|
|
||||||
|
def dropEvent(self, event): # noqa: N802
|
||||||
|
source = event.source()
|
||||||
|
if isinstance(source, _KanbanColumn) and source is not self:
|
||||||
|
item = source.currentItem()
|
||||||
|
tid = item.data(Qt.UserRole) if item else None
|
||||||
|
if tid:
|
||||||
|
event.acceptProposedAction()
|
||||||
|
self.task_dropped.emit(tid, self.status)
|
||||||
|
return
|
||||||
|
event.ignore()
|
||||||
|
|
||||||
|
|
||||||
|
class _DropZone(QLabel):
|
||||||
|
"""Drag-an-.xlsx-here area for the Import tab."""
|
||||||
|
|
||||||
|
file_dropped = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setAlignment(Qt.AlignCenter)
|
||||||
|
self.setMinimumHeight(70)
|
||||||
|
_p = current_palette()
|
||||||
|
self.setStyleSheet(
|
||||||
|
f"QLabel {{ border: 1px dashed {_p.border_strong};"
|
||||||
|
f" border-radius: {_p.radius_lg}px;"
|
||||||
|
f" color: {_p.text_muted}; padding: 10px; }}")
|
||||||
|
self.setAcceptDrops(True)
|
||||||
|
|
||||||
|
def dragEnterEvent(self, event): # noqa: N802
|
||||||
|
urls = event.mimeData().urls()
|
||||||
|
if urls and urls[0].toLocalFile().lower().endswith(
|
||||||
|
(".xlsx", ".xlsm", ".xls", ".csv", ".json")):
|
||||||
|
event.acceptProposedAction()
|
||||||
|
|
||||||
|
def dropEvent(self, event): # noqa: N802
|
||||||
|
urls = event.mimeData().urls()
|
||||||
|
if urls:
|
||||||
|
self.file_dropped.emit(urls[0].toLocalFile())
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Lịch sử các lượt chạy của một task — R08-T11.
|
||||||
|
|
||||||
|
Mở từ menu chuột phải trên thẻ Kanban. Chỉ đọc: liệt kê từng lượt đã chạy,
|
||||||
|
kết quả và log.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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 _RunHistoryDialog(QDialog):
|
||||||
|
"""Run history of one task as a table (newest first): time, status, error;
|
||||||
|
double-click a row to open that run's artifact folder."""
|
||||||
|
|
||||||
|
def __init__(self, task: dict, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._task = task
|
||||||
|
self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}")
|
||||||
|
self.resize(620, 380)
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
hint = QLabel(tr("schedtask.hist_hint"))
|
||||||
|
hint.setObjectName("hint")
|
||||||
|
root.addWidget(hint)
|
||||||
|
|
||||||
|
runs = list(reversed(task.get("runs", []) or []))
|
||||||
|
self.table = QTableWidget(len(runs), 4)
|
||||||
|
self.table.setHorizontalHeaderLabels([
|
||||||
|
tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"),
|
||||||
|
tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"),
|
||||||
|
])
|
||||||
|
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||||
|
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
|
for row, run in enumerate(runs):
|
||||||
|
ok = run.get("status") == "success"
|
||||||
|
cells = (
|
||||||
|
run.get("finished_at", ""),
|
||||||
|
str(run.get("status", "")),
|
||||||
|
run.get("run_id", ""),
|
||||||
|
(run.get("error") or "")[:200],
|
||||||
|
)
|
||||||
|
for col, text in enumerate(cells):
|
||||||
|
item = QTableWidgetItem(str(text))
|
||||||
|
if col == 0:
|
||||||
|
item.setData(Qt.UserRole, run.get("run_id", ""))
|
||||||
|
self.table.setItem(row, col, item)
|
||||||
|
self.table.resizeColumnsToContents()
|
||||||
|
self.table.horizontalHeader().setStretchLastSection(True)
|
||||||
|
self.table.itemDoubleClicked.connect(self._open_artifact)
|
||||||
|
root.addWidget(self.table, 1)
|
||||||
|
|
||||||
|
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
||||||
|
buttons.rejected.connect(self.reject)
|
||||||
|
buttons.accepted.connect(self.accept)
|
||||||
|
root.addWidget(buttons)
|
||||||
|
|
||||||
|
def _open_artifact(self, item: QTableWidgetItem) -> None:
|
||||||
|
first = self.table.item(item.row(), 0)
|
||||||
|
run_id = first.data(Qt.UserRole) if first else ""
|
||||||
|
if not run_id:
|
||||||
|
return
|
||||||
|
folder = taskrepo.ARTIFACTS_DIR / self._task["task_id"] / run_id
|
||||||
|
if folder.exists():
|
||||||
|
open_path(str(folder))
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""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 .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 .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 .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)))
|
||||||
+6
-227
@@ -1,231 +1,10 @@
|
|||||||
"""Calendar view for Schedule Task — an alternative to the Kanban board:
|
"""Vỏ chuyển tiếp — R08-T11.
|
||||||
Week / Month / Year granularity, each task placed on its scheduled date
|
|
||||||
(``schedule.run_at``). Click a task to edit it (same editor the Kanban
|
Phần thân đã chuyển sang ``presentation/scheduling/calendar_view_widget.py``.
|
||||||
board's double-click opens); click a day's "+" to create a task pre-filled
|
Giữ đường import cũ cho ``ui/schedule_task_tab.py`` và checker.
|
||||||
with that date. All grid/date math lives in ``core/calendar_grid.py`` (no Qt,
|
|
||||||
directly unit-testable) — this module is just the Qt rendering of it.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from ..presentation.scheduling.calendar_view_widget import CalendarView, _DayCell # noqa: F401
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, Signal
|
__all__ = ["CalendarView"]
|
||||||
from PySide6.QtWidgets import (
|
|
||||||
QComboBox, QFrame, QGridLayout, QHBoxLayout, QLabel, QListWidget,
|
|
||||||
QListWidgetItem, QPushButton, QScrollArea, QVBoxLayout, QWidget,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ..core.calendar_grid import (
|
|
||||||
GRANULARITIES, group_tasks_by_date, month_grid, month_task_counts, shift_period, week_days,
|
|
||||||
)
|
|
||||||
from ..i18n import on_language_changed, tr
|
|
||||||
from ..theme import current_palette
|
|
||||||
from .icons import icon
|
|
||||||
|
|
||||||
_WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
|
||||||
|
|
||||||
|
|
||||||
class _DayCell(QFrame):
|
|
||||||
add_requested = Signal(str) # "YYYY-MM-DD"
|
|
||||||
task_clicked = Signal(str) # task_id
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.setObjectName("dayCell")
|
|
||||||
self.setFrameShape(QFrame.StyledPanel)
|
|
||||||
self._date_str = ""
|
|
||||||
lay = QVBoxLayout(self)
|
|
||||||
lay.setContentsMargins(4, 4, 4, 4)
|
|
||||||
lay.setSpacing(2)
|
|
||||||
head = QHBoxLayout()
|
|
||||||
self.date_lbl = QLabel()
|
|
||||||
self.add_btn = QPushButton("+")
|
|
||||||
self.add_btn.setFixedSize(20, 20)
|
|
||||||
self.add_btn.clicked.connect(lambda: self.add_requested.emit(self._date_str))
|
|
||||||
head.addWidget(self.date_lbl, 1)
|
|
||||||
head.addWidget(self.add_btn)
|
|
||||||
lay.addLayout(head)
|
|
||||||
self.list = QListWidget()
|
|
||||||
self.list.setFrameShape(QFrame.NoFrame)
|
|
||||||
# Transparent so the cell's today/weekend tint shows through the task area.
|
|
||||||
self.list.setStyleSheet("background: transparent;")
|
|
||||||
self.list.itemClicked.connect(self._on_item_clicked)
|
|
||||||
lay.addWidget(self.list, 1)
|
|
||||||
|
|
||||||
def set_day(self, d: date, tasks: List[dict], dim: bool,
|
|
||||||
today: bool = False, weekend: bool = False) -> None:
|
|
||||||
self._date_str = d.isoformat()
|
|
||||||
self.date_lbl.setText(str(d.day))
|
|
||||||
p = current_palette()
|
|
||||||
num_color = p.accent if today else (p.text_faint if dim else p.text)
|
|
||||||
self.date_lbl.setStyleSheet(f"font-weight:600; color:{num_color};")
|
|
||||||
# Today is the only cell that gets a filled surface + accent border;
|
|
||||||
# weekends are set apart by a recessed surface alone, so the eye lands
|
|
||||||
# on "today" first and on the weekend block only when scanning.
|
|
||||||
r = p.radius
|
|
||||||
if today:
|
|
||||||
css = (f"#dayCell {{ background: {p.accent_soft}; "
|
|
||||||
f"border: 1px solid {p.accent}; border-radius: {r}px; }}")
|
|
||||||
elif weekend:
|
|
||||||
css = (f"#dayCell {{ background: {p.surface}; "
|
|
||||||
f"border: 1px solid {p.border}; border-radius: {r}px; }}")
|
|
||||||
else:
|
|
||||||
css = f"#dayCell {{ border: 1px solid {p.border}; border-radius: {r}px; }}"
|
|
||||||
self.setStyleSheet(css)
|
|
||||||
self.list.clear()
|
|
||||||
for t in tasks:
|
|
||||||
item = QListWidgetItem(t.get("title") or tr("schedtask.no_title"))
|
|
||||||
item.setData(Qt.UserRole, t.get("task_id"))
|
|
||||||
self.list.addItem(item)
|
|
||||||
|
|
||||||
def _on_item_clicked(self, item: QListWidgetItem) -> None:
|
|
||||||
tid = item.data(Qt.UserRole)
|
|
||||||
if tid:
|
|
||||||
self.task_clicked.emit(tid)
|
|
||||||
|
|
||||||
|
|
||||||
class CalendarView(QWidget):
|
|
||||||
add_task_on_date = Signal(str) # "YYYY-MM-DD"
|
|
||||||
edit_task = Signal(str) # task_id
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.granularity = "month"
|
|
||||||
self.anchor = date.today()
|
|
||||||
self._tasks: List[dict] = []
|
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
|
||||||
head = QHBoxLayout()
|
|
||||||
self.prev_btn = QPushButton()
|
|
||||||
self.prev_btn.setIcon(icon("chevron-left"))
|
|
||||||
self.prev_btn.clicked.connect(lambda: self._shift(-1))
|
|
||||||
self.today_btn = QPushButton()
|
|
||||||
self.today_btn.clicked.connect(self._go_today)
|
|
||||||
self.next_btn = QPushButton()
|
|
||||||
self.next_btn.setIcon(icon("chevron-right"))
|
|
||||||
self.next_btn.clicked.connect(lambda: self._shift(1))
|
|
||||||
self.period_lbl = QLabel()
|
|
||||||
self.period_lbl.setStyleSheet("font-weight:700;")
|
|
||||||
self.granularity_combo = QComboBox()
|
|
||||||
for g in GRANULARITIES:
|
|
||||||
self.granularity_combo.addItem("", g)
|
|
||||||
self.granularity_combo.currentIndexChanged.connect(self._on_granularity_changed)
|
|
||||||
head.addWidget(self.prev_btn)
|
|
||||||
head.addWidget(self.today_btn)
|
|
||||||
head.addWidget(self.next_btn)
|
|
||||||
head.addWidget(self.period_lbl, 1)
|
|
||||||
head.addWidget(self.granularity_combo)
|
|
||||||
root.addLayout(head)
|
|
||||||
|
|
||||||
scroll = QScrollArea()
|
|
||||||
scroll.setWidgetResizable(True)
|
|
||||||
self._grid_host = QWidget()
|
|
||||||
self._grid = QGridLayout(self._grid_host)
|
|
||||||
self._grid.setSpacing(4)
|
|
||||||
scroll.setWidget(self._grid_host)
|
|
||||||
root.addWidget(scroll, 1)
|
|
||||||
|
|
||||||
on_language_changed(self._retranslate)
|
|
||||||
self._retranslate()
|
|
||||||
|
|
||||||
def _retranslate(self) -> None:
|
|
||||||
self.today_btn.setText(tr("schedtask.cal_today"))
|
|
||||||
self.prev_btn.setToolTip(tr("schedtask.cal_prev"))
|
|
||||||
self.next_btn.setToolTip(tr("schedtask.cal_next"))
|
|
||||||
for i, g in enumerate(GRANULARITIES):
|
|
||||||
self.granularity_combo.setItemText(i, tr(f"schedtask.cal_gran.{g}"))
|
|
||||||
self._render()
|
|
||||||
|
|
||||||
# ---- public ------------------------------------------------------
|
|
||||||
def set_tasks(self, tasks: List[dict]) -> None:
|
|
||||||
self._tasks = tasks
|
|
||||||
self._render()
|
|
||||||
|
|
||||||
def show_month(self, year: int, month: int) -> None:
|
|
||||||
"""Switch to Month view centered on (year, month) — used when the
|
|
||||||
user drills down from a Year-view row."""
|
|
||||||
self.anchor = date(year, month, 1)
|
|
||||||
self.granularity = "month"
|
|
||||||
idx = self.granularity_combo.findData("month")
|
|
||||||
if idx >= 0:
|
|
||||||
self.granularity_combo.blockSignals(True)
|
|
||||||
self.granularity_combo.setCurrentIndex(idx)
|
|
||||||
self.granularity_combo.blockSignals(False)
|
|
||||||
self._render()
|
|
||||||
|
|
||||||
# ---- navigation ---------------------------------------------------
|
|
||||||
def _shift(self, direction: int) -> None:
|
|
||||||
self.anchor = shift_period(self.anchor, self.granularity, direction)
|
|
||||||
self._render()
|
|
||||||
|
|
||||||
def _go_today(self) -> None:
|
|
||||||
self.anchor = date.today()
|
|
||||||
self._render()
|
|
||||||
|
|
||||||
def _on_granularity_changed(self) -> None:
|
|
||||||
data = self.granularity_combo.currentData()
|
|
||||||
if data:
|
|
||||||
self.granularity = data
|
|
||||||
self._render()
|
|
||||||
|
|
||||||
# ---- rendering ------------------------------------------------------
|
|
||||||
def _clear_grid(self) -> None:
|
|
||||||
while self._grid.count():
|
|
||||||
item = self._grid.takeAt(0)
|
|
||||||
w = item.widget()
|
|
||||||
if w is not None:
|
|
||||||
w.deleteLater()
|
|
||||||
|
|
||||||
def _render(self) -> None:
|
|
||||||
self._update_period_label()
|
|
||||||
self._clear_grid()
|
|
||||||
by_date = group_tasks_by_date(self._tasks)
|
|
||||||
if self.granularity == "week":
|
|
||||||
self._render_days(week_days(self.anchor), by_date)
|
|
||||||
elif self.granularity == "year":
|
|
||||||
self._render_year(by_date)
|
|
||||||
else:
|
|
||||||
self._render_days(sum(month_grid(self.anchor), []), by_date, mark_month=self.anchor.month)
|
|
||||||
|
|
||||||
def _render_days(self, days: List[date], by_date: Dict[str, List[dict]],
|
|
||||||
mark_month: Optional[int] = None) -> None:
|
|
||||||
for col, key in enumerate(_WEEKDAY_KEYS):
|
|
||||||
lbl = QLabel(tr(f"schedtask.cal_weekday.{key}"))
|
|
||||||
lbl.setStyleSheet("font-weight:600;")
|
|
||||||
lbl.setAlignment(Qt.AlignCenter)
|
|
||||||
self._grid.addWidget(lbl, 0, col)
|
|
||||||
today = date.today()
|
|
||||||
rows = [days[i:i + 7] for i in range(0, len(days), 7)]
|
|
||||||
for r, week in enumerate(rows, start=1):
|
|
||||||
for c, d in enumerate(week):
|
|
||||||
cell = _DayCell()
|
|
||||||
dim = mark_month is not None and d.month != mark_month
|
|
||||||
# _WEEKDAY_KEYS is Mon..Sun → columns 5 (Sat) and 6 (Sun) are the weekend.
|
|
||||||
cell.set_day(d, by_date.get(d.isoformat(), []), dim,
|
|
||||||
today=(d == today), weekend=(c in (5, 6)))
|
|
||||||
cell.add_requested.connect(self.add_task_on_date.emit)
|
|
||||||
cell.task_clicked.connect(self.edit_task.emit)
|
|
||||||
self._grid.addWidget(cell, r, c)
|
|
||||||
|
|
||||||
def _render_year(self, by_date: Dict[str, List[dict]]) -> None:
|
|
||||||
counts = month_task_counts(by_date, self.anchor.year)
|
|
||||||
lst = QListWidget()
|
|
||||||
for m in range(1, 13):
|
|
||||||
label = date(self.anchor.year, m, 1).strftime("%B")
|
|
||||||
n = counts[m]
|
|
||||||
text = tr("schedtask.cal_month_count", month=label, n=n) if n else label
|
|
||||||
item = QListWidgetItem(text)
|
|
||||||
item.setData(Qt.UserRole, m)
|
|
||||||
lst.addItem(item)
|
|
||||||
lst.itemClicked.connect(lambda item: self.show_month(self.anchor.year, item.data(Qt.UserRole)))
|
|
||||||
self._grid.addWidget(lst, 0, 0)
|
|
||||||
|
|
||||||
def _update_period_label(self) -> None:
|
|
||||||
if self.granularity == "week":
|
|
||||||
days = week_days(self.anchor)
|
|
||||||
self.period_lbl.setText(f"{days[0].isoformat()} - {days[-1].isoformat()}")
|
|
||||||
elif self.granularity == "year":
|
|
||||||
self.period_lbl.setText(str(self.anchor.year))
|
|
||||||
else:
|
|
||||||
self.period_lbl.setText(self.anchor.strftime("%Y-%m"))
|
|
||||||
|
|||||||
+6
-503
@@ -8,6 +8,11 @@ and AI Create Task (preview first — nothing is created until confirmed).
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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
|
import copy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
@@ -38,47 +43,9 @@ _VIEWS = ("kanban", "calendar")
|
|||||||
_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"}
|
_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"}
|
||||||
|
|
||||||
|
|
||||||
class _KanbanColumn(QListWidget):
|
|
||||||
"""One status lane. Accepts drops from sibling columns; a drop means
|
|
||||||
'move this task to my status'."""
|
|
||||||
|
|
||||||
task_dropped = Signal(str, str) # task_id, new_status
|
|
||||||
|
|
||||||
def __init__(self, status: str):
|
|
||||||
super().__init__()
|
|
||||||
self.status = status
|
|
||||||
self.setDragDropMode(QAbstractItemView.DragDrop)
|
|
||||||
self.setDefaultDropAction(Qt.MoveAction)
|
|
||||||
# Shift/Ctrl-click several cards in the SAME column, then right-click
|
|
||||||
# → "Delete N selected" to bulk-remove tasks instead of one at a time.
|
|
||||||
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
|
||||||
self.setWordWrap(True)
|
|
||||||
# Cards wrap, so there is never anything to reach by scrolling sideways
|
|
||||||
# — but QListWidget's own column hint runs 1-6px past the viewport, and
|
|
||||||
# a lane sprouted a horizontal scrollbar at 36 of 38 window widths I
|
|
||||||
# measured. Which lanes grew one changed with the width, which is why it
|
|
||||||
# looked like it depended on the screen.
|
|
||||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
||||||
self.setResizeMode(QListWidget.Adjust) # re-wrap on every resize
|
|
||||||
# No pixel floor here. A fixed one is always wrong on some screen:
|
|
||||||
# 190 lost the seventh lane, 150 still wanted 1242px where a 1280
|
|
||||||
# window leaves 1091 — so the 1280 monitor scrolled sideways and the
|
|
||||||
# 1920 one did not, same app, same build. The board divides whatever
|
|
||||||
# width it has by seven instead; see _fit_lanes().
|
|
||||||
|
|
||||||
def dropEvent(self, event): # noqa: N802
|
|
||||||
source = event.source()
|
|
||||||
if isinstance(source, _KanbanColumn) and source is not self:
|
|
||||||
item = source.currentItem()
|
|
||||||
tid = item.data(Qt.UserRole) if item else None
|
|
||||||
if tid:
|
|
||||||
event.acceptProposedAction()
|
|
||||||
self.task_dropped.emit(tid, self.status)
|
|
||||||
return
|
|
||||||
event.ignore()
|
|
||||||
|
|
||||||
|
|
||||||
class ScheduleTaskTab(QWidget):
|
class ScheduleTaskTab(TaskActionsMixin, QWidget):
|
||||||
status_message = Signal(str)
|
status_message = Signal(str)
|
||||||
|
|
||||||
def __init__(self, ctx: AppContext, scheduler=None):
|
def __init__(self, ctx: AppContext, scheduler=None):
|
||||||
@@ -208,16 +175,6 @@ class ScheduleTaskTab(QWidget):
|
|||||||
def _on_view_changed(self) -> None:
|
def _on_view_changed(self) -> None:
|
||||||
self._view_stack.setCurrentIndex(self.view_tabs.currentIndex())
|
self._view_stack.setCurrentIndex(self.view_tabs.currentIndex())
|
||||||
|
|
||||||
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 .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"))
|
|
||||||
|
|
||||||
# ---- lane widths ------------------------------------------------------
|
# ---- lane widths ------------------------------------------------------
|
||||||
#
|
#
|
||||||
@@ -300,32 +257,9 @@ class ScheduleTaskTab(QWidget):
|
|||||||
self.calendar.set_tasks(all_tasks)
|
self.calendar.set_tasks(all_tasks)
|
||||||
|
|
||||||
# ---- actions --------------------------------------------------------
|
# ---- actions --------------------------------------------------------
|
||||||
def _save_and_refresh(self, task: dict) -> None:
|
|
||||||
taskrepo.save_task(task, self._tasks_dir)
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
def _add_task(self) -> None:
|
|
||||||
from .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 .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 _on_task_dropped(self, task_id: str, new_status: str) -> None:
|
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:
|
"""Dropping a card into a lane ACTS on the task, not just relabels it:
|
||||||
@@ -361,434 +295,3 @@ class ScheduleTaskTab(QWidget):
|
|||||||
return
|
return
|
||||||
self._save_and_refresh(task)
|
self._save_and_refresh(task)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
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:
|
|
||||||
_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"])
|
|
||||||
|
|
||||||
# ---- AI create ----------------------------------------------------------
|
|
||||||
def _ai_create(self) -> None:
|
|
||||||
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)))
|
|
||||||
|
|
||||||
|
|
||||||
class _RunHistoryDialog(QDialog):
|
|
||||||
"""Run history of one task as a table (newest first): time, status, error;
|
|
||||||
double-click a row to open that run's artifact folder."""
|
|
||||||
|
|
||||||
def __init__(self, task: dict, parent=None):
|
|
||||||
super().__init__(parent)
|
|
||||||
self._task = task
|
|
||||||
self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}")
|
|
||||||
self.resize(620, 380)
|
|
||||||
root = QVBoxLayout(self)
|
|
||||||
hint = QLabel(tr("schedtask.hist_hint"))
|
|
||||||
hint.setObjectName("hint")
|
|
||||||
root.addWidget(hint)
|
|
||||||
|
|
||||||
runs = list(reversed(task.get("runs", []) or []))
|
|
||||||
self.table = QTableWidget(len(runs), 4)
|
|
||||||
self.table.setHorizontalHeaderLabels([
|
|
||||||
tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"),
|
|
||||||
tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"),
|
|
||||||
])
|
|
||||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
||||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
||||||
for row, run in enumerate(runs):
|
|
||||||
ok = run.get("status") == "success"
|
|
||||||
cells = (
|
|
||||||
run.get("finished_at", ""),
|
|
||||||
str(run.get("status", "")),
|
|
||||||
run.get("run_id", ""),
|
|
||||||
(run.get("error") or "")[:200],
|
|
||||||
)
|
|
||||||
for col, text in enumerate(cells):
|
|
||||||
item = QTableWidgetItem(str(text))
|
|
||||||
if col == 0:
|
|
||||||
item.setData(Qt.UserRole, run.get("run_id", ""))
|
|
||||||
self.table.setItem(row, col, item)
|
|
||||||
self.table.resizeColumnsToContents()
|
|
||||||
self.table.horizontalHeader().setStretchLastSection(True)
|
|
||||||
self.table.itemDoubleClicked.connect(self._open_artifact)
|
|
||||||
root.addWidget(self.table, 1)
|
|
||||||
|
|
||||||
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
|
||||||
buttons.rejected.connect(self.reject)
|
|
||||||
buttons.accepted.connect(self.accept)
|
|
||||||
root.addWidget(buttons)
|
|
||||||
|
|
||||||
def _open_artifact(self, item: QTableWidgetItem) -> None:
|
|
||||||
first = self.table.item(item.row(), 0)
|
|
||||||
run_id = first.data(Qt.UserRole) if first else ""
|
|
||||||
if not run_id:
|
|
||||||
return
|
|
||||||
folder = taskrepo.ARTIFACTS_DIR / self._task["task_id"] / run_id
|
|
||||||
if folder.exists():
|
|
||||||
open_path(str(folder))
|
|
||||||
|
|
||||||
|
|
||||||
class _DropZone(QLabel):
|
|
||||||
"""Drag-an-.xlsx-here area for the Import tab."""
|
|
||||||
|
|
||||||
file_dropped = Signal(str)
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.setAlignment(Qt.AlignCenter)
|
|
||||||
self.setMinimumHeight(70)
|
|
||||||
_p = current_palette()
|
|
||||||
self.setStyleSheet(
|
|
||||||
f"QLabel {{ border: 1px dashed {_p.border_strong};"
|
|
||||||
f" border-radius: {_p.radius_lg}px;"
|
|
||||||
f" color: {_p.text_muted}; padding: 10px; }}")
|
|
||||||
self.setAcceptDrops(True)
|
|
||||||
|
|
||||||
def dragEnterEvent(self, event): # noqa: N802
|
|
||||||
urls = event.mimeData().urls()
|
|
||||||
if urls and urls[0].toLocalFile().lower().endswith(
|
|
||||||
(".xlsx", ".xlsm", ".xls", ".csv", ".json")):
|
|
||||||
event.acceptProposedAction()
|
|
||||||
|
|
||||||
def dropEvent(self, event): # noqa: N802
|
|
||||||
urls = event.mimeData().urls()
|
|
||||||
if urls:
|
|
||||||
self.file_dropped.emit(urls[0].toLocalFile())
|
|
||||||
|
|
||||||
|
|
||||||
class _AiCreateDialog(QDialog):
|
|
||||||
"""Create tasks two ways, one tab each (both preview first — nothing is
|
|
||||||
saved until the user confirms): ✨ AI gen from a natural-language
|
|
||||||
description, or 📥 Import from a filled Excel template (pick or drag)."""
|
|
||||||
|
|
||||||
def __init__(self, ctx: AppContext, parent=None):
|
|
||||||
super().__init__(parent)
|
|
||||||
from PySide6.QtWidgets import QTabWidget
|
|
||||||
|
|
||||||
self.ctx = ctx
|
|
||||||
self.created_tasks: List[dict] = []
|
|
||||||
self._planned: List[dict] = []
|
|
||||||
self._worker: Optional[AgentWorker] = None
|
|
||||||
self.setWindowTitle(tr("schedtask.ai_btn"))
|
|
||||||
self.resize(600, 520)
|
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
|
||||||
ws_row = QHBoxLayout()
|
|
||||||
ws_row.addWidget(QLabel(tr("schedtask.f_workspace")))
|
|
||||||
self.workspace_combo = QComboBox()
|
|
||||||
self.workspace_combo.addItem(tr("schedtask.no_workspace"), "")
|
|
||||||
for p in list_projects():
|
|
||||||
self.workspace_combo.addItem(p.name, p.project_id)
|
|
||||||
self.workspace_combo.setToolTip(tr("schedtask.hint_workspace"))
|
|
||||||
ws_row.addWidget(self.workspace_combo, 1)
|
|
||||||
root.addLayout(ws_row)
|
|
||||||
self.tabs = QTabWidget()
|
|
||||||
root.addWidget(self.tabs, 1)
|
|
||||||
|
|
||||||
# ---- tab 1: AI gen ------------------------------------------------
|
|
||||||
ai_page = QWidget()
|
|
||||||
al = QVBoxLayout(ai_page)
|
|
||||||
al.addWidget(QLabel(tr("schedtask.ai_desc_label")))
|
|
||||||
self.desc_edit = QPlainTextEdit()
|
|
||||||
self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph"))
|
|
||||||
self.desc_edit.setMaximumHeight(110)
|
|
||||||
al.addWidget(self.desc_edit)
|
|
||||||
# Attachments (files + links) — merged into every task this generates,
|
|
||||||
# AND into the planning prompt so the AI knows they exist.
|
|
||||||
attach_row = QHBoxLayout()
|
|
||||||
self.ai_files_edit = QLineEdit()
|
|
||||||
self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder"))
|
|
||||||
ai_pick_btn = QPushButton(tr("schedtask.pick_files"))
|
|
||||||
ai_pick_btn.setIcon(icon("folder"))
|
|
||||||
ai_pick_btn.clicked.connect(self._ai_pick_files)
|
|
||||||
attach_row.addWidget(self.ai_files_edit, 1)
|
|
||||||
attach_row.addWidget(ai_pick_btn)
|
|
||||||
al.addWidget(QLabel(tr("schedtask.f_files")))
|
|
||||||
al.addLayout(attach_row)
|
|
||||||
self.ai_links_edit = QLineEdit()
|
|
||||||
self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder"))
|
|
||||||
al.addWidget(QLabel(tr("schedtask.f_links")))
|
|
||||||
al.addWidget(self.ai_links_edit)
|
|
||||||
self.gen_btn = QPushButton(tr("schedtask.ai_generate"))
|
|
||||||
self.gen_btn.setIcon(icon("sparkle"))
|
|
||||||
self.gen_btn.setObjectName("primary")
|
|
||||||
self.gen_btn.clicked.connect(self._generate)
|
|
||||||
al.addWidget(self.gen_btn)
|
|
||||||
al.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
|
||||||
self.preview = QPlainTextEdit()
|
|
||||||
self.preview.setReadOnly(True)
|
|
||||||
al.addWidget(self.preview, 1)
|
|
||||||
self.tabs.addTab(ai_page, tr("schedtask.tab_ai"))
|
|
||||||
|
|
||||||
# ---- tab 2: Import from Excel --------------------------------------
|
|
||||||
imp_page = QWidget()
|
|
||||||
il = QVBoxLayout(imp_page)
|
|
||||||
tpl_btn = QPushButton(tr("schedtask.export_template_btn"))
|
|
||||||
tpl_btn.setIcon(icon("upload"))
|
|
||||||
tpl_btn.clicked.connect(self._export_template)
|
|
||||||
il.addWidget(tpl_btn)
|
|
||||||
pick_row = QHBoxLayout()
|
|
||||||
pick_btn = QPushButton(tr("schedtask.import_pick_btn"))
|
|
||||||
pick_btn.setIcon(icon("folder"))
|
|
||||||
pick_btn.clicked.connect(self._pick_import_file)
|
|
||||||
pick_row.addWidget(pick_btn)
|
|
||||||
pick_row.addStretch(1)
|
|
||||||
il.addLayout(pick_row)
|
|
||||||
self.drop_zone = _DropZone()
|
|
||||||
self.drop_zone.setText(tr("schedtask.drop_hint"))
|
|
||||||
self.drop_zone.file_dropped.connect(self._load_import_file)
|
|
||||||
il.addWidget(self.drop_zone)
|
|
||||||
il.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
|
||||||
self.import_preview = QPlainTextEdit()
|
|
||||||
self.import_preview.setReadOnly(True)
|
|
||||||
il.addWidget(self.import_preview, 1)
|
|
||||||
self.tabs.addTab(imp_page, tr("schedtask.tab_import"))
|
|
||||||
|
|
||||||
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
|
||||||
self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm"))
|
|
||||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
|
|
||||||
self.buttons.accepted.connect(self._confirm)
|
|
||||||
self.buttons.rejected.connect(self.reject)
|
|
||||||
root.addWidget(self.buttons)
|
|
||||||
|
|
||||||
# ---- Import tab ------------------------------------------------------
|
|
||||||
def _export_template(self) -> None:
|
|
||||||
from PySide6.QtWidgets import QFileDialog
|
|
||||||
|
|
||||||
from ..core.task_excel import export_template
|
|
||||||
|
|
||||||
path, _ = QFileDialog.getSaveFileName(
|
|
||||||
self, tr("schedtask.export_template_btn"),
|
|
||||||
"cowork_tasks_template.xlsx", "Excel (*.xlsx)")
|
|
||||||
if not path:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
export_template(path)
|
|
||||||
open_path(str(Path(path).parent))
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc))
|
|
||||||
|
|
||||||
def _pick_import_file(self) -> None:
|
|
||||||
from PySide6.QtWidgets import QFileDialog
|
|
||||||
|
|
||||||
from ..core.task_import import IMPORT_FILTER
|
|
||||||
|
|
||||||
path, _ = QFileDialog.getOpenFileName(
|
|
||||||
self, tr("schedtask.import_pick_btn"), "", IMPORT_FILTER)
|
|
||||||
if path:
|
|
||||||
self._load_import_file(path)
|
|
||||||
|
|
||||||
def _load_import_file(self, path: str) -> None:
|
|
||||||
from ..core.task_import import import_tasks
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._planned = import_tasks(path)
|
|
||||||
except ValueError as exc:
|
|
||||||
self.import_preview.setPlainText(str(exc))
|
|
||||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
|
|
||||||
return
|
|
||||||
by_id = {t["task_id"]: t["title"] for t in self._planned}
|
|
||||||
lines = []
|
|
||||||
for i, t in enumerate(self._planned, 1):
|
|
||||||
sched = t.get("schedule", {})
|
|
||||||
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
|
||||||
deps = t.get("dependency", {}).get("depends_on") or []
|
|
||||||
dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else ""
|
|
||||||
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
|
||||||
f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}")
|
|
||||||
self.import_preview.setPlainText("\n\n".join(lines))
|
|
||||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned))
|
|
||||||
|
|
||||||
def _ai_pick_files(self) -> None:
|
|
||||||
from PySide6.QtWidgets import QFileDialog
|
|
||||||
|
|
||||||
files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files"))
|
|
||||||
if files:
|
|
||||||
existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()]
|
|
||||||
self.ai_files_edit.setText("; ".join(existing + files))
|
|
||||||
|
|
||||||
def _attached_files(self) -> List[str]:
|
|
||||||
return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()]
|
|
||||||
|
|
||||||
def _attached_links(self) -> List[str]:
|
|
||||||
return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()]
|
|
||||||
|
|
||||||
def _generate(self) -> None:
|
|
||||||
description = self.desc_edit.toPlainText().strip()
|
|
||||||
if not description or self._worker is not None:
|
|
||||||
return
|
|
||||||
files, links = self._attached_files(), self._attached_links()
|
|
||||||
self.gen_btn.setEnabled(False)
|
|
||||||
self.gen_btn.setText(tr("schedtask.ai_generating"))
|
|
||||||
|
|
||||||
def job(worker: AgentWorker):
|
|
||||||
from ..core.ai_task_planner import plan_tasks
|
|
||||||
|
|
||||||
provider = self.ctx.build_active_provider()
|
|
||||||
full_desc = description
|
|
||||||
if files or links:
|
|
||||||
attach_note = "; ".join(files + links)
|
|
||||||
full_desc += f"\n\n(Attached references available: {attach_note})"
|
|
||||||
planned = plan_tasks(provider, full_desc, cancel=worker.is_cancelled)
|
|
||||||
# Attachments apply to every generated task so they're available
|
|
||||||
# at RUN time too, not just visible to the planner.
|
|
||||||
for t in planned:
|
|
||||||
t["input"]["file_paths"] = list(files)
|
|
||||||
t["input"]["links"] = list(links)
|
|
||||||
return {"tasks": planned}
|
|
||||||
|
|
||||||
w = AgentWorker(job)
|
|
||||||
w.finished_ok.connect(self._on_planned)
|
|
||||||
w.failed.connect(self._on_failed)
|
|
||||||
self._worker = w
|
|
||||||
w.start()
|
|
||||||
|
|
||||||
def _on_planned(self, result: dict) -> None:
|
|
||||||
self._worker = None
|
|
||||||
self.gen_btn.setEnabled(True)
|
|
||||||
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
|
||||||
self._planned = result.get("tasks") or []
|
|
||||||
lines = []
|
|
||||||
for i, t in enumerate(self._planned, 1):
|
|
||||||
sched = t.get("schedule", {})
|
|
||||||
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
|
||||||
dep = t.get("dependency", {})
|
|
||||||
chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else ""
|
|
||||||
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
|
||||||
f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n"
|
|
||||||
f" {t.get('description', '')[:150]}")
|
|
||||||
self.preview.setPlainText("\n\n".join(lines))
|
|
||||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned))
|
|
||||||
|
|
||||||
def _on_failed(self, err: str) -> None:
|
|
||||||
self._worker = None
|
|
||||||
self.gen_btn.setEnabled(True)
|
|
||||||
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
|
||||||
self.preview.setPlainText(str(err))
|
|
||||||
|
|
||||||
def _confirm(self) -> None:
|
|
||||||
project_id = self.workspace_combo.currentData() or ""
|
|
||||||
for t in self._planned:
|
|
||||||
t["project_id"] = project_id
|
|
||||||
self.created_tasks = self._planned
|
|
||||||
self.accept()
|
|
||||||
|
|||||||
Reference in New Issue
Block a user