merge: merge origin/feature/teamhoa/r05-r06 (R07/R08) into feature/delta-team/epic-R04

This commit is contained in:
2026-08-28 10:32:38 +09:00
72 changed files with 6546 additions and 2657 deletions
+3 -1
View File
@@ -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"]
+105 -38
View File
@@ -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"]
+325 -54
View File
@@ -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"]
+16 -26
View File
@@ -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"]