diff --git a/presentation/scheduling/ai_task_creator_dialog.py b/presentation/scheduling/ai_task_creator_dialog.py new file mode 100644 index 0000000..dd1a5a3 --- /dev/null +++ b/presentation/scheduling/ai_task_creator_dialog.py @@ -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() diff --git a/presentation/scheduling/ai_task_import_dialog.py b/presentation/scheduling/ai_task_import_dialog.py new file mode 100644 index 0000000..5693a9d --- /dev/null +++ b/presentation/scheduling/ai_task_import_dialog.py @@ -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)) diff --git a/presentation/scheduling/calendar_view_widget.py b/presentation/scheduling/calendar_view_widget.py new file mode 100644 index 0000000..3d65731 --- /dev/null +++ b/presentation/scheduling/calendar_view_widget.py @@ -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")) diff --git a/presentation/scheduling/kanban_board_widget.py b/presentation/scheduling/kanban_board_widget.py new file mode 100644 index 0000000..a578833 --- /dev/null +++ b/presentation/scheduling/kanban_board_widget.py @@ -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()) diff --git a/presentation/scheduling/run_history_dialog.py b/presentation/scheduling/run_history_dialog.py new file mode 100644 index 0000000..571cfea --- /dev/null +++ b/presentation/scheduling/run_history_dialog.py @@ -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)) diff --git a/presentation/scheduling/task_actions.py b/presentation/scheduling/task_actions.py new file mode 100644 index 0000000..9877169 --- /dev/null +++ b/presentation/scheduling/task_actions.py @@ -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))) diff --git a/ui/calendar_view.py b/ui/calendar_view.py index 861e20a..61db27b 100644 --- a/ui/calendar_view.py +++ b/ui/calendar_view.py @@ -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"] diff --git a/ui/schedule_task_tab.py b/ui/schedule_task_tab.py index bcc2a58..d684f24 100644 --- a/ui/schedule_task_tab.py +++ b/ui/schedule_task_tab.py @@ -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()