merge: merge origin/feature/teamhoa/r05-r06 (R07/R08) into feature/delta-team/epic-R04
This commit is contained in:
@@ -1 +1,3 @@
|
||||
"""Presentation scheduling package: KanbanBoardWidget, CalendarViewWidget, AiTaskCreatorDialog."""
|
||||
"""Schedule Task screen, split into single-responsibility widgets (R08-T11):
|
||||
``kanban_board_widget``, ``calendar_view_widget``, ``ai_task_creator_dialog``,
|
||||
``ai_task_import_dialog``, assembled by the ``schedule_task_tab`` shell."""
|
||||
|
||||
@@ -1,51 +1,56 @@
|
||||
"""Hộp thoại tạo task bằng AI, và nhập task từ file — R08-T11.
|
||||
"""AiTaskCreatorDialog — "AI Create Task" (R08-T11, extracted from
|
||||
``ui/schedule_task_tab.py``'s ``_AiCreateDialog``, lines 579-641/722-794 of
|
||||
the original 795-line file).
|
||||
|
||||
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 ô".
|
||||
Still one dialog with two tabs (AI-gen, then Import — the latter is
|
||||
:class:`~presentation.scheduling.ai_task_import_dialog.ImportTaskPanel`,
|
||||
embedded here rather than duplicated): the physical file split matches
|
||||
``docs/refactor/Feature_Architecture_Proposal.md``'s R08-T11 breakdown, the
|
||||
user-visible dialog is unchanged. ``_confirm`` still uses "whichever tab
|
||||
produced a task list most recently" (mirroring the original class's shared
|
||||
``self._planned`` attribute) — the AI-gen tab sets it on completion, the
|
||||
Import tab reports it through :attr:`ImportTaskPanel.tasks_changed`.
|
||||
|
||||
* **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.
|
||||
AI generation goes through
|
||||
``application/scheduling/ai_task_planner_service.py::AiTaskPlannerService``
|
||||
(R07-T05) instead of ``core.ai_task_planner.plan_tasks`` directly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from typing import List, Optional
|
||||
|
||||
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,
|
||||
QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPlainTextEdit, QPushButton, QTabWidget, 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
|
||||
|
||||
from cowork_local.application.scheduling.ai_task_planner_service import (
|
||||
AiTaskPlannerService,
|
||||
)
|
||||
from cowork_local.core.projects import list_projects
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.presentation.scheduling.ai_task_import_dialog import ImportTaskPanel
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class _AiCreateDialog(TaskImportMixin, QDialog):
|
||||
class AiTaskCreatorDialog(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)."""
|
||||
description, or 📥 Import from a filled Excel/CSV/JSON file."""
|
||||
|
||||
def __init__(self, ctx: AppContext, parent=None):
|
||||
super().__init__(parent)
|
||||
from PySide6.QtWidgets import QTabWidget
|
||||
|
||||
self.ctx = ctx
|
||||
self._planner = AiTaskPlannerService(provider_factory=ctx.build_active_provider)
|
||||
self.created_tasks: List[dict] = []
|
||||
self._planned: List[dict] = []
|
||||
self._ai_planned: List[dict] = []
|
||||
# Which tab produced the task list currently backing the Ok button —
|
||||
# mirrors the original single-class dialog's shared `self._planned`
|
||||
# attribute, where whichever of _on_planned()/_load_import_file()
|
||||
# ran LAST (regardless of which tab is currently showing) won.
|
||||
self._active_source = "ai"
|
||||
self._worker: Optional[AgentWorker] = None
|
||||
self.setWindowTitle(tr("schedtask.ai_btn"))
|
||||
self.resize(600, 520)
|
||||
@@ -63,7 +68,20 @@ class _AiCreateDialog(TaskImportMixin, QDialog):
|
||||
self.tabs = QTabWidget()
|
||||
root.addWidget(self.tabs, 1)
|
||||
|
||||
# ---- tab 1: AI gen ------------------------------------------------
|
||||
self.tabs.addTab(self._build_ai_gen_page(), tr("schedtask.tab_ai"))
|
||||
self.import_panel = ImportTaskPanel(self._planner)
|
||||
self.import_panel.tasks_changed.connect(self._on_import_tasks_changed)
|
||||
self.tabs.addTab(self.import_panel, 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)
|
||||
|
||||
# ---- AI-gen tab -------------------------------------------------------
|
||||
def _build_ai_gen_page(self) -> QWidget:
|
||||
ai_page = QWidget()
|
||||
al = QVBoxLayout(ai_page)
|
||||
al.addWidget(QLabel(tr("schedtask.ai_desc_label")))
|
||||
@@ -96,42 +114,7 @@ class _AiCreateDialog(TaskImportMixin, QDialog):
|
||||
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 ------------------------------------------------------
|
||||
|
||||
|
||||
return ai_page
|
||||
|
||||
def _ai_pick_files(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
@@ -156,19 +139,12 @@ class _AiCreateDialog(TaskImportMixin, QDialog):
|
||||
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)
|
||||
planned = self._planner.plan(
|
||||
full_desc, file_paths=files, links=links, cancel=worker.is_cancelled)
|
||||
return {"tasks": planned}
|
||||
|
||||
w = AgentWorker(job)
|
||||
@@ -181,9 +157,10 @@ class _AiCreateDialog(TaskImportMixin, QDialog):
|
||||
self._worker = None
|
||||
self.gen_btn.setEnabled(True)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
||||
self._planned = result.get("tasks") or []
|
||||
self._ai_planned = result.get("tasks") or []
|
||||
self._active_source = "ai"
|
||||
lines = []
|
||||
for i, t in enumerate(self._planned, 1):
|
||||
for i, t in enumerate(self._ai_planned, 1):
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
||||
dep = t.get("dependency", {})
|
||||
@@ -192,7 +169,7 @@ class _AiCreateDialog(TaskImportMixin, QDialog):
|
||||
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))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._ai_planned))
|
||||
|
||||
def _on_failed(self, err: str) -> None:
|
||||
self._worker = None
|
||||
@@ -200,9 +177,20 @@ class _AiCreateDialog(TaskImportMixin, QDialog):
|
||||
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
||||
self.preview.setPlainText(str(err))
|
||||
|
||||
# ---- Import tab ---------------------------------------------------------
|
||||
def _on_import_tasks_changed(self, has_tasks: bool) -> None:
|
||||
if has_tasks:
|
||||
self._active_source = "import"
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(has_tasks)
|
||||
|
||||
# ---- confirm ------------------------------------------------------------
|
||||
def _confirm(self) -> None:
|
||||
planned = self.import_panel.planned if self._active_source == "import" else self._ai_planned
|
||||
project_id = self.workspace_combo.currentData() or ""
|
||||
for t in self._planned:
|
||||
for t in planned:
|
||||
t["project_id"] = project_id
|
||||
self.created_tasks = self._planned
|
||||
self.created_tasks = planned
|
||||
self.accept()
|
||||
|
||||
|
||||
__all__ = ["AiTaskCreatorDialog"]
|
||||
|
||||
@@ -1,44 +1,104 @@
|
||||
"""Nhập task từ file — R08-T11.
|
||||
"""Import-from-file tab content for AI Create Task (R08-T11, extracted from
|
||||
``ui/schedule_task_tab.py``'s ``_AiCreateDialog`` — the Import tab + its
|
||||
``_DropZone``, lines 551-576/643-665/674-720 of the original file).
|
||||
|
||||
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.
|
||||
:class:`ImportTaskPanel` is a plain ``QWidget`` (not its own dialog) so
|
||||
``ai_task_creator_dialog.py`` can embed it as one tab of the single AI-create
|
||||
dialog the user sees — the two files are a code split, not a UX split; there
|
||||
is still one dialog with two tabs, exactly as before.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from typing import List
|
||||
|
||||
from PySide6.QtCore import 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,
|
||||
QHBoxLayout, QLabel, QMessageBox, QPlainTextEdit, QPushButton,
|
||||
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
|
||||
|
||||
from cowork_local.application.scheduling.ai_task_planner_service import (
|
||||
AiTaskPlannerService,
|
||||
)
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.osutil import open_path
|
||||
|
||||
|
||||
class TaskImportMixin:
|
||||
"""Nhập task từ file. Trộn vào _AiCreateDialog."""
|
||||
class _DropZone(QLabel):
|
||||
"""Drag-an-.xlsx-here area for the Import tab."""
|
||||
|
||||
file_dropped = Signal(str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
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 ImportTaskPanel(QWidget):
|
||||
"""Pick/drag an Excel/CSV/JSON file, preview the tasks it maps to, and
|
||||
hold that NOT-yet-saved list — the dialog reads :attr:`planned` when the
|
||||
user confirms.
|
||||
|
||||
Args:
|
||||
planner: an ``AiTaskPlannerService`` — ``import_file`` is called
|
||||
through it (R07-T05) rather than ``core.task_import`` directly.
|
||||
"""
|
||||
|
||||
tasks_changed = Signal(bool) # True when the current preview has >=1 valid task
|
||||
|
||||
def __init__(self, planner: AiTaskPlannerService, parent=None):
|
||||
super().__init__(parent)
|
||||
self._planner = planner
|
||||
self.planned: List[dict] = []
|
||||
|
||||
il = QVBoxLayout(self)
|
||||
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.preview = QPlainTextEdit()
|
||||
self.preview.setReadOnly(True)
|
||||
il.addWidget(self.preview, 1)
|
||||
|
||||
def _export_template(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from ...core.task_excel import export_template
|
||||
from cowork_local.core.task_excel import export_template
|
||||
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, tr("schedtask.export_template_btn"),
|
||||
@@ -50,32 +110,39 @@ class TaskImportMixin:
|
||||
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
|
||||
from cowork_local.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
|
||||
|
||||
def _load_import_file(self, path: str) -> None:
|
||||
try:
|
||||
self._planned = import_tasks(path)
|
||||
self.planned = self._planner.import_file(path)
|
||||
except ValueError as exc:
|
||||
self.import_preview.setPlainText(str(exc))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||
# Same as the original single-class dialog: a bad file leaves
|
||||
# whatever was previously loaded in `planned` untouched (only the
|
||||
# preview text and the Ok button reflect the failure) rather than
|
||||
# discarding a prior successful load.
|
||||
self.preview.setPlainText(str(exc))
|
||||
self.tasks_changed.emit(False)
|
||||
return
|
||||
by_id = {t["task_id"]: t["title"] for t in self._planned}
|
||||
by_id = {t["task_id"]: t["title"] for t in self.planned}
|
||||
lines = []
|
||||
for i, t in enumerate(self._planned, 1):
|
||||
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))
|
||||
self.preview.setPlainText("\n\n".join(lines))
|
||||
self.tasks_changed.emit(bool(self.planned))
|
||||
|
||||
|
||||
__all__ = ["ImportTaskPanel"]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Calendar view for Schedule Task — an alternative to the Kanban board:
|
||||
"""Calendar view for Schedule Task — an alternative to the Kanban board
|
||||
(R08-T11, relocated from ``ui/calendar_view.py`` with no logic changes):
|
||||
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
|
||||
@@ -16,12 +17,12 @@ from PySide6.QtWidgets import (
|
||||
QListWidgetItem, QPushButton, QScrollArea, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ...core.calendar_grid import (
|
||||
from cowork_local.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
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
_WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
||||
|
||||
@@ -229,3 +230,6 @@ class CalendarView(QWidget):
|
||||
self.period_lbl.setText(str(self.anchor.year))
|
||||
else:
|
||||
self.period_lbl.setText(self.anchor.strftime("%Y-%m"))
|
||||
|
||||
|
||||
__all__ = ["CalendarView"]
|
||||
|
||||
@@ -1,33 +1,48 @@
|
||||
"""Bảng Kanban 7 cột kéo thả — R08-T11.
|
||||
"""Kanban board for Schedule Task (R08-T11, extracted from
|
||||
``ui/schedule_task_tab.py``'s ``ScheduleTaskTab`` — Kanban rendering +
|
||||
drag-drop + row actions, lines 41-79/222-300/302-484/496-548 of the original
|
||||
795-line file).
|
||||
|
||||
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.
|
||||
Owns the 7-lane board itself. What used to be plain module-function calls
|
||||
into ``core/tasks.py`` (``duplicate_task``, ``taskrepo.save_task``,
|
||||
``taskrepo.delete_task``, ...) and ``self.scheduler.run_now(...)`` inline are
|
||||
now calls into
|
||||
``application/scheduling/task_application_service.py::TaskApplicationService``
|
||||
(R07-T04) — the drag-drop business rules (dropping on Running/Done/Scheduled)
|
||||
in particular used to be ~30 lines of if/elif inside a Qt slot; now it's
|
||||
``TaskApplicationService.move_to_status`` plus a few branches on its result.
|
||||
|
||||
``_DropZone`` là vùng nhận file kéo vào, dùng chung với hộp thoại nhập task.
|
||||
Task EDITING (opening ``TaskEditorDialog``) is deliberately NOT owned here —
|
||||
``CalendarView`` needs the exact same "open the editor for this task id"
|
||||
behaviour for its own click handler, so it stays a shell-level concern
|
||||
(``schedule_task_tab.py``) both widgets request via a signal, instead of
|
||||
being duplicated in two places.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
|
||||
from PySide6.QtCore import QEvent, 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,
|
||||
QAbstractItemView, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||
QMenu, QMessageBox, QScrollArea, 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 cowork_local.application.scheduling.task_application_service import (
|
||||
TaskApplicationService,
|
||||
)
|
||||
from cowork_local.core.tasks import STATUSES, chain_error, new_task
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.infrastructure.persistence.json.task_repository_impl import (
|
||||
TaskRepository,
|
||||
)
|
||||
from cowork_local.presentation.scheduling.run_history_dialog import RunHistoryDialog
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.osutil import open_path
|
||||
|
||||
# Priority shown as a plain text tag (no colored-emoji squares). Only the
|
||||
# elevated priorities get a visible marker; low/medium stay unmarked as before.
|
||||
_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"}
|
||||
|
||||
|
||||
class _KanbanColumn(QListWidget):
|
||||
@@ -45,18 +60,12 @@ class _KanbanColumn(QListWidget):
|
||||
# → "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.
|
||||
# Cards wrap, so there is never anything to reach by scrolling
|
||||
# sideways — but QListWidget's own column hint runs 1-6px past the
|
||||
# viewport; the board divides whatever width it has by seven instead
|
||||
# (see KanbanBoardWidget._fit_lanes()).
|
||||
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()
|
||||
@@ -70,29 +79,291 @@ class _KanbanColumn(QListWidget):
|
||||
event.ignore()
|
||||
|
||||
|
||||
class _DropZone(QLabel):
|
||||
"""Drag-an-.xlsx-here area for the Import tab."""
|
||||
class KanbanBoardWidget(QWidget):
|
||||
"""The 7-lane board: Backlog / Scheduled / Running / Waiting Input /
|
||||
Done / Failed / Paused. Cards drag between columns (dropping = changing
|
||||
status via ``TaskApplicationService.move_to_status``), double-click and
|
||||
the right-click menu request an edit via :attr:`edit_requested`.
|
||||
|
||||
file_dropped = Signal(str)
|
||||
Args:
|
||||
ctx: ``AppContext`` — passed through to ``TaskEditorDialog`` callers
|
||||
need it for, kept here only so callers don't have to fetch it
|
||||
separately.
|
||||
tasks_dir: ``None`` -> the app's default task-storage directory;
|
||||
tests pass a ``tmp_path``.
|
||||
scheduler: ``TaskScheduler`` (may be ``None`` — matches the original
|
||||
widget's "no scheduler in tests" tolerance) used as the
|
||||
``run_now`` dispatch source for the service.
|
||||
service: inject a ready-made ``TaskApplicationService`` (tests); when
|
||||
``None``, one is built from ``tasks_dir``/``scheduler``.
|
||||
"""
|
||||
|
||||
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)
|
||||
status_message = Signal(str)
|
||||
counts_changed = Signal(dict) # status -> count, for the shell's summary label
|
||||
edit_requested = Signal(str) # task_id — shell opens TaskEditorDialog
|
||||
|
||||
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()
|
||||
_LANE_FLOOR_CH = 8 # roughly eight characters of a task title, plus padding
|
||||
|
||||
def dropEvent(self, event): # noqa: N802
|
||||
urls = event.mimeData().urls()
|
||||
if urls:
|
||||
self.file_dropped.emit(urls[0].toLocalFile())
|
||||
def __init__(self, ctx, tasks_dir=None, scheduler=None,
|
||||
service: Optional[TaskApplicationService] = None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._tasks_dir = tasks_dir
|
||||
self._repo = TaskRepository(tasks_dir)
|
||||
self._service = service or TaskApplicationService(
|
||||
self._repo, run_now=scheduler.run_now if scheduler is not None else None)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
board = QWidget()
|
||||
scroll.setWidget(board)
|
||||
cols = QHBoxLayout(board)
|
||||
cols.setSpacing(2)
|
||||
self.columns: Dict[str, _KanbanColumn] = {}
|
||||
self.column_headers: Dict[str, QLabel] = {}
|
||||
for status in STATUSES:
|
||||
box = QVBoxLayout()
|
||||
box.setContentsMargins(0, 0, 0, 0)
|
||||
box.setSpacing(2)
|
||||
head = QLabel()
|
||||
head.setStyleSheet("font-weight:600;")
|
||||
col = _KanbanColumn(status)
|
||||
col.setObjectName("kanbanLane")
|
||||
col.task_dropped.connect(self._on_task_dropped)
|
||||
col.itemDoubleClicked.connect(self._on_double_click)
|
||||
col.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
col.customContextMenuRequested.connect(
|
||||
lambda pos, c=col: self._context_menu(c, pos))
|
||||
box.addWidget(head)
|
||||
box.addWidget(col, 1)
|
||||
holder = QWidget()
|
||||
holder.setLayout(box)
|
||||
cols.addWidget(holder)
|
||||
self.columns[status] = col
|
||||
self.column_headers[status] = head
|
||||
root.addWidget(scroll, 1)
|
||||
self._board_scroll = scroll
|
||||
scroll.viewport().installEventFilter(self)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
for status, col in self.columns.items():
|
||||
col.setToolTip(tr(f"schedtask.col_tip.{status}"))
|
||||
|
||||
# ---- lane widths ------------------------------------------------------
|
||||
def eventFilter(self, obj, event): # noqa: N802
|
||||
if obj is self._board_scroll.viewport() and event.type() == QEvent.Resize:
|
||||
self._fit_lanes()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def _fit_lanes(self) -> None:
|
||||
floor = self.fontMetrics().averageCharWidth() * self._LANE_FLOOR_CH + 24
|
||||
for col in self.columns.values():
|
||||
if col.minimumWidth() != floor:
|
||||
col.setMinimumWidth(floor)
|
||||
|
||||
# ---- rendering ----------------------------------------------------------
|
||||
def _card_text(self, t: dict) -> str:
|
||||
prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "")
|
||||
ai = "[AI] " if t.get("is_ai_generated") else ""
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else None
|
||||
when_line = when or tr("schedtask.no_schedule")
|
||||
chain = ""
|
||||
if t.get("dependency", {}).get("next_task_id") or t.get("dependency", {}).get("previous_task_id"):
|
||||
chain = " (linked)"
|
||||
last = t.get("logs", {}).get("last_status")
|
||||
last_line = {"success": tr("schedtask.last_success"),
|
||||
"failed": tr("schedtask.last_failed")}.get(last, tr("schedtask.last_never"))
|
||||
return (f"{ai}{t.get('title', '')}{chain}\n"
|
||||
f"{when_line} {prio}\n{last_line}")
|
||||
|
||||
def refresh(self) -> List[dict]:
|
||||
"""Re-render every lane from disk. Returns the full task list so the
|
||||
shell can hand the same read to ``CalendarView.set_tasks`` without a
|
||||
second ``list_tasks`` call."""
|
||||
all_tasks = self._repo.list()
|
||||
counts = {s: 0 for s in STATUSES}
|
||||
for col in self.columns.values():
|
||||
col.clear()
|
||||
for t in all_tasks:
|
||||
status = t.get("status", "backlog")
|
||||
if status not in self.columns:
|
||||
continue
|
||||
counts[status] += 1
|
||||
item = QListWidgetItem(self._card_text(t))
|
||||
item.setData(Qt.UserRole, t["task_id"])
|
||||
self.columns[status].addItem(item)
|
||||
pal = current_palette()
|
||||
for status, col in self.columns.items():
|
||||
self.column_headers[status].setText(
|
||||
f"{tr(f'schedtask.status.{status}')} ({counts[status]})")
|
||||
# Dropping a card into Running STARTS the task for real, so that
|
||||
# lane is outlined while it holds anything.
|
||||
if status == "running" and counts[status]:
|
||||
col.setStyleSheet(
|
||||
f"border: 1px solid {pal.warning}; border-radius: {pal.radius}px;")
|
||||
self.column_headers[status].setStyleSheet(
|
||||
f"font-weight:600; color: {pal.warning};")
|
||||
else:
|
||||
col.setStyleSheet("")
|
||||
self.column_headers[status].setStyleSheet("font-weight:600;")
|
||||
if col.count() == 0:
|
||||
empty = QListWidgetItem(tr("schedtask.no_tasks"))
|
||||
empty.setFlags(Qt.NoItemFlags)
|
||||
col.addItem(empty)
|
||||
self.counts_changed.emit(counts)
|
||||
return all_tasks
|
||||
|
||||
# ---- actions --------------------------------------------------------
|
||||
def _on_double_click(self, item: QListWidgetItem) -> None:
|
||||
tid = item.data(Qt.UserRole)
|
||||
if tid:
|
||||
self.edit_requested.emit(tid)
|
||||
|
||||
def _on_task_dropped(self, task_id: str, new_status: str) -> None:
|
||||
"""Dropping a card ACTS on the task via ``TaskApplicationService.
|
||||
move_to_status`` — see that method's docstring for the exact rules."""
|
||||
result = self._service.move_to_status(task_id, new_status)
|
||||
if result is None:
|
||||
self.refresh()
|
||||
return
|
||||
if result.blocked:
|
||||
self.refresh() # can't drag a running task
|
||||
return
|
||||
if result.ran_now:
|
||||
self._emit_run_now_message(result.run_now_result,
|
||||
(result.task or {}).get("title", ""))
|
||||
self.refresh()
|
||||
return
|
||||
self.refresh()
|
||||
if result.needs_schedule:
|
||||
# No time set yet — a silently-disabled "Scheduled" card would
|
||||
# never run and look broken. Open the editor right away.
|
||||
self.status_message.emit(tr("schedtask.msg_set_schedule"))
|
||||
self.edit_requested.emit(task_id)
|
||||
|
||||
@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 = self._repo.get(tid)
|
||||
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._emit_run_now_message(self._service.run_now(tid), task.get("title", ""))
|
||||
self.refresh()
|
||||
elif chosen == edit_act:
|
||||
self.edit_requested.emit(tid)
|
||||
elif chosen == dup_act:
|
||||
self._service.duplicate(tid)
|
||||
self.refresh()
|
||||
elif chosen == pause_act:
|
||||
self._service.toggle_pause(tid)
|
||||
self.refresh()
|
||||
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:
|
||||
self._service.delete(tid)
|
||||
self.refresh()
|
||||
|
||||
def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None:
|
||||
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
|
||||
ids = [it.data(Qt.UserRole) for it in selected if it.data(Qt.UserRole)]
|
||||
self._service.bulk_delete(ids)
|
||||
self.refresh()
|
||||
return True
|
||||
|
||||
def _emit_run_now_message(self, result, title: str = "") -> None:
|
||||
if result is None:
|
||||
return
|
||||
if result.ok:
|
||||
self.status_message.emit(tr("schedtask.msg_running", title=title))
|
||||
elif result.reason == "manual_task":
|
||||
self.status_message.emit(tr("schedtask.msg_manual_norun"))
|
||||
elif result.reason == "no_scheduler":
|
||||
self.status_message.emit(tr("schedtask.msg_no_scheduler"))
|
||||
|
||||
def _view_logs(self, task: dict) -> None:
|
||||
from cowork_local.core.tasks import ARTIFACTS_DIR
|
||||
|
||||
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 = 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.
|
||||
Chain-cycle validation (``chain_error``) is core/tasks.py domain
|
||||
logic already, not duplicated here — only the save + edit-request
|
||||
wiring is this widget's job."""
|
||||
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(self._repo.list() + [nxt], task["task_id"], nxt["task_id"])
|
||||
if err:
|
||||
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
|
||||
return
|
||||
self._repo.save(nxt)
|
||||
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"
|
||||
self._repo.save(task)
|
||||
self.refresh()
|
||||
self.edit_requested.emit(nxt["task_id"])
|
||||
|
||||
|
||||
__all__ = ["KanbanBoardWidget"]
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
"""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.
|
||||
"""
|
||||
"""RunHistoryDialog — one task's run history as a table (R08-T11, split out
|
||||
of ``kanban_board_widget.py`` to keep that file under the 400-line cap;
|
||||
originally ``ui/schedule_task_tab.py::_RunHistoryDialog``, lines 496-548)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtCore import Qt
|
||||
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,
|
||||
QAbstractItemView, QDialog, QDialogButtonBox, QLabel, QTableWidget,
|
||||
QTableWidgetItem, QVBoxLayout,
|
||||
)
|
||||
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 cowork_local.i18n import tr
|
||||
from cowork_local.ui.osutil import open_path
|
||||
|
||||
|
||||
class _RunHistoryDialog(QDialog):
|
||||
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."""
|
||||
|
||||
@@ -50,7 +36,6 @@ class _RunHistoryDialog(QDialog):
|
||||
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", "")),
|
||||
@@ -73,10 +58,15 @@ class _RunHistoryDialog(QDialog):
|
||||
root.addWidget(buttons)
|
||||
|
||||
def _open_artifact(self, item: QTableWidgetItem) -> None:
|
||||
from cowork_local.core.tasks import ARTIFACTS_DIR
|
||||
|
||||
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
|
||||
folder = ARTIFACTS_DIR / self._task["task_id"] / run_id
|
||||
if folder.exists():
|
||||
open_path(str(folder))
|
||||
|
||||
|
||||
__all__ = ["RunHistoryDialog"]
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""ScheduleTaskTab shell (R08-T11) — assembles
|
||||
``kanban_board_widget.py::KanbanBoardWidget`` and
|
||||
``calendar_view_widget.py::CalendarView`` behind the header/view-switch that
|
||||
used to be inline in ``ui/schedule_task_tab.py`` (lines 81-245 of the
|
||||
original 795-line file: header, view-tab wiring, lane-fit event filter moved
|
||||
into the Kanban widget itself, the belt-and-braces 10s refresh timer).
|
||||
|
||||
Task EDITING (opening ``TaskEditorDialog``) lives HERE, not in either child
|
||||
widget, because both need the exact same "open the editor for this task id"
|
||||
behaviour — Kanban's double-click/edit-menu and Calendar's task click both
|
||||
request it via a signal instead of each importing ``TaskEditorDialog``
|
||||
themselves.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QTimer, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QLabel, QPushButton, QSizePolicy, QStackedWidget,
|
||||
QTabBar, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.core.tasks import STATUSES, list_tasks, load_task, new_task, save_task
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.scheduling.calendar_view_widget import CalendarView
|
||||
from cowork_local.presentation.scheduling.kanban_board_widget import KanbanBoardWidget
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
_VIEWS = ("kanban", "calendar")
|
||||
|
||||
|
||||
class ScheduleTaskTab(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext, scheduler=None, tasks_dir: Optional[Path] = None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self.scheduler = scheduler # TaskScheduler (may be None in tests)
|
||||
# None -> the app's default TASKS_DIR (core/tasks.py). Overridable
|
||||
# (new in R08-T11; the original monolithic tab hardcoded None with no
|
||||
# way to point it at a tmp_path) so this shell is actually testable
|
||||
# without touching the user's real config folder — same shape
|
||||
# TaskScheduler.__init__ already accepts.
|
||||
self._tasks_dir: Optional[Path] = tasks_dir
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
# ---- header ----------------------------------------------------
|
||||
header = QHBoxLayout()
|
||||
self._title = QLabel()
|
||||
self._title.setStyleSheet("font-weight:700; font-size:15px;")
|
||||
self.counts_lbl = QLabel("")
|
||||
self.counts_lbl.setObjectName("hint")
|
||||
self.counts_lbl.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
|
||||
self.counts_lbl.setMinimumWidth(0)
|
||||
self.add_btn = QPushButton()
|
||||
self.add_btn.setIcon(icon("plus"))
|
||||
self.add_btn.setObjectName("primary")
|
||||
self.add_btn.clicked.connect(self._add_task)
|
||||
self.ai_btn = QPushButton()
|
||||
self.ai_btn.setIcon(icon("sparkle"))
|
||||
self.ai_btn.clicked.connect(self._ai_create)
|
||||
# Two views of the same tasks, so they read as a pair of tabs rather
|
||||
# than a drop-list you have to open to discover the Calendar exists.
|
||||
self.view_tabs = QTabBar()
|
||||
self.view_tabs.setObjectName("viewTabs")
|
||||
self.view_tabs.setDrawBase(False)
|
||||
self.view_tabs.setExpanding(False)
|
||||
for _v in _VIEWS:
|
||||
self.view_tabs.addTab("")
|
||||
self.view_tabs.currentChanged.connect(self._on_view_changed)
|
||||
header.addWidget(self._title)
|
||||
header.addWidget(self.counts_lbl, 1)
|
||||
header.addWidget(self.view_tabs)
|
||||
header.addWidget(self.add_btn)
|
||||
header.addWidget(self.ai_btn)
|
||||
root.addLayout(header)
|
||||
|
||||
# ---- board / calendar (two views of the SAME tasks) -----------------
|
||||
self._view_stack = QStackedWidget()
|
||||
self.kanban = KanbanBoardWidget(ctx, tasks_dir=self._tasks_dir, scheduler=scheduler)
|
||||
self.kanban.status_message.connect(self.status_message.emit)
|
||||
self.kanban.counts_changed.connect(self._on_counts_changed)
|
||||
self.kanban.edit_requested.connect(self._edit_task)
|
||||
self._view_stack.addWidget(self.kanban)
|
||||
self.calendar = CalendarView()
|
||||
self.calendar.edit_task.connect(self._edit_task)
|
||||
self.calendar.add_task_on_date.connect(self._add_task_on_date)
|
||||
self._view_stack.addWidget(self.calendar)
|
||||
root.addWidget(self._view_stack, 1)
|
||||
|
||||
if self.scheduler is not None:
|
||||
self.scheduler.tasks_changed.connect(self.refresh)
|
||||
self.scheduler.task_started.connect(lambda _tid: self.refresh())
|
||||
self.scheduler.task_finished.connect(lambda _tid, _ok: self.refresh())
|
||||
|
||||
# Belt-and-braces: also re-read the board every 10s so a card's lane
|
||||
# ALWAYS reflects reality (Scheduled → Running → Done) even if some
|
||||
# change slipped past the signals (e.g. task files edited externally).
|
||||
self._refresh_timer = QTimer(self)
|
||||
self._refresh_timer.setInterval(10_000)
|
||||
self._refresh_timer.timeout.connect(self.refresh)
|
||||
self._refresh_timer.start()
|
||||
|
||||
self.refresh()
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
# ---- i18n ------------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
self._title.setText(tr("schedtask.title"))
|
||||
self.add_btn.setText(tr("schedtask.add_btn"))
|
||||
self.add_btn.setToolTip(tr("schedtask.add_tooltip"))
|
||||
self.ai_btn.setText(tr("schedtask.ai_btn"))
|
||||
self.ai_btn.setToolTip(tr("schedtask.ai_tooltip"))
|
||||
for i, v in enumerate(_VIEWS):
|
||||
self.view_tabs.setTabText(i, tr(f"schedtask.view.{v}"))
|
||||
self.kanban.retranslate()
|
||||
self.refresh()
|
||||
|
||||
# ---- Kanban / Calendar view switch --------------------------------
|
||||
def _on_view_changed(self) -> None:
|
||||
self._view_stack.setCurrentIndex(self.view_tabs.currentIndex())
|
||||
|
||||
def _on_counts_changed(self, counts: dict) -> None:
|
||||
summary = " ".join(
|
||||
f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s])
|
||||
self.counts_lbl.setText(summary)
|
||||
self.counts_lbl.setToolTip(summary) # full text stays reachable if clipped
|
||||
|
||||
def refresh(self) -> None:
|
||||
all_tasks = self.kanban.refresh()
|
||||
self.calendar.set_tasks(all_tasks)
|
||||
|
||||
# ---- task creation / editing (shared by Kanban + Calendar) -----------
|
||||
def _save_and_refresh(self, task: dict) -> None:
|
||||
save_task(task, self._tasks_dir)
|
||||
self.refresh()
|
||||
|
||||
def _add_task(self) -> None:
|
||||
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
||||
|
||||
dlg = TaskEditorDialog(None, 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 cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
||||
|
||||
task = load_task(task_id, self._tasks_dir)
|
||||
if not task:
|
||||
return
|
||||
dlg = TaskEditorDialog(task, list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
|
||||
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 cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
||||
|
||||
t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"})
|
||||
dlg = TaskEditorDialog(t, 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"))
|
||||
|
||||
# ---- AI create ----------------------------------------------------------
|
||||
def _ai_create(self) -> None:
|
||||
from cowork_local.presentation.scheduling.ai_task_creator_dialog import (
|
||||
AiTaskCreatorDialog,
|
||||
)
|
||||
|
||||
dlg = AiTaskCreatorDialog(self.ctx, self)
|
||||
if dlg.exec() and dlg.created_tasks:
|
||||
for t in dlg.created_tasks:
|
||||
save_task(t, self._tasks_dir)
|
||||
self.refresh()
|
||||
self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks)))
|
||||
|
||||
|
||||
__all__ = ["ScheduleTaskTab"]
|
||||
Reference in New Issue
Block a user