Feature/delta team/epic r04 #7
@@ -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:
|
||||
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.
|
||||
"""Vỏ chuyển tiếp — R08-T11.
|
||||
|
||||
Phần thân đã chuyển sang ``presentation/scheduling/calendar_view_widget.py``.
|
||||
Giữ đường import cũ cho ``ui/schedule_task_tab.py`` và checker.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Dict, List, Optional
|
||||
from ..presentation.scheduling.calendar_view_widget import CalendarView, _DayCell # noqa: F401
|
||||
|
||||
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 .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"))
|
||||
__all__ = ["CalendarView"]
|
||||
|
||||
+6
-503
@@ -8,6 +8,11 @@ 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
|
||||
@@ -38,47 +43,9 @@ _VIEWS = ("kanban", "calendar")
|
||||
_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)
|
||||
|
||||
def __init__(self, ctx: AppContext, scheduler=None):
|
||||
@@ -208,16 +175,6 @@ class ScheduleTaskTab(QWidget):
|
||||
def _on_view_changed(self) -> None:
|
||||
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 ------------------------------------------------------
|
||||
#
|
||||
@@ -300,32 +257,9 @@ class ScheduleTaskTab(QWidget):
|
||||
self.calendar.set_tasks(all_tasks)
|
||||
|
||||
# ---- 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:
|
||||
"""Dropping a card into a lane ACTS on the task, not just relabels it:
|
||||
@@ -361,434 +295,3 @@ class ScheduleTaskTab(QWidget):
|
||||
return
|
||||
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