Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8c6b5c20a | ||
|
|
1efa1d29d1 | ||
|
|
0e51356a7d | ||
|
|
69ab8e125b |
@@ -23,13 +23,13 @@ from .state import AppContext
|
||||
from .ui.widgets import tidy_popup
|
||||
from .theme import current_palette, set_active_theme, stylesheet
|
||||
from .core.task_scheduler import TaskScheduler
|
||||
from .presentation.dashboard.dashboard_tab import DashboardTab
|
||||
from .presentation.graph.structure_graph_view import StructureGraphView
|
||||
from .presentation.scheduling.schedule_task_tab import ScheduleTaskTab
|
||||
from .ui.cowork_tab import CoworkTab
|
||||
from .ui.dashboard_tab import DashboardTab
|
||||
from .ui.monitoring_tab import MonitoringTab
|
||||
from .ui.schedule_task_tab import ScheduleTaskTab
|
||||
from .ui.settings_dialog import SettingsDialog
|
||||
from .ui.sidebar import HistorySidebar
|
||||
from .ui.structure_graph_view import StructureGraphView
|
||||
from .ui.workspace_tab import WorkspaceTab
|
||||
|
||||
ASSETS = Path(__file__).resolve().parent / "assets"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Read-only query services for monitoring/dashboard screens (EPIC R08).
|
||||
|
||||
⚠️ Ownership note (R08-T13): per ``docs/refactor/Feature_Architecture_
|
||||
Proposal.md``'s file-split diagram, ``dashboard_query_service.py`` lives
|
||||
under ``application/monitoring/`` alongside the Dashboard split — but the
|
||||
SAME document's "Ranh giới phân hệ" table assigns ``application/monitoring/``
|
||||
to Team Nam (R08-T07→T10, Monitoring's own 8-tab split). This directory did
|
||||
not exist yet when Team Hoa reached R08-T13, so creating it here does not
|
||||
collide with any file Team Nam has written — same situation R06-T02 flagged
|
||||
for ``infrastructure/persistence/json/atomic_write.py`` vs. Team Nam's
|
||||
planned ``atomic_json_file.py``. Team Nam should confirm when they start
|
||||
R08-T07→T10 whether ``DashboardQueryService`` belongs here permanently or
|
||||
should move once Monitoring's own query service exists.
|
||||
"""
|
||||
|
||||
from .dashboard_query_service import DashboardQueryService
|
||||
|
||||
__all__ = ["DashboardQueryService"]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""DashboardQueryService - read-only usage/cost queries for the Dashboard
|
||||
screen (R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``, lines
|
||||
196-199/256-261/284-322 of the original 437-line file: ``_pricing``,
|
||||
``_period_range``'s date-math, and the ``period_totals``/``period_breakdown``
|
||||
calls ``_refresh_chart`` made directly).
|
||||
|
||||
``ui/dashboard_tab.py`` called ``core/usage_tracker.py``/``core/model_
|
||||
pricing.py`` directly from FIVE different methods spread across what is now
|
||||
three widgets (``token_usage_card_widget.py``, ``usage_chart_widget.py``,
|
||||
``habits_widget.py``) — each recomputing the same merged pricing dict. This
|
||||
service is the one place that merge happens now; the three widgets share it
|
||||
instead of each calling ``core.usage_tracker``/``core.model_pricing`` on
|
||||
their own.
|
||||
|
||||
Pure Python: no Qt. Wraps ``core/usage_tracker.py`` (a plain-Python module
|
||||
already) rather than reimplementing any of its date/cost math.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
|
||||
class DashboardQueryService:
|
||||
"""Usage/cost queries scoped to one ``AppContext``.
|
||||
|
||||
Args:
|
||||
ctx: ``AppContext`` — read for ``ctx.config`` (pricing table,
|
||||
currency, budget) and nothing else; this class does no I/O of
|
||||
its own beyond what ``core.usage_tracker`` already does.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: Any) -> None:
|
||||
self.ctx = ctx
|
||||
|
||||
def pricing(self) -> Dict[str, Any]:
|
||||
"""The merged price table (defaults + user overrides), synced from
|
||||
Monitoring's model-pricing table first so cost figures always agree
|
||||
between the two screens."""
|
||||
from cowork_local.core import model_pricing as mp
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
mp.sync_to_usage(self.ctx.config)
|
||||
return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||||
|
||||
def period_range(self, granularity: str, offset: int) -> Tuple[date, date]:
|
||||
"""The SELECTED period as an inclusive ``(start, end)`` date range —
|
||||
drives every widget on the screen (cards, chart, habits)."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
start, end = ut.period_bounds(granularity, offset)
|
||||
return start, end - timedelta(days=1) # load_events end is inclusive
|
||||
|
||||
def summary(self, start: date, end: date) -> Dict[str, Any]:
|
||||
"""Everything the stat cards + habits panel need for one period:
|
||||
the raw events, ``usage_tracker.summarize``'s aggregate stats, the
|
||||
per-bucket costs, and their total — computed once so both widgets
|
||||
read the same numbers instead of loading events twice."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
events = ut.load_events(start, end)
|
||||
pricing = self.pricing()
|
||||
stats = ut.summarize(events)
|
||||
costs = ut.cost_usd_events(events, pricing)
|
||||
return {
|
||||
"events": events,
|
||||
"pricing": pricing,
|
||||
"stats": stats,
|
||||
"costs": costs,
|
||||
"total_cost": sum(costs.values()),
|
||||
}
|
||||
|
||||
def chart_series(self, granularity: str, offset: int, metric: str
|
||||
) -> List[Tuple[str, float]]:
|
||||
"""``(label, value)`` points for the spline chart — WEEK -> 7 days,
|
||||
MONTH -> weeks, YEAR -> 12 months, in whichever ``metric``
|
||||
("tokens" | "cost") was selected."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
events = ut.load_events() # all events; breakdown slices by period
|
||||
pricing = self.pricing()
|
||||
parts = ut.period_breakdown(events, granularity, pricing, offset=offset)
|
||||
mi = 0 if metric == "tokens" else 1 # (label, tokens, cost) -> +1 for the value
|
||||
return [(row[0], float(row[mi + 1])) for row in parts]
|
||||
|
||||
def period_totals(self, granularity: str, offset: int) -> Tuple[float, float]:
|
||||
"""``(tokens, cost)`` totals for one period — used to compute the
|
||||
vs-previous-period delta the chart's reference line shows."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
events = ut.load_events()
|
||||
return ut.period_totals(events, granularity, self.pricing(), offset)
|
||||
|
||||
def period_range_label(self, granularity: str, offset: int) -> str:
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
return ut.period_range_label(granularity, offset)
|
||||
|
||||
def budget_status(self):
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
return ut.budget_status(self.ctx.config)
|
||||
|
||||
def set_budget(self, amount: float, currency: str) -> None:
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
ut.set_budget(self.ctx.config, amount, currency)
|
||||
|
||||
|
||||
__all__ = ["DashboardQueryService"]
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Application services for Schedule Task (EPIC R07)."""
|
||||
|
||||
from .ai_task_planner_service import AiTaskPlannerService
|
||||
from .task_application_service import MoveResult, RunNowResult, TaskApplicationService
|
||||
|
||||
__all__ = ["TaskApplicationService", "RunNowResult", "MoveResult", "AiTaskPlannerService"]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""AiTaskPlannerService - AI-generate / import task lists, outside the widget
|
||||
(R07-T05).
|
||||
|
||||
``ui/schedule_task_tab.py``'s ``_AiCreateDialog`` already delegates the
|
||||
actual planning to two existing pure functions —
|
||||
``core/ai_task_planner.py::plan_tasks`` (natural-language description ->
|
||||
task dicts, via the active provider) and
|
||||
``core/task_import.py::import_tasks`` (Excel/CSV/JSON -> task dicts) — so
|
||||
this service does not reimplement either. What it DOES own is one small
|
||||
piece of business logic that currently only exists inside the dialog's
|
||||
``AgentWorker`` job closure (``_generate``'s ``job()``): every AI-generated
|
||||
task must carry the SAME file/link attachments the user attached to the
|
||||
request, so they're available again at run time, not just visible to the
|
||||
planner while it drafts the task list. Leaving that step trapped in a Qt
|
||||
worker closure means it can only be exercised by driving the real dialog;
|
||||
here it's a plain, independently testable method.
|
||||
|
||||
Pure Python: no Qt import. The provider is a constructor-injected factory
|
||||
(``() -> Provider``, no arguments — matches ``AppContext.build_active_
|
||||
provider``), the same dependency-inversion shape
|
||||
``application/conversations/conversation_application_service.py`` (R04-T03)
|
||||
uses for ITS provider factory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Union
|
||||
|
||||
ProviderFactory = Callable[[], Any]
|
||||
CancelFn = Callable[[], bool]
|
||||
|
||||
|
||||
class AiTaskPlannerService:
|
||||
"""AI task generation + file/Excel/CSV/JSON import, for
|
||||
``presentation/scheduling/ai_task_creator_dialog.py`` and
|
||||
``ai_task_import_dialog.py`` (R08-T11) to call instead of importing
|
||||
``core.ai_task_planner``/``core.task_import`` directly.
|
||||
|
||||
Args:
|
||||
provider_factory: ``() -> Provider``. Production passes
|
||||
``AppContext.build_active_provider``; tests pass a lambda
|
||||
returning a :class:`FakeProvider`.
|
||||
"""
|
||||
|
||||
def __init__(self, provider_factory: Optional[ProviderFactory] = None) -> None:
|
||||
self._provider_factory = provider_factory
|
||||
|
||||
def plan(
|
||||
self,
|
||||
description: str,
|
||||
*,
|
||||
file_paths: Sequence[str] = (),
|
||||
links: Sequence[str] = (),
|
||||
provider: Any = None,
|
||||
cancel: Optional[CancelFn] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Turn ``description`` into a list of NOT-yet-saved task dicts.
|
||||
|
||||
``provider`` overrides the constructor's factory for this one call
|
||||
(useful for tests, or a caller that already resolved a provider);
|
||||
omit it to use the injected factory. Raises ``RuntimeError`` when
|
||||
no provider is available at all, or when the model's reply had no
|
||||
parseable task list (same error ``core.ai_task_planner.plan_tasks``
|
||||
already raises).
|
||||
"""
|
||||
resolved = provider if provider is not None else self._resolve_provider()
|
||||
from cowork_local.core.ai_task_planner import plan_tasks
|
||||
|
||||
planned = plan_tasks(resolved, description, cancel=cancel)
|
||||
# Attachments apply to EVERY generated task so they're still there
|
||||
# when the task actually runs, not just while the planner drafts it
|
||||
# (see module docstring — this used to only happen inside the
|
||||
# dialog's worker closure).
|
||||
for task in planned:
|
||||
task["input"]["file_paths"] = list(file_paths)
|
||||
task["input"]["links"] = list(links)
|
||||
return planned
|
||||
|
||||
def import_file(self, path: Union[str, Path]) -> List[Dict[str, Any]]:
|
||||
"""Excel/CSV/JSON -> NOT-yet-saved task dicts, auto-chained in file
|
||||
order. Raises ``ValueError`` with a human-readable message on an
|
||||
unusable/unsupported file (same contract
|
||||
``core.task_import.import_tasks`` already has)."""
|
||||
from cowork_local.core.task_import import import_tasks
|
||||
|
||||
return import_tasks(path)
|
||||
|
||||
def _resolve_provider(self) -> Any:
|
||||
if self._provider_factory is None:
|
||||
raise RuntimeError("No provider available to plan tasks.")
|
||||
return self._provider_factory()
|
||||
|
||||
|
||||
__all__ = ["AiTaskPlannerService"]
|
||||
@@ -0,0 +1,172 @@
|
||||
"""TaskApplicationService - task CRUD + dispatch, outside the widget (R07-T04).
|
||||
|
||||
``ui/schedule_task_tab.py`` currently does all of this by importing
|
||||
``core/tasks.py`` module functions directly and calling
|
||||
``self.scheduler.run_now(...)`` inline inside Qt slot methods
|
||||
(``_run_now``, ``_context_menu``'s duplicate/pause/delete branches,
|
||||
``_on_task_dropped``'s per-lane business rules). None of it is Qt — it's
|
||||
plain CRUD plus a few small rules ("a manual task never auto-runs",
|
||||
"dropping a card on Done disables its schedule so it won't re-fire",
|
||||
"dropping on Scheduled with no time set needs the editor, not a silent
|
||||
no-op") — but it can only be exercised today by driving the real widget.
|
||||
|
||||
This service is the seam ``presentation/scheduling/kanban_board_widget.py``
|
||||
(R08-T11) calls instead: same rules, same
|
||||
:class:`~infrastructure.persistence.json.task_repository_impl.TaskRepository`
|
||||
underneath, testable with no Qt at all.
|
||||
|
||||
Pure Python: no Qt import. ``run_now`` dispatch is a plain injected callable
|
||||
(production wires ``TaskScheduler.run_now``; tests inject a stub), the same
|
||||
constructor-injection shape ``application/conversations/conversation_
|
||||
application_service.py`` (R04-T03) uses for its provider factory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
# "TaskRepository" here is a Protocol-shaped name, not an import: this module
|
||||
# only calls .get/.save/.delete/.duplicate, so any object with that shape
|
||||
# (the real infrastructure.persistence.json.task_repository_impl.TaskRepository,
|
||||
# or a test double) works without this file importing infrastructure/ at
|
||||
# module scope.
|
||||
RunNowFn = Callable[[str], bool]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunNowResult:
|
||||
"""Outcome of asking a task to run immediately.
|
||||
|
||||
``reason`` is one of ``""`` (ok), ``"not_found"``, ``"manual_task"``
|
||||
(manual tasks never auto-run — spec: they exist to be run by a human),
|
||||
``"no_scheduler"`` (no ``run_now`` callable was wired in), or
|
||||
``"already_running"`` (the scheduler's own dedupe rejected it).
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class MoveResult:
|
||||
"""Outcome of dropping a task card onto a Kanban lane
|
||||
(``move_to_status``). The caller (kanban widget) uses the flags to decide
|
||||
what to show — a full re-render, a "task is running" toast, or opening
|
||||
the task editor — without re-deriving the business rule itself."""
|
||||
|
||||
task: Optional[Dict[str, Any]]
|
||||
blocked: bool = False # dropped while already running — ignored
|
||||
ran_now: bool = False # dropped on the Running lane — dispatched
|
||||
run_now_result: Optional[RunNowResult] = None
|
||||
needs_schedule: bool = False # dropped on Scheduled with no run_at set — needs editing
|
||||
|
||||
|
||||
class TaskApplicationService:
|
||||
"""CRUD + dispatch for Schedule Task, backed by a ``TaskRepository``.
|
||||
|
||||
Args:
|
||||
repository: a ``TaskRepository``-shaped object (``.get``, ``.save``,
|
||||
``.delete``, ``.duplicate``). Production passes
|
||||
``infrastructure.persistence.json.task_repository_impl.
|
||||
TaskRepository()``; tests pass one scoped to a ``tmp_path``.
|
||||
run_now: ``(task_id) -> bool``. Production passes
|
||||
``TaskScheduler.run_now``; ``None`` means no scheduler is wired
|
||||
(matches the widget's own "no scheduler" guard today).
|
||||
"""
|
||||
|
||||
def __init__(self, repository: Any, run_now: Optional[RunNowFn] = None) -> None:
|
||||
self._repository = repository
|
||||
self._run_now = run_now
|
||||
|
||||
# -- single-task actions ------------------------------------------------ #
|
||||
def run_now(self, task_id: str) -> RunNowResult:
|
||||
"""Dispatch ``task_id`` immediately. A "Run now" always counts as
|
||||
manual approval (spec §13) — this is the ONE path that bypasses
|
||||
``execution.requires_approval``, same as the scheduler's own
|
||||
``run_now`` already does."""
|
||||
task = self._repository.get(task_id)
|
||||
if task is None:
|
||||
return RunNowResult(False, "not_found")
|
||||
if task.get("task_type") == "manual":
|
||||
return RunNowResult(False, "manual_task")
|
||||
if self._run_now is None:
|
||||
return RunNowResult(False, "no_scheduler")
|
||||
ok = self._run_now(task_id)
|
||||
return RunNowResult(ok, "" if ok else "already_running")
|
||||
|
||||
def duplicate(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""A saved copy with a fresh identity — see
|
||||
``core/tasks.py::duplicate_task`` for what's preserved/reset."""
|
||||
task = self._repository.get(task_id)
|
||||
if task is None:
|
||||
return None
|
||||
dup = self._repository.duplicate(task)
|
||||
self._repository.save(dup)
|
||||
return dup
|
||||
|
||||
def toggle_pause(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Pause a task, or resume a paused one back to Backlog (matches
|
||||
``ui/schedule_task_tab.py``'s context-menu action exactly — resuming
|
||||
does NOT restore whatever status the task had before pausing, only
|
||||
Backlog, so the user re-schedules explicitly rather than a stale
|
||||
schedule silently re-firing)."""
|
||||
task = self._repository.get(task_id)
|
||||
if task is None:
|
||||
return None
|
||||
task["status"] = "backlog" if task.get("status") == "paused" else "paused"
|
||||
self._repository.save(task)
|
||||
return task
|
||||
|
||||
def delete(self, task_id: str) -> bool:
|
||||
if self._repository.get(task_id) is None:
|
||||
return False
|
||||
self._repository.delete(task_id)
|
||||
return True
|
||||
|
||||
def bulk_delete(self, task_ids: List[str]) -> int:
|
||||
"""Delete every id in ``task_ids``; returns how many actually
|
||||
existed (mirrors ``_confirm_and_delete_selected``'s best-effort
|
||||
loop — a stale id in the selection doesn't abort the rest)."""
|
||||
return sum(1 for tid in task_ids if self.delete(tid))
|
||||
|
||||
# -- Kanban drag/drop ----------------------------------------------------- #
|
||||
def move_to_status(self, task_id: str, new_status: str) -> Optional[MoveResult]:
|
||||
"""Apply the business rule behind dropping a card into a lane
|
||||
(``ui/schedule_task_tab.py::_on_task_dropped``, moved here so it's
|
||||
testable without a real ``QListWidget`` drag gesture):
|
||||
|
||||
* already running -> the drop is ignored (a running task can't be
|
||||
re-filed by dragging it).
|
||||
* dropped on Running -> runs it now (counts as manual approval).
|
||||
* dropped on Done -> marks it done AND disables its schedule, so a
|
||||
repeating task marked done by hand doesn't quietly re-fire later.
|
||||
* dropped on Scheduled with no ``run_at`` set yet -> saved as-is but
|
||||
flagged ``needs_schedule`` — the caller should open the editor
|
||||
rather than leave a Scheduled card that will never actually run.
|
||||
* anything else -> plain status change.
|
||||
"""
|
||||
task = self._repository.get(task_id)
|
||||
if task is None:
|
||||
return None
|
||||
if task.get("status") == "running":
|
||||
return MoveResult(task=task, blocked=True)
|
||||
if new_status == "running":
|
||||
result = self.run_now(task_id)
|
||||
return MoveResult(task=self._repository.get(task_id), ran_now=True, run_now_result=result)
|
||||
if new_status == "done":
|
||||
task["status"] = "done"
|
||||
task["schedule"]["enabled"] = False
|
||||
self._repository.save(task)
|
||||
return MoveResult(task=task)
|
||||
task["status"] = new_status
|
||||
if new_status == "scheduled" and not task["schedule"].get("enabled"):
|
||||
if task["schedule"].get("run_at"):
|
||||
task["schedule"]["enabled"] = True
|
||||
else:
|
||||
self._repository.save(task)
|
||||
return MoveResult(task=task, needs_schedule=True)
|
||||
self._repository.save(task)
|
||||
return MoveResult(task=task)
|
||||
|
||||
|
||||
__all__ = ["TaskApplicationService", "RunNowResult", "MoveResult"]
|
||||
@@ -1,5 +1,17 @@
|
||||
"""Workspace file operations for non-agent-loop callers (EPIC R06)."""
|
||||
"""Workspace file operations for non-agent-loop callers (EPIC R06, R08)."""
|
||||
|
||||
from .ai_edit_output import parse_ai_output, split_code_block
|
||||
from .file_preview_helpers import is_probably_text, pptx_available, read_text
|
||||
from .file_workspace_service import FileWorkspaceService
|
||||
from .graph_index_service import extract_file_contents, pdf_to_markdown
|
||||
|
||||
__all__ = ["FileWorkspaceService"]
|
||||
__all__ = [
|
||||
"FileWorkspaceService",
|
||||
"read_text",
|
||||
"is_probably_text",
|
||||
"pptx_available",
|
||||
"split_code_block",
|
||||
"parse_ai_output",
|
||||
"pdf_to_markdown",
|
||||
"extract_file_contents",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Parse an AI file-edit reply into its parts (R08-T12, moved out of
|
||||
``ui/folder_tab.py`` — that file's module-level ``_split_code_block``/
|
||||
``_parse_ai_output``, lines 1536-1562 of the original 1587-line file). Pure
|
||||
string parsing, no Qt — used by ``presentation/folder/ai_file_editor_dialog.py``
|
||||
to turn a model's raw reply into a proposed edit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
def split_code_block(text: str) -> Tuple[Optional[str], str]:
|
||||
"""Split an AI reply into ``(file_content, summary)``. ``file_content``
|
||||
is the first fenced code block (the edited file); ``summary`` is any
|
||||
prose before it. Returns ``(None, text)`` when there's no code block."""
|
||||
m = re.search(r"```[^\n]*\n(.*?)```", text or "", re.DOTALL)
|
||||
if not m:
|
||||
return None, (text or "")
|
||||
return m.group(1), (text[:m.start()].strip())
|
||||
|
||||
|
||||
def parse_ai_output(text: str) -> Tuple[Optional[str], Optional[str], str, List[Tuple[str, str]]]:
|
||||
"""Parse an AI edit reply into ``(target, content, summary, image_gens)``.
|
||||
``FILE: <path>`` names a NEW file to create; ``IMAGE_GEN: <prompt> =>
|
||||
<path>`` lines request generated illustration images (relative paths)."""
|
||||
content, summary = split_code_block(text)
|
||||
target = None
|
||||
m = re.search(r"(?mi)^\s*FILE:\s*(.+?)\s*$", text or "")
|
||||
if m:
|
||||
target = m.group(1).strip().strip("`\"'")
|
||||
image_gens = []
|
||||
for gm in re.finditer(r"(?mi)^\s*IMAGE_GEN:\s*(.+?)\s*=>\s*(\S+)\s*$", text or ""):
|
||||
image_gens.append((gm.group(1).strip(), gm.group(2).strip().strip("`\"'")))
|
||||
# Strip the directive lines out of the shown summary.
|
||||
summary = re.sub(r"(?mi)^\s*(FILE|IMAGE_GEN):\s*.+?$", "", summary).strip()
|
||||
return target, content, summary, image_gens
|
||||
|
||||
|
||||
__all__ = ["split_code_block", "parse_ai_output"]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Pure helpers for previewing a file (R08-T12, moved out of
|
||||
``ui/folder_tab.py`` — that file's module-level functions
|
||||
``_read_text``/``_is_probably_text``/``_pptx_available``, lines 1519-1533 and
|
||||
1565-1587 of the original 1587-line file). No Qt, no widget state — the
|
||||
"is this file text? is pptx editing available?" questions the preview
|
||||
manager asks before it decides how to render something.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_PPTX_READY = None # cached: pptx-editing library available (after auto-install)
|
||||
|
||||
|
||||
def pptx_available() -> bool:
|
||||
"""True when python-pptx is importable. If it's MISSING, auto-download &
|
||||
install it (via deps.ensure_module) so pptx editing 'just works' — cached
|
||||
so the (one-time) install is attempted only once."""
|
||||
global _PPTX_READY
|
||||
if _PPTX_READY is None:
|
||||
try:
|
||||
from cowork_local.core.deps import ensure_module
|
||||
|
||||
_PPTX_READY = ensure_module("pptx", "python-pptx") is not None
|
||||
except Exception: # noqa: BLE001
|
||||
_PPTX_READY = False
|
||||
return _PPTX_READY
|
||||
|
||||
|
||||
def read_text(path: str) -> str:
|
||||
try:
|
||||
return Path(path).read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
return f"[could not read file: {exc}]"
|
||||
|
||||
|
||||
def is_probably_text(path: str) -> bool:
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
chunk = f.read(4096)
|
||||
except OSError:
|
||||
return False
|
||||
if b"\x00" in chunk:
|
||||
return False
|
||||
try:
|
||||
chunk.decode("utf-8")
|
||||
return True
|
||||
except UnicodeDecodeError:
|
||||
# Latin-ish text still edits fine via errors="replace"; only reject on
|
||||
# a hard binary signal (NUL above), so most source files pass.
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["pptx_available", "read_text", "is_probably_text"]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Temporary file-content extraction for Graph-RAG Q&A (R08-T14, moved out
|
||||
of ``ui/structure_graph_view.py`` — that file's module-level
|
||||
``_pdf_to_markdown``/``_extract_file_contents``, lines 964-1034 of the
|
||||
original 1035-line file). Runs inside the ask worker's job function so the
|
||||
answer is synthesized from real file content, not just the graph structure.
|
||||
|
||||
Pure Python: no Qt. Best-effort throughout (never raises) — a failed
|
||||
extraction degrades to "no content for this file", not a broken Q&A turn.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
def pdf_to_markdown(pdf_path: str, out_dir: str) -> Optional[str]:
|
||||
"""Convert a PDF to Markdown with opendataloader-pdf when available
|
||||
(richer structure than a plain text dump). Best-effort — returns None
|
||||
if the package isn't installed or the call fails, so the caller falls
|
||||
back to ``core/doc_extract.py``."""
|
||||
try:
|
||||
import opendataloader_pdf # optional; auto-installed elsewhere if present
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
from cowork_local.core.deps import ensure_module
|
||||
if ensure_module("opendataloader_pdf", "opendataloader-pdf") is None:
|
||||
return None
|
||||
import opendataloader_pdf # noqa: F811
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
out = Path(out_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
for call in (
|
||||
lambda: opendataloader_pdf.convert(input_path=[str(pdf_path)], output_dir=str(out),
|
||||
generate_markdown=True),
|
||||
lambda: opendataloader_pdf.convert(input_path=str(pdf_path), output_dir=str(out)),
|
||||
lambda: opendataloader_pdf.convert(str(pdf_path), str(out)),
|
||||
):
|
||||
try:
|
||||
call()
|
||||
break
|
||||
except TypeError:
|
||||
continue
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
mds = list(out.rglob(Path(pdf_path).stem + "*.md")) or list(out.rglob("*.md"))
|
||||
for md in mds:
|
||||
try:
|
||||
return md.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def extract_file_contents(paths: List[str], cache: Dict[str, str], tmp_dir: str,
|
||||
max_files: int = 15, max_total: int = 120_000
|
||||
) -> Tuple[str, Dict[str, str]]:
|
||||
"""Read the ACTUAL content of ``paths`` (PDF -> markdown via
|
||||
opendataloader when available, else ``doc_extract`` for office/pdf/
|
||||
text). Returns ``(block, cache)`` — ``block`` is the concatenated
|
||||
content for the prompt (bounded), ``cache`` maps path -> text for
|
||||
reuse. Never raises."""
|
||||
from cowork_local.core import doc_extract
|
||||
|
||||
cache = dict(cache or {})
|
||||
parts, total = [], 0
|
||||
for p in paths[:max_files]:
|
||||
if total >= max_total:
|
||||
break
|
||||
text = cache.get(p)
|
||||
if text is None:
|
||||
try:
|
||||
if Path(p).suffix.lower() == ".pdf":
|
||||
text = pdf_to_markdown(p, tmp_dir)
|
||||
if not text:
|
||||
text, _n = doc_extract.extract_text(p)
|
||||
else:
|
||||
text, _n = doc_extract.extract_text(p)
|
||||
except Exception: # noqa: BLE001
|
||||
text = ""
|
||||
cache[p] = text or ""
|
||||
text = cache.get(p) or ""
|
||||
if not text:
|
||||
continue
|
||||
chunk = text[: max(0, max_total - total)]
|
||||
total += len(chunk)
|
||||
parts.append(f'--- {Path(p).name} ({p}) ---\n{chunk}')
|
||||
return ("\n\n".join(parts), cache)
|
||||
|
||||
|
||||
__all__ = ["pdf_to_markdown", "extract_file_contents"]
|
||||
+20
-10
@@ -17,7 +17,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QObject, QTimer, Signal
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
from .tasks import (
|
||||
advance_after_run, chain_action, dependencies_met, due_tasks, format_run_at,
|
||||
@@ -39,22 +39,32 @@ class TaskScheduler(QObject):
|
||||
# which fires before the worker thread has even begun).
|
||||
history_ready = Signal(str) # task_id
|
||||
|
||||
def __init__(self, ctx, tasks_dir: Optional[Path] = None, parent=None):
|
||||
def __init__(self, ctx, tasks_dir: Optional[Path] = None, parent=None, clock=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self.tasks_dir = tasks_dir # None → default TASKS_DIR
|
||||
self._workers: Dict[str, AgentWorker] = {} # task_id → running worker
|
||||
self._retries: Dict[str, int] = {}
|
||||
self._session_ids: Dict[str, str] = {} # task_id → its run's History session id
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(TICK_MS)
|
||||
self._timer.timeout.connect(self.tick)
|
||||
# R07-T03: the QTimer this class used to own directly is now behind a
|
||||
# small clock interface (start/stop/pump) — see
|
||||
# platform/qt/qt_scheduler_clock.py::QtSchedulerClock. Defaulting to a
|
||||
# real one here keeps every existing production call site (which
|
||||
# never passes `clock=`) unchanged; tests inject
|
||||
# tests/fakes/fake_clock.py::FakeClock to control ticks by hand with
|
||||
# no Qt event loop running. Imported lazily so importing core.tasks/
|
||||
# core.task_scheduler for the Qt-free logic doesn't require the Qt
|
||||
# adapter module to even exist in a headless test context.
|
||||
if clock is None:
|
||||
from ..infrastructure.qt.qt_scheduler_clock import QtSchedulerClock
|
||||
clock = QtSchedulerClock(self)
|
||||
self._clock = clock
|
||||
|
||||
# ---- lifecycle ----------------------------------------------------
|
||||
def start(self) -> None:
|
||||
self._recover_orphans()
|
||||
self.tick() # catch up overdue tasks right at app start
|
||||
self._timer.start()
|
||||
self._clock.start(TICK_MS, self.tick)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Request every running worker to stop, then WAIT (bounded) for them
|
||||
@@ -67,15 +77,15 @@ class TaskScheduler(QObject):
|
||||
``_on_done`` (the only place that writes the run into the task's
|
||||
history) never runs. The task's real output can already be sitting on
|
||||
disk while its history stays stuck on "running" forever. Pumping
|
||||
``processEvents()`` here lets that queued signal actually get
|
||||
delivered before the app finishes quitting.
|
||||
the clock here lets that queued signal actually get delivered before
|
||||
the app finishes quitting.
|
||||
"""
|
||||
self._timer.stop()
|
||||
self._clock.stop()
|
||||
deadline = time.monotonic() + STOP_WAIT_SECS
|
||||
while self._workers and time.monotonic() < deadline:
|
||||
for w in list(self._workers.values()):
|
||||
w.request_stop()
|
||||
QCoreApplication.processEvents()
|
||||
self._clock.pump()
|
||||
for w in list(self._workers.values()):
|
||||
w.wait(50)
|
||||
# Anything still alive past the deadline is abandoned here;
|
||||
|
||||
+29
-71
@@ -151,7 +151,14 @@ def save_task(task: Dict[str, Any], directory: Path = None) -> Path:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
|
||||
path = task_path(task["task_id"], directory)
|
||||
path.write_text(json.dumps(task, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
# R07-T01: atomic write — same class of bug already fixed in
|
||||
# core/projects.py and core/history.py at R06-T02 (plain write_text has a
|
||||
# gap between truncate and write; a crash there leaves a half-written
|
||||
# tasks/<id>.json that load_task() then silently treats as "missing",
|
||||
# dropping the task). Lazy import to match the existing call sites and
|
||||
# avoid a core -> infrastructure import at module load time.
|
||||
from ..infrastructure.persistence.json.atomic_write import write_json
|
||||
write_json(path, task)
|
||||
return path
|
||||
|
||||
|
||||
@@ -287,36 +294,32 @@ def chain_error(tasks: List[Dict[str, Any]], task_id: str,
|
||||
|
||||
|
||||
# ---- schedule math --------------------------------------------------------
|
||||
def _is_excluded_day(dt: datetime, sched: Dict[str, Any]) -> bool:
|
||||
"""True when ``dt`` falls on a day this schedule must skip: a weekend
|
||||
(working_days_only) or a public holiday of the configured country."""
|
||||
if sched.get("working_days_only") and dt.weekday() >= 5: # 5=Sat, 6=Sun
|
||||
return True
|
||||
if sched.get("skip_holidays"):
|
||||
# R07-T02: the actual date/cron math now lives in
|
||||
# domain/tasks/schedule_calculator.py::ScheduleCalculator (pure Python, unit
|
||||
# tested on its own — see tests/unit/test_schedule_calculator.py). Everything
|
||||
# below is a thin wrapper kept for backward compatibility: task_scheduler.py,
|
||||
# task_executors.py and ui/task_editor_dialog.py all still import these
|
||||
# module-level names from core.tasks, and core/holiday_calendar.py::is_holiday
|
||||
# / core/cron.py::Cron are only wired in HERE (lazily, matching the previous
|
||||
# lazy-import style) — domain/ is not allowed to import core/ (ADR-001 I2).
|
||||
_calculator: Optional[Any] = None
|
||||
|
||||
|
||||
def _get_calculator():
|
||||
global _calculator
|
||||
if _calculator is None:
|
||||
from .cron import Cron
|
||||
from .holiday_calendar import is_holiday
|
||||
from ..domain.tasks.schedule_calculator import ScheduleCalculator
|
||||
|
||||
if is_holiday(dt.date(), sched.get("holiday_country", "")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _add_month(dt: datetime) -> datetime:
|
||||
import calendar
|
||||
|
||||
year = dt.year + (1 if dt.month == 12 else 0)
|
||||
month = 1 if dt.month == 12 else dt.month + 1
|
||||
day = min(dt.day, calendar.monthrange(year, month)[1])
|
||||
return dt.replace(year=year, month=month, day=day)
|
||||
_calculator = ScheduleCalculator(is_holiday=is_holiday, make_cron=Cron)
|
||||
return _calculator
|
||||
|
||||
|
||||
def shift_off_excluded_days(dt: datetime, sched: Dict[str, Any]) -> datetime:
|
||||
"""Push ``dt`` forward one day at a time until it lands on an allowed day
|
||||
(same time of day) — used for one-time schedules set on a weekend/holiday."""
|
||||
guard = 0
|
||||
while _is_excluded_day(dt, sched) and guard < 400:
|
||||
dt += timedelta(days=1)
|
||||
guard += 1
|
||||
return dt
|
||||
return _get_calculator().shift_off_excluded_days(dt, sched)
|
||||
|
||||
|
||||
def compute_next_run(task: Dict[str, Any], after: datetime) -> Optional[datetime]:
|
||||
@@ -324,57 +327,12 @@ def compute_next_run(task: Dict[str, Any], after: datetime) -> Optional[datetime
|
||||
(daily / weekly / monthly / cron), or None for one-shot schedules.
|
||||
Occurrences on excluded days (weekends with working_days_only, public
|
||||
holidays with skip_holidays+holiday_country) are skipped forward."""
|
||||
sched = task.get("schedule", {})
|
||||
repeat = sched.get("repeat_type", "none")
|
||||
|
||||
if repeat == "cron":
|
||||
from .cron import Cron, CronError
|
||||
|
||||
try:
|
||||
cron = Cron(sched.get("cron_expression") or "")
|
||||
except CronError:
|
||||
return None
|
||||
nxt = cron.next_after(after)
|
||||
guard = 0
|
||||
while nxt is not None and _is_excluded_day(nxt, sched) and guard < 400:
|
||||
nxt = cron.next_after(nxt)
|
||||
guard += 1
|
||||
return nxt
|
||||
|
||||
base = parse_run_at(sched.get("run_at"))
|
||||
if base is None:
|
||||
return None
|
||||
if repeat == "daily":
|
||||
advance = lambda d: d + timedelta(days=1) # noqa: E731
|
||||
elif repeat == "weekly":
|
||||
advance = lambda d: d + timedelta(weeks=1) # noqa: E731
|
||||
elif repeat == "monthly":
|
||||
advance = _add_month
|
||||
else:
|
||||
return None
|
||||
nxt = base
|
||||
while nxt <= after:
|
||||
nxt = advance(nxt)
|
||||
guard = 0
|
||||
while _is_excluded_day(nxt, sched) and guard < 400:
|
||||
nxt = advance(nxt)
|
||||
guard += 1
|
||||
return nxt
|
||||
return _get_calculator().compute_next_run(task, after)
|
||||
|
||||
|
||||
def due_tasks(tasks: List[Dict[str, Any]], now: datetime) -> List[Dict[str, Any]]:
|
||||
"""Tasks that should start now: Scheduled + schedule enabled + run_at due."""
|
||||
due = []
|
||||
for t in tasks:
|
||||
if t.get("status") != "scheduled":
|
||||
continue
|
||||
sched = t.get("schedule", {})
|
||||
if not sched.get("enabled"):
|
||||
continue
|
||||
run_at = parse_run_at(sched.get("run_at"))
|
||||
if run_at is not None and run_at <= now:
|
||||
due.append(t)
|
||||
return due
|
||||
return _get_calculator().due_tasks(tasks, now)
|
||||
|
||||
|
||||
# ---- post-run bookkeeping (pure; scheduler applies + saves) ---------------
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
# BÁO CÁO KẾT QUẢ — TEAM HOA: EPIC R05, R06
|
||||
|
||||
* **Dự án**: Cowork Local (Cowork-Local BamBOO)
|
||||
* **Team**: 🟢 Team Hoa — Workspace, Filesystem, Scheduling & Tool Registry
|
||||
* **Nhánh**: `feature/teamhoa/r05-r06` (tạo từ `origin/feature/deltateam/refactor-plan`, chưa push lên remote — xem mục 7)
|
||||
* **Thời gian thực hiện**: 21/08/2026, 21:40 ➔ 22:57
|
||||
* **Ngày báo cáo**: 22/08/2026
|
||||
* **Tài liệu gốc**: `Feature_Architecture_Proposal.md`, `Refactoring_Checklist.md`, `plan.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Tóm tắt điều hành
|
||||
|
||||
Hoàn tất **10/10 task** của 2 EPIC được giao: **R05** (Tool, MCP & Connector Policy) và **R06** (Workspace, Filesystem & History Isolation). Đã commit 2 commit trên branch cục bộ; **chưa push lên Gitea** — remote từ chối với lỗi quyền ghi (xem mục 7 #1).
|
||||
|
||||
| Chỉ số | Kết quả |
|
||||
| :--- | :--- |
|
||||
| Task hoàn thành | **10/10** (R05: 5, R06: 5) |
|
||||
| Commit | 2 (`ae4fe72`, `cf542b7`) |
|
||||
| File thay đổi | 41 (27 file mới, 14 file sửa — 1 file (`docs/refactor/Refactoring_Checklist.md`) sửa ở cả 2 commit) |
|
||||
| Dòng code | +3.054 / −459 |
|
||||
| Test | **283 pass** / 12,5s (283/287 — 4 fail có sẵn từ trước, không do R05/R06) |
|
||||
| Test suite nhanh (unit + contract + characterization + routing) | **256 pass / 4,5s** |
|
||||
| CASAN Check 3 (`scripts/check_imports.py`) | **PASS** — 0 Qt import trong `domain/`, `application/` |
|
||||
| File production > 400 dòng (file mới) | **0** — lớn nhất `domain/tools/tool_registry.py` 125 dòng |
|
||||
|
||||
**2 lỗi thật được phát hiện và sửa trong quá trình làm** (chi tiết mục 5): một lỗ hổng bảo mật (MCP/connector tool không qua permission gate) và một race condition (turn chạy ngầm lưu nhầm lịch sử vào project khác).
|
||||
|
||||
---
|
||||
|
||||
## 2. Kết quả theo từng EPIC
|
||||
|
||||
### 🔹 EPIC R05 — Tool, MCP & Connector Policy (5/5)
|
||||
|
||||
| Task | Sản phẩm | Ghi chú |
|
||||
| :--- | :--- | :--- |
|
||||
| R05-T01 | `domain/tools/tool_descriptor.py`, `tool_registry.py` | `ToolCapability` (Flag: READ/WRITE/EXECUTE/NETWORK, kết hợp được) + `ToolDescriptor` + `ToolRegistry` |
|
||||
| R05-T02 | `infrastructure/filesystem/{file_tools,command_tools,fetch_tools,tool_context}.py` | Tách if/elif dispatcher của `core/tools.py`; `core/tools.py` còn 291 dòng (từ 566), là shim strangler-fig |
|
||||
| R05-T03 | `application/conversations/tool_policy_gateway.py` | `ToolPolicyGateway.allow(name, gate, payload)` — thay 2 chỗ check hardcode riêng biệt (`chat_agent.py`, `code_agent.py`) bằng 1 lookup capability |
|
||||
| R05-T04 | Sửa `core/chat_agent.py`, `core/mcp_client.py` | **Thay đổi hành vi có chủ đích** — xem mục 5, Lỗi 1 |
|
||||
| R05-T05 | `infrastructure/mcp/mcp_source_manager.py` | Tách lifecycle connection MCP khỏi `state.py::AppContext` |
|
||||
|
||||
**Vấn đề gốc đã giải quyết** — cùng một việc "tool này có cần xác nhận trước khi chạy không" tồn tại **3 cách trả lời khác nhau**:
|
||||
|
||||
```
|
||||
core/chat_agent.py::run_cowork name in ("run_command", "install_package")
|
||||
core/code_agent.py::run_code name in (WRITE_TOOLS | MS365_WRITE_TOOLS)
|
||||
core/mcp_client.py / ext_connectors.py (không hỏi gì cả)
|
||||
```
|
||||
|
||||
Cách thứ 3 là một lỗ hổng thật, không phải khác biệt thiết kế — xem mục 5.
|
||||
|
||||
### 🔹 EPIC R06 — Workspace, Filesystem & History Isolation (5/5)
|
||||
|
||||
| Task | Sản phẩm | Ghi chú |
|
||||
| :--- | :--- | :--- |
|
||||
| R06-T01 | `domain/workspaces/workspace_session.py` | `WorkspaceSession` — snapshot bất biến (project_id/workspace_root/sandbox_dir/allowed_paths) + `is_allowed(path)`, cùng khuôn với `ConversationExecutionRequest` (R04-T01) |
|
||||
| R06-T02 | `infrastructure/persistence/json/{atomic_write,workspace_repository_impl,conversation_repository_impl}.py` | **Sửa bug thật** — xem mục 5, Lỗi 2 |
|
||||
| R06-T03 | `infrastructure/filesystem/execution_workspace.py` | Đặt tên cho quy ước `.scratch` đã có, không đổi vị trí file |
|
||||
| R06-T04 | Sửa `ui/chat_panel.py` | **Sửa race condition thật** — xem mục 5, Lỗi 3 |
|
||||
| R06-T05 | `application/workspaces/file_workspace_service.py` | File Explorer/AI Editor gọi `core/tools.py::execute_tool` giống agent, không viết lại logic |
|
||||
|
||||
---
|
||||
|
||||
## 3. Kiến trúc sau refactor
|
||||
|
||||
```text
|
||||
presentation/ (chưa đổi ở đợt này — ui/chat_panel.py chỉ thêm 1 field "home_history_dir")
|
||||
│
|
||||
▼
|
||||
application/ conversations/tool_policy_gateway.py ← ALLOW/CONFIRM cho mọi tool call
|
||||
workspaces/file_workspace_service.py ← file ops cho File Explorer/AI Editor
|
||||
│ (100% pure Python — check_imports.py chặn import Qt)
|
||||
▼
|
||||
domain/ tools/{tool_descriptor,tool_registry}.py ← capability + catalogue
|
||||
workspaces/workspace_session.py ← snapshot workspace bất biến
|
||||
▲
|
||||
infrastructure/ filesystem/{file_tools,command_tools,fetch_tools,tool_context,execution_workspace}.py
|
||||
mcp/mcp_source_manager.py ← lifecycle connection MCP
|
||||
persistence/json/{atomic_write,*_repository_impl}.py
|
||||
```
|
||||
|
||||
**Nguyên tắc di trú (ADR-001 mục 4, tiếp nối cách Team Duy làm ở R04)**: **không viết lại engine**. `core/tools.py::execute_tool`, `core/chat_agent.py::run_cowork`, `core/code_agent.py::run_code` vẫn là engine bên dưới — tầng mới chỉ sở hữu phần phân loại rủi ro (R05) và phần định danh workspace (R06) mà trước đây nằm rải rác/hardcode. `pytest` xanh liên tục giữa các bước.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bằng chứng kiểm thử
|
||||
|
||||
### Phân bố test (bao gồm test mới của Team Hoa)
|
||||
|
||||
| Suite | Số test | Ghi chú |
|
||||
| :--- | ---: | :--- |
|
||||
| `tests/unit/` | 137 | +41 test mới (R05: 26, R06: 15 — không tính `test_history_dir_race.py`, ở `integration/`) |
|
||||
| `tests/contracts/` | 29 | có sẵn từ R03, không đổi |
|
||||
| `tests/characterization/` | 13 | có sẵn từ R01, vẫn xanh — xác nhận `run_cowork` không hồi quy sau khi sửa gate |
|
||||
| `tests/routing/` | 79 | có sẵn từ trước, không đụng |
|
||||
| **Cộng 4 suite nhanh** | **256** (4 fail routing-env, không do R05/R06) | 4,5s |
|
||||
| `tests/integration/` | 27 | +2 test mới: `test_history_dir_race.py` — Qt offscreen thật, không phải test double |
|
||||
| **Tổng** | **287** (283 pass) | 12,5s |
|
||||
|
||||
### Đối chiếu Definition of Done (theo `DeltaTeam_prompt.md` / mẫu Team Duy)
|
||||
|
||||
| # | Tiêu chí | Kết quả |
|
||||
| :--- | :--- | :--- |
|
||||
| 1 | Mọi file mới < 400 dòng | ✅ Lớn nhất: `domain/tools/tool_registry.py` 125 dòng |
|
||||
| 2 | 0 import Qt trong `domain/`, `application/` | ✅ `check_imports.py` PASS |
|
||||
| 3 | Comment tiếng Anh giải thích lý do ở mọi khối sửa/mới | ✅ |
|
||||
| 4 | Có unit/contract/integration test, verify bằng chạy thật | ✅ 41 test mới + 2 test Qt offscreen thật cho race condition |
|
||||
| 5 | Không hồi quy | ✅ 283/287 pass — 4 fail là lỗi có sẵn từ trước R05/R06 (2 EPIC R02, 2 do môi trường máy có Ollama thật) |
|
||||
| 6 | Ghi Start/End vào Checklist | ✅ 10 task đã tick kèm mốc thời gian |
|
||||
| 7 | Cổng CASAN (`run_quality_gate.py`, R10-T02) | ⚠️ Chưa viết (thuộc R10, chưa tới lượt) — Check 3 đã PASS |
|
||||
|
||||
---
|
||||
|
||||
## 5. Hai lỗi thật phát hiện và sửa trong quá trình làm
|
||||
|
||||
### 🔴 Lỗi 1 (R05-T04) — Tool MCP/Connector chạy hoàn toàn không qua permission gate
|
||||
|
||||
`core/chat_agent.py::run_cowork` có 2 nhánh dispatch tool call: nhánh built-in (`read_file`, `run_command`, ...) đi qua gate xác nhận khi Settings bật "confirm before running commands"; nhánh `extra_tools` (mọi tool từ MCP server hoặc Connector — `core/mcp_client.py`, `core/ext_connectors.py`) gọi thẳng:
|
||||
|
||||
```python
|
||||
if name in extra_names and extra_executor is not None:
|
||||
...
|
||||
result = extra_executor(name, args) # KHÔNG có bước xác nhận nào
|
||||
```
|
||||
|
||||
Nghĩa là một MCP server (kể cả server tự cấu hình, hoặc MS365 write-tool như `send_mail`) chạy **auto-run tuyệt đối**, bất kể người dùng đã bật "confirm before running commands" trong Settings hay chưa. Đây không phải khác biệt thiết kế có chủ đích — không có ghi chú, không có toggle riêng cho việc này.
|
||||
|
||||
*Sửa*: mọi `extra_tools` được gắn `ToolCapability` mặc định bảo toàn (`WRITE|EXECUTE|NETWORK` — vì MCP không có chuẩn khai báo rủi ro), đăng ký vào registry của turn, và đi qua CÙNG `ToolPolicyGateway` với built-in tools.
|
||||
|
||||
**Đây là thay đổi hành vi người dùng sẽ thấy**: khi "confirm before running commands" đang bật, tool MCP/connector từ giờ sẽ hỏi xác nhận — giống `run_command`. Verify bằng test `tests/unit/test_cowork_extra_tool_policy.py` (3 test: rejected trước khi executor chạy, approved thì chạy, `gate=None` vẫn auto-run như cũ).
|
||||
|
||||
### 🟠 Lỗi 2 (R06-T04) — Turn chạy ngầm lưu nhầm lịch sử vào project khác
|
||||
|
||||
`ui/chat_panel.py::_persist_session` (lưu hội thoại của một turn **chạy ngầm**, không phải conversation đang xem) gọi:
|
||||
|
||||
```python
|
||||
save_conversation(self.ctx.config.history_dir(), ...)
|
||||
```
|
||||
|
||||
`history_dir()` đọc `config._project_history_dir` — một field **dùng chung** trên `AppContext.config`, được `ui/workspace_tab.py::_load_current` ghi đè mỗi lần người dùng đổi project trong màn Workspace. Nếu một turn ở project A còn đang chạy (ví dụ Scheduled Task, hoặc user gõ câu hỏi rồi chuyển sang xem project B ngay) và người dùng đổi sang project B **trước khi** turn đó lưu xong, hội thoại của project A bị ghi nhầm vào thư mục lịch sử của project B.
|
||||
|
||||
*Sửa*: thêm `"home_history_dir"` vào dict `ctx` mà mỗi turn đã có sẵn (cùng quy ước với `home_id`/`home_messages`/`home_title` — dict này được author code gốc thiết kế đúng cho mục đích này, chỉ thiếu 1 field), chụp giá trị **tại lúc submit** thay vì đọc sống lúc lưu.
|
||||
|
||||
*Kèm 1 phát hiện phụ*: `_save_snapshot` (dùng cho conversation ĐANG XEM) đã có logic đúng từ trước để không ghi đè `project_id` của một turn nền bằng project hiện tại — chỉ riêng **thư mục lưu** là bị bỏ sót, không phải toàn bộ cơ chế bị thiếu.
|
||||
|
||||
Verify bằng test Qt offscreen thật (không phải double): `tests/integration/test_history_dir_race.py` — dựng `ChatPanel` thật, giả lập đổi project giữa lúc turn chạy, xác nhận file được lưu đúng thư mục project A.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cải thiện phụ (không nằm trong yêu cầu task)
|
||||
|
||||
| Cải thiện | Ảnh hưởng |
|
||||
| :--- | :--- |
|
||||
| `core/projects.py::save_project`, `core/history.py::save_conversation/rename_conversation/set_pinned` chuyển sang ghi atomic (`infrastructure/persistence/json/atomic_write.py`) | Trước đây `path.write_text(json.dumps(...))` không atomic — crash/kill giữa lúc ghi để lại file JSON hỏng, và `load_project`/`load_conversation` coi file hỏng như "không tồn tại" ➔ **mất project hoặc hội thoại âm thầm, không báo lỗi**. Có test giả lập crash giữa lúc ghi xác nhận file cũ không bị hỏng (`tests/unit/test_atomic_write_and_repositories.py`) |
|
||||
| `McpServerConnection.is_alive()` (mới, `core/mcp_client.py`) | Nhỏ, cộng thêm — cho `McpToolSourceManager` biết một connection cached đã chết (subprocess crash) để khởi động lại, thay vì cache giữ một connection chết vô thời hạn |
|
||||
|
||||
---
|
||||
|
||||
## 7. Còn nợ & cần quyết định
|
||||
|
||||
| # | Nội dung | Người quyết |
|
||||
| :--- | :--- | :--- |
|
||||
| 1 | **Branch chưa lên được Gitea** — `git push` bị từ chối: `User permission denied for writing` (pre-receive hook). Cần cấp quyền push cho tài khoản git đang dùng trên máy này, hoặc push bằng tài khoản khác có quyền. | Admin Gitea |
|
||||
| 2 | **Xung đột file với EPIC R02 (Team Nam)**: R02-T01 giao `infrastructure/persistence/json/atomic_json_file.py`. R06-T02 cần atomic write ngay nên tạo `atomic_write.py` (tên khác, cùng thư mục) — không đụng file của Team Nam, nhưng 2 module cùng mục đích sẽ tồn tại song song cho tới khi hợp nhất. | Team Nam (khi bắt đầu R02-T01) |
|
||||
| 3 | **`WorkspaceRepository`/`ConversationRepository`/`FileWorkspaceService` chưa có call site thật** — giống tình trạng `ProviderRegistry` của Team Duy ở R03 (mục 7 #1 trong báo cáo Team Duy). Mọi nơi trong production vẫn gọi trực tiếp `core/projects.py`/`core/history.py`/`core/tools.py::execute_tool`. | Team Hoa (nối dây ở EPIC sau) |
|
||||
| 4 | **R06-T04 không sửa đúng y nguyên `ui/workspace_tab.py::_load_current` như mô tả gốc trong `plan.md`** — bug thật nằm ở điểm ĐỌC (`ui/chat_panel.py::_persist_session`), không phải điểm GHI (`_load_current` chỉ set field, tự nó không đọc lại). Đã sửa đúng điểm đọc, có test thật xác nhận. Việc đổi `_load_current` sang "đồng bộ bằng session id" như plan gốc gợi ý cần tách sâu hơn `WorkspaceTab`/`ChatPanel`, thuộc phạm vi R08 (UI/Application Separation). | Team Duy (R08) |
|
||||
| 5 | **2 test đỏ có sẵn từ trước, không do R05/R06**: `tests/test_config_security.py` × 2 (EPIC R02/Team Nam, đã ghi nhận từ báo cáo Team Duy) và `tests/unit/test_routing_wiring.py` × 2 (môi trường máy này có Ollama/llama3.1 thật + config routing cục bộ khác giả định "fresh install" của test — nghi là do máy chạy test có cấu hình routing/Ollama khác máy Team Duy dùng, cần Team Duy xác nhận lại trên máy sạch). | Team Nam (#1), Team Duy (#2) |
|
||||
|
||||
---
|
||||
|
||||
## 8. Phạm vi chưa kiểm thử
|
||||
|
||||
Nêu rõ để tránh hiểu nhầm mức độ bảo đảm:
|
||||
|
||||
* **R05-T04 (gate cho MCP/connector) chưa test với MCP server thật** — toàn bộ test dùng `ToolSpec` giả (`_EXTRA_SPEC` trong `test_cowork_extra_tool_policy.py`), chưa có tình huống thật với `core/mcp_client.py::McpServerConnection` chạy subprocess thật.
|
||||
* **`McpToolSourceManager` (R05-T05) chưa test với subprocess MCP thật** — test dùng `_FakeConnection`, không spawn tiến trình. Đã smoke-test `AppContext.build_mcp_tools()` thật (không có server nào cấu hình → chỉ trả về ms365 local tools) nhưng chưa thử ensure/restart trên một server thật.
|
||||
* **`ui/folder_tab.py`, `ui/file_edit_dialog.py` chưa được nối vào `FileWorkspaceService` (R06-T05)** — dịch vụ tồn tại và có test unit đầy đủ, nhưng chưa xác nhận bằng cách chạy UI thật (đã mở app kiểm tra sau R05, nhưng không lặp lại cho R06's file explorer flow cụ thể).
|
||||
* **Đã mở app thật 1 lần sau khi sửa `ui/chat_panel.py` (R06-T04)** để xác nhận không crash lúc khởi động — chưa thử tay thao tác "đổi project giữa lúc chat đang trả lời" trên UI thật (chỉ verify bằng test offscreen).
|
||||
|
||||
---
|
||||
|
||||
## 9. Việc kế tiếp của Team Hoa
|
||||
|
||||
| EPIC | Nội dung | Điều kiện |
|
||||
| :--- | :--- | :--- |
|
||||
| **R07** (Scheduling & Workflow Runtime) | Tách `TaskRepository`/`ScheduleCalculator` khỏi `QTimer` (`core/task_scheduler.py`), xây `TaskApplicationService` | Phối hợp 🟣 Team Nam (Co4E Workflows) |
|
||||
| **R08** (T01 ➔ ...) | Phần Team Hoa trong tách UI (`ui/workspace_tab.py`, `ui/folder_tab.py`, `ui/schedule_task_tab.py`, `ui/dashboard_tab.py`, Graph) | Chờ R07 |
|
||||
| Nối `WorkspaceRepository`/`ConversationRepository`/`FileWorkspaceService` vào call site thật | Xem mục 7 #3 | Có thể làm sớm hơn R07/R08 nếu được yêu cầu |
|
||||
|
||||
---
|
||||
|
||||
## 10. Lịch sử commit
|
||||
|
||||
| Commit | Nội dung |
|
||||
| :--- | :--- |
|
||||
| `ae4fe72` | feat(R05): tool capability registry, unified policy gateway, MCP lifecycle manager |
|
||||
| `cf542b7` | feat(R06): workspace session snapshot, atomic persistence, history-dir race fix |
|
||||
@@ -0,0 +1,238 @@
|
||||
# BÁO CÁO KẾT QUẢ — TEAM HOA: EPIC R05, R06, R07, R08 (phần Team Hoa)
|
||||
|
||||
* **Dự án**: Cowork Local (Cowork-Local BamBOO)
|
||||
* **Team**: 🟢 Team Hoa — Workspace, Filesystem, Scheduling & Tool Registry
|
||||
* **Nhánh**: `feature/teamhoa/r05-r08` (tạo từ `origin/feature/deltateam/refactor-plan`, có sẵn nền R01/R03/R04 của Team Duy; đổi tên từ `feature/teamhoa/r05-r06` sau khi gộp thêm R07/R08)
|
||||
* **Thời gian thực hiện**: 21/08/2026 21:40 → 27/08/2026 20:52
|
||||
* **Ngày báo cáo**: 27/08/2026 (bản gộp, thay thế `BaoCao_TeamHoa_R05_R06.md` và `BaoCao_TeamHoa_R07_R08.md`)
|
||||
* **Tài liệu gốc**: `Feature_Architecture_Proposal.md`, `Refactoring_Checklist.md`, `plan.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Tóm tắt điều hành
|
||||
|
||||
Hoàn tất **toàn bộ 19/19 task thuộc phạm vi Team Hoa** trên 4 EPIC: **R05** (Tool, MCP & Connector Policy), **R06** (Workspace, Filesystem & History Isolation), **R07** (Scheduling & Workflow Runtime), **R08** (UI/Application Separation — phần Team Hoa, T11→T14).
|
||||
|
||||
| Chỉ số | Kết quả |
|
||||
| :--- | :--- |
|
||||
| Task hoàn thành | **19/19** (R05: 5, R06: 5, R07: 5, R08: 4 — không tính R07-T06/R08-T01→T10 thuộc Team Duy/Team Nam) |
|
||||
| File thay đổi | 92+ (phần lớn file mới) |
|
||||
| Test cuối cùng | **377 pass / 4 fail** (xem tiến trình chi tiết ở mục 4) |
|
||||
| CASAN Check 3 (`scripts/check_imports.py`) | **PASS** — 0 Qt import trong `domain/`, `application/` |
|
||||
| File production > 400 dòng (file mới) | **0** — lớn nhất `presentation/graph/graph_renderer.py` 391 dòng |
|
||||
| `python -c "import cowork_local.app"` | **OK** sau mọi task |
|
||||
|
||||
**3 lỗi thật phát hiện và sửa**, **1 quyết định kiến trúc đổi so với plan gốc (xác nhận bằng thực nghiệm)** — chi tiết mục 5.
|
||||
|
||||
---
|
||||
|
||||
## 2. Kết quả theo từng EPIC
|
||||
|
||||
### 🔹 EPIC R05 — Tool, MCP & Connector Policy (5/5)
|
||||
|
||||
| Task | Sản phẩm | Ghi chú |
|
||||
| :--- | :--- | :--- |
|
||||
| R05-T01/T02 | `domain/tools/{tool_descriptor,tool_registry}.py`, `infrastructure/filesystem/{file_tools,command_tools,fetch_tools}.py` | Tách if/elif dispatcher của `core/tools.py`; `core/tools.py` còn lại là shim strangler-fig (re-export `ToolContext`/`ToolError`, dispatch qua dict), 566 ➔ 291 dòng. |
|
||||
| R05-T03 | `application/conversations/tool_policy_gateway.py::ToolPolicyGateway` | Thay 2 chỗ check hardcode riêng biệt (`chat_agent.py`, `code_agent.py`) bằng 1 lookup capability chung. |
|
||||
| R05-T04 | Sửa `core/chat_agent.py`, `core/mcp_client.py` | **Thay đổi hành vi có chủ đích** — xem mục 5, Lỗi 1. |
|
||||
| R05-T05 | `infrastructure/mcp/mcp_source_manager.py::McpToolSourceManager` | Tách lifecycle connection MCP khỏi `state.py::AppContext`. |
|
||||
|
||||
### 🔹 EPIC R06 — Workspace, Filesystem & History Isolation (5/5)
|
||||
|
||||
| Task | Sản phẩm | Ghi chú |
|
||||
| :--- | :--- | :--- |
|
||||
| R06-T01 | `domain/workspaces/workspace_session.py::WorkspaceSession` | Snapshot bất biến (project_id/workspace_root/sandbox_dir/allowed_paths) + `is_allowed(path)`. |
|
||||
| R06-T02 | `infrastructure/persistence/json/{atomic_write,workspace_repository_impl,conversation_repository_impl}.py` | **Sửa bug thật** — xem mục 5, Lỗi 2. |
|
||||
| R06-T03 | `infrastructure/filesystem/execution_workspace.py::ExecutionWorkspace` | Đặt tên cho quy ước `.scratch` đã có sẵn. |
|
||||
| R06-T04 | Sửa `ui/chat_panel.py` | **Sửa race condition thật** — xem mục 5, Lỗi 3. |
|
||||
| R06-T05 | `application/workspaces/file_workspace_service.py::FileWorkspaceService` | Seam cho File Explorer/AI Editor gọi `execute_tool` giống agent — **chưa có call site thật lúc R06 xong; đã nối dây ở R08-T12** (xem mục 5). |
|
||||
|
||||
### 🔹 EPIC R07 — Scheduling & Workflow Runtime (5/5, phạm vi Team Hoa)
|
||||
|
||||
| Task | Sản phẩm | Ghi chú |
|
||||
| :--- | :--- | :--- |
|
||||
| R07-T01 | `infrastructure/persistence/json/task_repository_impl.py::TaskRepository` | Bọc CRUD của `core/tasks.py`. **Sửa bug thật**: `save_task` trước đây ghi không atomic — cùng lớp bug đã sửa ở R06-T02. |
|
||||
| R07-T02 | `domain/tasks/schedule_calculator.py::ScheduleCalculator` | Tách "schedule math" (cron/interval/daily/weekly/monthly + holiday exclusion) thành pure Python, trước đây **0 test**, giờ có 14 test. |
|
||||
| R07-T03 | `infrastructure/qt/qt_scheduler_clock.py::QtSchedulerClock` | Bọc `QTimer` sau 1 interface nhỏ, inject qua `clock=`. **Đổi vị trí so với plan gốc** — xem mục 5. |
|
||||
| R07-T04 | `application/scheduling/task_application_service.py::TaskApplicationService` | Gom CRUD + luật kéo-thả Kanban (`move_to_status`). |
|
||||
| R07-T05 | `application/scheduling/ai_task_planner_service.py::AiTaskPlannerService` | Seam cho `plan_tasks`/`import_tasks`. |
|
||||
|
||||
*(R07-T06 `Co4EWorkflowService` là việc Team Nam — không đụng.)*
|
||||
|
||||
### 🔹 EPIC R08 — UI/Application Separation (4/4, phạm vi Team Hoa: T11→T14)
|
||||
|
||||
`presentation/` **chưa tồn tại** trước task này — Team Hoa tạo cấu trúc lần đầu.
|
||||
|
||||
| Task | God file gốc | Tách thành |
|
||||
| :--- | :--- | :--- |
|
||||
| R08-T11 | `ui/schedule_task_tab.py` (795 dòng) | `presentation/scheduling/{kanban_board_widget,calendar_view_widget,ai_task_creator_dialog,ai_task_import_dialog,run_history_dialog}.py` + shell |
|
||||
| R08-T12 | `ui/folder_tab.py` (1587 dòng — lớn nhất) | `presentation/folder/{workspace_file_tree,document_preview_manager,code_editor,office_document_renderer,ai_file_editor_dialog,ai_edit_model_resolver,ai_edit_pipeline}.py` + shell |
|
||||
| R08-T13 | `ui/dashboard_tab.py` (437 dòng) | `presentation/dashboard/{token_usage_card_widget,usage_chart_widget,habits_widget}.py` + shell, `application/monitoring/dashboard_query_service.py` |
|
||||
| R08-T14 | `ui/structure_graph_view.py` (1035 dòng) | `presentation/graph/{graph_scene_items,graph_renderer,graph_messages_view,graph_qa_widget}.py` + shell |
|
||||
|
||||
*(R08-T01→T10 thuộc Team Duy/Team Nam — không đụng.)* Mỗi god-file cũ chỉ có 1-2 nơi khởi tạo thật (`app.py`, `ui/workspace_tab.py`) nên đã **sửa thẳng import site** và **xoá hẳn file `ui/*.py` cũ** thay vì giữ shim (khác `core/tools.py` ở R05, có hàng chục call site).
|
||||
|
||||
---
|
||||
|
||||
## 3. Kiến trúc sau refactor
|
||||
|
||||
```text
|
||||
presentation/ (MỚI ở R08 — Team Hoa tạo cấu trúc lần đầu)
|
||||
scheduling/ {kanban_board_widget, calendar_view_widget,
|
||||
ai_task_creator_dialog, ai_task_import_dialog,
|
||||
run_history_dialog, schedule_task_tab}.py
|
||||
folder/ {workspace_file_tree, document_preview_manager, code_editor,
|
||||
office_document_renderer, ai_file_editor_dialog,
|
||||
ai_edit_model_resolver, ai_edit_pipeline, folder_tab}.py
|
||||
dashboard/ {token_usage_card_widget, usage_chart_widget,
|
||||
habits_widget, dashboard_tab}.py
|
||||
graph/ {graph_scene_items, graph_renderer, graph_messages_view,
|
||||
graph_qa_widget, structure_graph_view}.py
|
||||
shared/ web_engine_support.py (HAS_WEB_ENGINE dùng chung)
|
||||
│
|
||||
▼
|
||||
application/ conversations/tool_policy_gateway.py ← ALLOW/CONFIRM cho mọi tool call (R05)
|
||||
workspaces/{file_workspace_service, file_preview_helpers,
|
||||
ai_edit_output, graph_index_service}.py (R06, R08)
|
||||
scheduling/{task_application_service, ai_task_planner_service}.py (R07)
|
||||
monitoring/dashboard_query_service.py (R08)
|
||||
│ (100% pure Python — check_imports.py chặn import Qt)
|
||||
▼
|
||||
domain/ tools/{tool_descriptor,tool_registry}.py ← capability + catalogue (R05)
|
||||
workspaces/workspace_session.py ← snapshot workspace bất biến (R06)
|
||||
tasks/schedule_calculator.py ← due-time/cron math thuần Python (R07)
|
||||
▲
|
||||
infrastructure/ filesystem/{file_tools,command_tools,fetch_tools,tool_context,execution_workspace}.py (R05/R06)
|
||||
mcp/mcp_source_manager.py ← lifecycle connection MCP (R05)
|
||||
persistence/json/{atomic_write,workspace_repository_impl,
|
||||
conversation_repository_impl,task_repository_impl}.py (R06/R07)
|
||||
qt/qt_scheduler_clock.py ← QTimer đằng sau 1 interface nhỏ (R07)
|
||||
```
|
||||
|
||||
**Nguyên tắc di trú xuyên suốt cả 4 EPIC (ADR-001 mục 4, tiếp nối cách Team Duy làm ở R04)**: **không viết lại engine**. `core/tools.py::execute_tool`, `core/chat_agent.py::run_cowork`, `core/tasks.py`, `core/task_scheduler.py`, `core/task_executors.py` vẫn là engine bên dưới — tầng mới chỉ sở hữu phần từng nằm rải rác/hardcode trong widget hoặc dispatcher. `pytest` xanh liên tục giữa các bước, chạy full suite sau MỖI task.
|
||||
|
||||
**Riêng ở R08**: khi 1 file bị tách vượt 400 dòng dù đã theo đúng mapping gốc, đã tách thêm file phụ theo kiểu **composition** (class phụ nhận `owner` là widget chính) thay vì service riêng — ví dụ `run_history_dialog.py`, `office_document_renderer.py`, `ai_edit_pipeline.py`, `ai_edit_model_resolver.py`, `graph_messages_view.py`. Đây là split kỹ thuật để đạt giới hạn LOC, không phải ranh giới tầng kiến trúc.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bằng chứng kiểm thử
|
||||
|
||||
### Tiến trình test qua từng EPIC
|
||||
|
||||
| Mốc | Tổng pass | Ghi chú |
|
||||
| :--- | ---: | :--- |
|
||||
| Sau R05+R06 | 283 (/287, 4 fail) | +41 unit test + 2 integration test (Qt offscreen thật) |
|
||||
| Sau R07 | 328 | +45 test mới (task repo, schedule calculator, Qt clock, task/AI-planner services) |
|
||||
| Sau R08 | **377** | +49 test mới (4 widget split, mỗi cái có unit + integration Qt offscreen) |
|
||||
|
||||
**4 fail cuối cùng — cùng 1 baseline có sẵn từ trước, xuyên suốt cả 4 EPIC, không phải do Team Hoa**:
|
||||
* `tests/test_config_security.py` × 2 (EPIC R02/Team Nam — `config.py` hardcode `sandbox_pw`)
|
||||
* `tests/unit/test_routing_wiring.py` × 2 (môi trường máy này có Ollama/llama3.1 thật, khác giả định "fresh install" của test)
|
||||
|
||||
### Đối chiếu Definition of Done
|
||||
|
||||
| # | Tiêu chí | Kết quả |
|
||||
| :--- | :--- | :--- |
|
||||
| 1 | Mọi file mới < 400 dòng | ✅ Lớn nhất: `presentation/graph/graph_renderer.py` 391 dòng |
|
||||
| 2 | 0 import Qt trong `domain/`, `application/` | ✅ `check_imports.py` PASS |
|
||||
| 3 | Comment tiếng Anh giải thích lý do ở mọi khối sửa/mới | ✅ |
|
||||
| 4 | Có unit/contract/integration test, verify bằng chạy thật | ✅ +90 test mới sau R05/R06 lên tới 377; mọi widget Qt test bằng offscreen thật, không double |
|
||||
| 5 | Không hồi quy | ✅ 377/381 pass — 4 fail cùng 1 baseline có sẵn, không đổi qua 4 EPIC |
|
||||
| 6 | Ghi Start/End vào Checklist | ✅ 19 task đã tick kèm mốc thời gian thật |
|
||||
| 7 | Cổng CASAN (`run_quality_gate.py`, R10-T02) | ⚠️ Chưa viết (thuộc R10, chưa tới lượt) — Check 3 đã PASS |
|
||||
|
||||
---
|
||||
|
||||
## 5. Lỗi thật phát hiện & quyết định kiến trúc
|
||||
|
||||
### 🔴 Lỗi 1 (R05-T04) — Tool MCP/Connector chạy hoàn toàn không qua permission gate
|
||||
|
||||
`core/chat_agent.py::run_cowork` có 2 nhánh dispatch tool call: built-in đi qua gate xác nhận khi Settings bật "confirm before running commands"; nhánh `extra_tools` (mọi tool từ MCP server hoặc Connector) gọi thẳng `extra_executor(name, args)` **không qua bước xác nhận nào**. Đây không phải khác biệt thiết kế — không có ghi chú, không có toggle riêng.
|
||||
|
||||
*Sửa*: mọi `extra_tools` gắn `ToolCapability` mặc định bảo toàn (`WRITE|EXECUTE|NETWORK`), đi qua CÙNG `ToolPolicyGateway` với built-in tools. **Thay đổi hành vi người dùng sẽ thấy**: tool MCP/connector giờ hỏi xác nhận khi "confirm before running commands" bật. Test: `tests/unit/test_cowork_extra_tool_policy.py`.
|
||||
|
||||
### 🟠 Lỗi 2 (R06-T02, R07-T01) — Ghi file không atomic ở 3 nơi
|
||||
|
||||
`core/projects.py::save_project`, `core/history.py::save_conversation/rename_conversation/set_pinned` (R06), và `core/tasks.py::save_task` (R07) đều từng dùng `path.write_text(json.dumps(...))` trần — crash giữa lúc ghi để lại file JSON hỏng, và hàm `load_*` tương ứng coi file hỏng như "không tồn tại" → **mất project/hội thoại/task âm thầm, không báo lỗi**. Cả 4 điểm ghi giờ qua `infrastructure/persistence/json/atomic_write.py::write_json` (temp file + `os.replace`). Có test giả lập crash giữa lúc ghi xác nhận file cũ không bị hỏng, cho cả 2 đợt sửa.
|
||||
|
||||
### 🟡 Lỗi 3 (R06-T04) — Turn chạy ngầm lưu nhầm lịch sử vào project khác
|
||||
|
||||
`ui/chat_panel.py::_persist_session` gọi `save_conversation(self.ctx.config.history_dir(), ...)`, đọc `config._project_history_dir` — field dùng chung bị `ui/workspace_tab.py::_load_current` ghi đè mỗi lần đổi project. Một turn chạy ngầm ở project A hoàn tất SAU khi user đã chuyển sang project B thì bị lưu nhầm vào lịch sử của B.
|
||||
|
||||
*Sửa*: thêm `"home_history_dir"` vào dict `ctx` mỗi turn, chụp giá trị tại lúc submit thay vì đọc sống lúc lưu. Test Qt offscreen thật: `tests/integration/test_history_dir_race.py`.
|
||||
|
||||
### 🔵 Quyết định kiến trúc (R07-T03) — `platform/qt/` đè lên module chuẩn `platform` của Python
|
||||
|
||||
Plan gốc đặt tên `platform/qt/qt_scheduler_clock.py`. Thực nghiệm trước khi viết:
|
||||
|
||||
```bash
|
||||
cd <repo_root> && python -c "import cowork_local; import platform; print(platform.system())"
|
||||
```
|
||||
|
||||
Sau khi tạo `platform/__init__.py`, lệnh trên báo lỗi `AttributeError: module 'platform' has no attribute 'system'` — bất cứ khi nào repo root nằm trực tiếp trên `sys.path` (không qua `__main__.py`'s parent-dir fixup), `import platform` phân giải nhầm vào package cục bộ. `core/windows_sandbox_vm.py`/`core/appcontainer_sandbox.py` đều `import platform`.
|
||||
|
||||
*Sửa*: chuyển sang `infrastructure/qt/qt_scheduler_clock.py` (không tạo package `platform/` mới) — đúng layer, cùng cấp `infrastructure/{filesystem,mcp,persistence,providers,telemetry}/`. Verify lại: PASS.
|
||||
|
||||
### Nối dây `FileWorkspaceService` (R06-T05 → R08-T12)
|
||||
|
||||
Báo cáo R06 để lại nợ: `FileWorkspaceService` (R06-T05) chưa có call site thật. Xác nhận lại bằng grep trước R08-T12: đúng 0 occurrence trong `ui/folder_tab.py`. Khi tách `document_preview_manager.py` (R08-T12), mọi điểm ghi text thuần (`save`, `create_new_file`, `write_content`) chuyển sang gọi `FileWorkspaceService.write_file` — dùng `WorkspaceSession.unscoped(...)` vì Folder Explorer duyệt bất kỳ thư mục nào, không giới hạn 1 project sandbox. Nhánh ghi `.pptx` (binary) vẫn giữ nguyên đường cũ.
|
||||
|
||||
**Tác dụng phụ có lợi**: `infrastructure/filesystem/file_tools.py::write_file` đã có sẵn cảnh báo cú pháp Python và tự build `.xlsx` thật từ text — 2 hành vi này **trước đây không tồn tại** trên đường ghi cũ của `folder_tab.py`, giờ được hưởng miễn phí.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cải thiện phụ (không nằm trong yêu cầu task)
|
||||
|
||||
| Cải thiện | Ảnh hưởng |
|
||||
| :--- | :--- |
|
||||
| `McpServerConnection.is_alive()` (R05, `core/mcp_client.py`) | Cho `McpToolSourceManager` biết một connection cached đã chết để khởi động lại. |
|
||||
| `domain/tasks/schedule_calculator.py` có bộ test riêng (R07) | `core/tasks.py` tự nhận "Qt-free, unit-testable" nhưng **0 test tồn tại** cho cron/interval/holiday-exclusion trước R07-T02. Giờ 14 test. |
|
||||
| `AiEditModelResolver.routed_provider`/`.routed_model` (R08-T12, property public mới) | Cần thêm để giữ `tests/integration/test_routing_surfaces.py`'s 2 test AI-Edit routing sau khi lớp routing chuyển từ `FolderTab` sang `AiEditModelResolver` — tránh hồi quy 1 test có từ EPIC R03. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Còn nợ & cần quyết định
|
||||
|
||||
| # | Nội dung | Người quyết |
|
||||
| :--- | :--- | :--- |
|
||||
| 1 | **Xung đột quy hoạch thư mục `infrastructure/persistence/json/atomic_write.py`** (R06) với `atomic_json_file.py` do Team Nam quy hoạch ở R02-T01 — chưa có xung đột file thật, cần xác nhận hợp nhất hay giữ 2 module song song. | Team Nam |
|
||||
| 2 | **Xung đột quy hoạch thư mục `application/monitoring/`** (R08-T13, `dashboard_query_service.py`) — quy hoạch cho Team Nam ở R08-T07→T10, nhưng plan gốc lại đặt file Dashboard vào đúng thư mục này. Chưa có xung đột file thật (thư mục trống trước đó). | Team Nam |
|
||||
| 3 | **`WorkspaceRepository`/`ConversationRepository` (R06) vẫn chưa có call site sản xuất thật** — R08-T12 chỉ nối `FileWorkspaceService`, chưa đụng 2 repository kia. | Chưa có EPIC nào nhận |
|
||||
| 4 | **AI-Edit pipeline (`ai_edit_pipeline.py`) và Graph Q&A ask-flow (`graph_qa_widget.py::_ask`) chưa có test end-to-end thật** — cả 2 chạy trên `AgentWorker` (QThread) thật, và **vốn dĩ đã không có test nào trước khi refactor** (xác nhận bằng grep). | Có thể thuộc phạm vi R10 Testing Pyramid |
|
||||
| 5 | **Dev tooling chưa cập nhật đường dẫn cũ**: `tools/check_controls_alive.py`, `tools/capture_screens.py`, `tools/build_audit_page.py`, `docs/screens/*.json`, `docs/ui-audit*.html` vẫn tham chiếu `ui/schedule_task_tab.py`/`ui/folder_tab.py`/`ui/dashboard_tab.py`/`ui/structure_graph_view.py` (không còn tồn tại). Không nằm trong `tests/`, không ảnh hưởng CI. | Chưa quyết định người phụ trách |
|
||||
|
||||
---
|
||||
|
||||
## 8. Phạm vi chưa kiểm thử
|
||||
|
||||
* **R05-T04 (gate cho MCP/connector) chưa test với MCP server thật** — dùng `ToolSpec` giả, chưa thử `core/mcp_client.py::McpServerConnection` chạy subprocess thật.
|
||||
* **`McpToolSourceManager` (R05-T05) chưa test với subprocess MCP thật** — dùng `_FakeConnection`.
|
||||
* **`presentation/folder/ai_edit_pipeline.py`** (toàn bộ luồng plan → edit → apply/discard qua `AgentWorker` streaming) — chỉ verify bằng import/construction, chưa gửi instruction qua worker thật.
|
||||
* **`presentation/graph/graph_qa_widget.py::_ask`** — tương tự, chỉ test phần không cần AgentWorker thật.
|
||||
* **`office_document_renderer.py`'s PDF/LibreOffice conversion path** — chưa xác nhận kịch bản fallback trên máy không có QtPdf/LibreOffice bằng test thật.
|
||||
* **Đã mở app thật bằng `python -c "import cowork_local.app"` sau mỗi task** để xác nhận không lỗi import — **chưa** mở app GUI thật, thao tác tay qua toàn bộ 4 màn hình đã tách để xác nhận trải nghiệm người dùng cuối.
|
||||
|
||||
---
|
||||
|
||||
## 9. Việc kế tiếp của Team Hoa
|
||||
|
||||
Toàn bộ 4 EPIC thuộc phạm vi Team Hoa (R05, R06, R07, R08 phần T11→T14) đã **hoàn tất**. Các bước còn lại không thuộc EPIC riêng của Team Hoa nữa:
|
||||
|
||||
| Việc | Điều kiện |
|
||||
| :--- | :--- |
|
||||
| Checkpoint 2 (Services & Sub-widgets, 28/08) | Cần Team Duy (R08-T01→T06) và Team Nam (R08-T07→T10) xong phần UI split của họ |
|
||||
| CASAN Check 2 (Modularity/LOC, Team Hoa chủ trì, 30/08) | `scripts/check_loc.py` chưa tồn tại (thuộc R10-T02, Team Duy) |
|
||||
| Giải quyết mục 7 #1, #2 | Khi Team Nam bắt đầu R02-T01 và R08-T07→T10 |
|
||||
| R10 (Testing, Packaging & Contributor Experience) | Team Duy chủ trì, chờ 3 team hoàn tất |
|
||||
|
||||
---
|
||||
|
||||
## 10. Lịch sử commit
|
||||
|
||||
| Commit | Nội dung |
|
||||
| :--- | :--- |
|
||||
| `ae4fe72` | feat(R05): tool capability registry, unified policy gateway, MCP lifecycle manager |
|
||||
| `cf542b7` | feat(R06): workspace session snapshot, atomic persistence, history-dir race fix |
|
||||
| `69ab8e1` | feat(R07): task repository, schedule calculator, Qt clock adapter, task/AI-planner services |
|
||||
| `0e51356` | feat(R08): split ScheduleTaskTab, FolderTab, DashboardTab, StructureGraphView |
|
||||
| *(gộp báo cáo)* | docs(refactor): merge Team Hoa reports R05→R08 into one; rename branch to `feature/teamhoa/r05-r08` |
|
||||
@@ -74,7 +74,7 @@
|
||||
> * Mọi file mới **< 400 dòng** (lớn nhất: `domain/tools/tool_registry.py` 125 dòng). `core/tools.py` giảm từ 566 ➔ 291 dòng.
|
||||
>
|
||||
> ### 📄 BÁO CÁO CHI TIẾT
|
||||
> Xem `docs/refactor/BaoCao_TeamHoa_R05_R06.md` — kết quả từng EPIC, bằng chứng kiểm thử, 2 lỗi thật đã phát hiện (permission gate bị bỏ qua cho MCP tools, race condition lưu nhầm lịch sử), và phạm vi **chưa** kiểm thử.
|
||||
> Xem `docs/refactor/BaoCao_TeamHoa_R05_R08.md` (báo cáo gộp R05→R08) — kết quả từng EPIC, bằng chứng kiểm thử, 2 lỗi thật đã phát hiện (permission gate bị bỏ qua cho MCP tools, race condition lưu nhầm lịch sử), và phạm vi **chưa** kiểm thử.
|
||||
>
|
||||
> ### 🔧 TÓM TẮT R06
|
||||
> * **R06-T01**: `domain/workspaces/workspace_session.py::WorkspaceSession` — snapshot bất biến (project_id, workspace_root, sandbox_dir, allowed_paths) + `is_allowed(path)`.
|
||||
@@ -97,6 +97,34 @@
|
||||
|
||||
---
|
||||
|
||||
## 📊 TIẾN ĐỘ THỰC TẾ — TEAM HOA, R07 + R08 (cập nhật `2026-08-27 20:52`)
|
||||
|
||||
> [!NOTE]
|
||||
> ### ✅ ĐÃ HOÀN TẤT: 9/9 task phạm vi Team Hoa của **R07 + R08** — cùng branch `feature/teamhoa/r05-r06`
|
||||
>
|
||||
> | EPIC | Task (phạm vi Team Hoa) | Trạng thái |
|
||||
> | :--- | :--- | :--- |
|
||||
> | **R07** Scheduling & Workflow Runtime | T01 → T05 | ✅ 5/5 (T06 Co4EWorkflowService là Team Nam) |
|
||||
> | **R08** UI/Application Separation | T11 → T14 | ✅ 4/4 (T01-T10 là Team Duy/Team Nam) |
|
||||
>
|
||||
> **Kiểm chứng (chạy thật):**
|
||||
> * `pytest tests/` ➔ **377 pass / 4 fail** (+94 test mới cho R07+R08 — 328 sau R07, 377 sau R08)
|
||||
> * 4 fail là **lỗi có sẵn từ trước**, giống hệt baseline đã ghi nhận ở R05/R06 (2× `test_config_security.py` EPIC R02, 2× `test_routing_wiring.py` môi trường máy)
|
||||
> * `python scripts/check_imports.py` ➔ **PASS** (0 Qt import trong `domain/`, `application/`)
|
||||
> * Mọi file mới **< 400 dòng** (lớn nhất: `presentation/graph/graph_renderer.py` 391 dòng)
|
||||
> * `python -c "import cowork_local.app"` ➔ OK sau mỗi task (app khởi động được với toàn bộ import mới)
|
||||
>
|
||||
> ### 📄 BÁO CÁO CHI TIẾT
|
||||
> Xem `docs/refactor/BaoCao_TeamHoa_R05_R08.md` (báo cáo gộp R05→R08) — kết quả từng task, 1 quyết định kiến trúc đổi so với plan gốc (đã thực nghiệm xác nhận), việc "nối dây" `FileWorkspaceService` (nợ từ R06-T05), và phạm vi **chưa** kiểm thử.
|
||||
>
|
||||
> ### 📌 CÒN NỢ / CẦN QUYẾT ĐỊNH
|
||||
> 1. **`application/monitoring/` mới tạo ở R08-T13** (`dashboard_query_service.py`) nhưng thư mục này được quy hoạch cho Team Nam (R08-T07→T10). Chưa có xung đột file thật (thư mục trống trước đó) nhưng **cần Team Nam xác nhận** khi bắt đầu phần Monitoring của họ — xem chi tiết trong báo cáo.
|
||||
> 2. **R07-T03 đổi vị trí so với plan gốc**: `platform/qt/qt_scheduler_clock.py` ➔ `infrastructure/qt/qt_scheduler_clock.py`, sau khi xác nhận bằng thực nghiệm rằng một package `platform/` ở top-level đè lên module chuẩn `platform` của Python.
|
||||
> 3. **AI-Edit pipeline (`presentation/folder/ai_edit_pipeline.py`) và Q&A ask-flow (`presentation/graph/graph_qa_widget.py::_ask`) chưa có test end-to-end** — cả hai chạy trên `AgentWorker` (QThread) thật và **vốn đã không có test nào từ trước khi refactor** (xác nhận bằng grep). Phạm vi test của R08-T12/T14 tập trung vào phần có thể test không cần thread thật (wiring, containment, rendering) — xem mục "Phạm vi chưa kiểm thử" trong báo cáo.
|
||||
> 4. Chưa `git push` — nhánh cục bộ vẫn chưa lên được Gitea, giống tình trạng đã ghi nhận ở báo cáo R05/R06 mục 7-#1.
|
||||
|
||||
---
|
||||
|
||||
## 📌 PHẦN 1: CHECKLIST CHI TIẾT THEO 10 EPIC (R01 ➔ R10)
|
||||
|
||||
### 🔹 EPIC R01: Architecture Foundation & Characterization (Nền Tảng Kiến Trúc & Test Bảo Vệ)
|
||||
@@ -209,18 +237,18 @@
|
||||
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Task Scheduling) + 🟣 **Team Nam** (Co4E Workflows)
|
||||
* **Mục tiêu**: Tách `TaskRepository` và `ScheduleCalculator` khỏi `QTimer` trong `core/task_scheduler.py#L20`; xây dựng `TaskApplicationService` và `Co4EWorkflowService`.
|
||||
|
||||
- [ ] **R07-T01 (Team Hoa)**: Tách `TaskRepository` lưu trữ JSON độc lập khỏi `core/tasks.py` ➔ `infrastructure/persistence/json/task_repository_impl.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R07-T02 (Team Hoa)**: Xây dựng `ScheduleCalculator` tính due-time / cron độc lập ➔ `domain/tasks/schedule_calculator.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R07-T03 (Team Hoa)**: Xây dựng `QtSchedulerClock` adapter (tách `TaskScheduler` khỏi `QTimer`) ➔ `platform/qt/qt_scheduler_clock.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R07-T04 (Team Hoa)**: Xây dựng `TaskApplicationService` (Pure Python) điều phối chạy, sao chép, dừng, xóa task ➔ `application/scheduling/task_application_service.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R07-T05 (Team Hoa)**: Xây dựng `AiTaskPlannerService` hỗ trợ tạo / import task bằng AI ➔ `application/scheduling/ai_task_planner_service.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [x] **R07-T01 (Team Hoa)**: Tách `TaskRepository` lưu trữ JSON độc lập khỏi `core/tasks.py` ➔ `infrastructure/persistence/json/task_repository_impl.py`
|
||||
*Start: `2026-08-27 16:05` | End: `2026-08-27 16:14`*
|
||||
- [x] **R07-T02 (Team Hoa)**: Xây dựng `ScheduleCalculator` tính due-time / cron độc lập ➔ `domain/tasks/schedule_calculator.py`
|
||||
*Start: `2026-08-27 16:14` | End: `2026-08-27 16:26`*
|
||||
- [x] **R07-T03 (Team Hoa)**: Xây dựng `QtSchedulerClock` adapter (tách `TaskScheduler` khỏi `QTimer`) ➔ `infrastructure/qt/qt_scheduler_clock.py` (đổi so với plan gốc `platform/qt/...` — xem báo cáo)
|
||||
*Start: `2026-08-27 16:26` | End: `2026-08-27 16:47`*
|
||||
- [x] **R07-T04 (Team Hoa)**: Xây dựng `TaskApplicationService` (Pure Python) điều phối chạy, sao chép, dừng, xóa task ➔ `application/scheduling/task_application_service.py`
|
||||
*Start: `2026-08-27 16:47` | End: `2026-08-27 17:02`*
|
||||
- [x] **R07-T05 (Team Hoa)**: Xây dựng `AiTaskPlannerService` hỗ trợ tạo / import task bằng AI ➔ `application/scheduling/ai_task_planner_service.py`
|
||||
*Start: `2026-08-27 17:02` | End: `2026-08-27 17:14`*
|
||||
- [ ] **R07-T06 (Team Nam)**: Xây dựng `Co4EWorkflowService` (Pure Python) quản lý định nghĩa và thực thi Co4E từ `core/co4e_run_manager.py` ➔ `application/workflows/co4e_workflow_service.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* (ngoài phạm vi Team Hoa)
|
||||
|
||||
---
|
||||
|
||||
@@ -253,14 +281,14 @@
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
|
||||
#### 🟢 Team Hoa (Workspace, Folder, Scheduling, Dashboard & Graph):
|
||||
- [ ] **R08-T11**: Tách `ui/schedule_task_tab.py` ➔ `kanban_board_widget.py`, `calendar_view_widget.py`, `ai_task_creator_dialog.py`, `ai_task_import_dialog.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R08-T12**: Tách `ui/folder_tab.py#L350` ➔ `workspace_file_tree.py`, `document_preview_manager.py`, `ai_file_editor_dialog.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R08-T13**: Tách `ui/dashboard_tab.py` ➔ `token_usage_card_widget.py`, `usage_chart_widget.py`, `habits_widget.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [ ] **R08-T14**: Tách `ui/structure_graph_view.py` ➔ `presentation/graph/structure_graph_view.py` & `graph_qa_widget.py`
|
||||
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
|
||||
- [x] **R08-T11**: Tách `ui/schedule_task_tab.py` ➔ `kanban_board_widget.py`, `calendar_view_widget.py`, `ai_task_creator_dialog.py`, `ai_task_import_dialog.py` (+ `run_history_dialog.py`, `schedule_task_tab.py` shell — xem báo cáo)
|
||||
*Start: `2026-08-27 17:14` | End: `2026-08-27 17:39`*
|
||||
- [x] **R08-T12**: Tách `ui/folder_tab.py#L350` ➔ `workspace_file_tree.py`, `document_preview_manager.py`, `ai_file_editor_dialog.py` (+ `code_editor.py`, `office_document_renderer.py`, `ai_edit_model_resolver.py`, `ai_edit_pipeline.py`, `folder_tab.py` shell — xem báo cáo). Đã nối `FileWorkspaceService` (nợ từ R06-T05).
|
||||
*Start: `2026-08-27 17:39` | End: `2026-08-27 18:09`*
|
||||
- [x] **R08-T13**: Tách `ui/dashboard_tab.py` ➔ `token_usage_card_widget.py`, `usage_chart_widget.py`, `habits_widget.py` (+ `dashboard_tab.py` shell, `application/monitoring/dashboard_query_service.py` — xem báo cáo về ghi chú xung đột thư mục với Team Nam)
|
||||
*Start: `2026-08-27 18:09` | End: `2026-08-27 18:16`*
|
||||
- [x] **R08-T14**: Tách `ui/structure_graph_view.py` ➔ `presentation/graph/structure_graph_view.py` (shell) & `graph_qa_widget.py` (+ `graph_renderer.py`, `graph_scene_items.py`, `graph_messages_view.py`, `application/workspaces/graph_index_service.py` — xem báo cáo)
|
||||
*Start: `2026-08-27 18:16` | End: `2026-08-27 20:52`*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Domain entities for schedule/due-time computation (EPIC R07)."""
|
||||
|
||||
from .schedule_calculator import ScheduleCalculator
|
||||
|
||||
__all__ = ["ScheduleCalculator"]
|
||||
@@ -0,0 +1,165 @@
|
||||
"""ScheduleCalculator - due-time / cron / interval math for Schedule Task,
|
||||
extracted from ``core/tasks.py``'s "schedule math" section (R07-T02).
|
||||
|
||||
``core/tasks.py`` is already Qt-free (its own docstring says so), but it
|
||||
still lives under ``core/`` where nothing enforces that "pure" claim - and it
|
||||
is the ONE piece of scheduling logic ``docs/refactor/plan.md`` calls out as
|
||||
needing its own unit tests (none existed before this task; see
|
||||
``tests/unit/test_schedule_calculator.py``). Moving it to ``domain/tasks/``
|
||||
makes the purity a build-time guarantee (``scripts/check_imports.py`` fails
|
||||
the build if this file ever imports Qt, ``core``, or anything with I/O) and
|
||||
gives the date math a home that is trivially unit-testable without going
|
||||
through ``core/tasks.py``'s file-repository concerns at all.
|
||||
|
||||
Two pieces of this math are themselves implemented elsewhere in ``core/`` -
|
||||
``core/cron.py::Cron`` (5-field cron parsing) and
|
||||
``core/holiday_calendar.py::is_holiday`` (VN public holidays). Importing
|
||||
``core`` from ``domain`` is exactly what ADR-001 rule I2 forbids (domain must
|
||||
not know infrastructure/core exists), so this class takes them as
|
||||
constructor-injected callables instead of importing them - the same
|
||||
dependency-inversion shape ``application/conversations/conversation_
|
||||
application_service.py`` (R04-T03) already uses for its provider factory.
|
||||
``core/tasks.py`` wires the real ``Cron``/``is_holiday`` in; tests can inject
|
||||
plain stub functions with zero I/O.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable, Dict, List, Optional, Protocol
|
||||
|
||||
# Same on-disk format core/tasks.py::_TIME_FMT uses for schedule.run_at.
|
||||
# Duplicated here (not imported - that would be a domain -> core edge) since
|
||||
# it's a 1-line format string, not business logic.
|
||||
_TIME_FMT = "%Y-%m-%d %H:%M"
|
||||
|
||||
_CRON_SEARCH_GUARD = 400 # matches the guard core/tasks.py used before extraction
|
||||
|
||||
|
||||
class _CronLike(Protocol):
|
||||
"""Structural shape this class needs from a cron object - satisfied by
|
||||
``core/cron.py::Cron`` without this module importing it."""
|
||||
|
||||
def next_after(self, after: datetime) -> Optional[datetime]:
|
||||
...
|
||||
|
||||
|
||||
def _parse_run_at(value: Optional[str]) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(value, _TIME_FMT)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class ScheduleCalculator:
|
||||
"""Pure due-time computation for one task's ``schedule`` dict.
|
||||
|
||||
``is_holiday``: ``Callable[[date, country_code], bool]`` or ``None`` -
|
||||
when ``None``, a schedule with ``skip_holidays`` set simply never treats
|
||||
any day as a holiday (degrades gracefully instead of raising, mirroring
|
||||
how a caller who doesn't care about holidays can just not wire it up).
|
||||
|
||||
``make_cron``: ``Callable[[str], _CronLike]`` (raises on a malformed
|
||||
expression) or ``None`` - when ``None``, ``repeat_type == "cron"``
|
||||
schedules never produce a next run (same as an invalid expression today).
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
is_holiday: Optional[Callable[[Any, str], bool]] = None,
|
||||
make_cron: Optional[Callable[[str], _CronLike]] = None) -> None:
|
||||
self._is_holiday = is_holiday
|
||||
self._make_cron = make_cron
|
||||
|
||||
def is_excluded_day(self, dt: datetime, sched: Dict[str, Any]) -> bool:
|
||||
"""True when ``dt`` falls on a day this schedule must skip: a
|
||||
weekend (working_days_only) or a public holiday of the configured
|
||||
country."""
|
||||
if sched.get("working_days_only") and dt.weekday() >= 5: # 5=Sat, 6=Sun
|
||||
return True
|
||||
if sched.get("skip_holidays") and self._is_holiday is not None:
|
||||
if self._is_holiday(dt.date(), sched.get("holiday_country", "")):
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_month(self, dt: datetime) -> datetime:
|
||||
"""Calendar-aware +1 month, clamping the day to the target month's
|
||||
length (e.g. Jan 31 + 1 month -> Feb 28/29, not an overflow error)."""
|
||||
year = dt.year + (1 if dt.month == 12 else 0)
|
||||
month = 1 if dt.month == 12 else dt.month + 1
|
||||
day = min(dt.day, calendar.monthrange(year, month)[1])
|
||||
return dt.replace(year=year, month=month, day=day)
|
||||
|
||||
def shift_off_excluded_days(self, dt: datetime, sched: Dict[str, Any]) -> datetime:
|
||||
"""Push ``dt`` forward one day at a time until it lands on an
|
||||
allowed day (same time of day) - used for one-time schedules set on
|
||||
a weekend/holiday."""
|
||||
guard = 0
|
||||
while self.is_excluded_day(dt, sched) and guard < _CRON_SEARCH_GUARD:
|
||||
dt += timedelta(days=1)
|
||||
guard += 1
|
||||
return dt
|
||||
|
||||
def compute_next_run(self, task: Dict[str, Any], after: datetime) -> Optional[datetime]:
|
||||
"""The next run time strictly after ``after`` for a repeating task
|
||||
(daily / weekly / monthly / cron), or ``None`` for one-shot
|
||||
schedules. Occurrences on excluded days are skipped forward."""
|
||||
sched = task.get("schedule", {})
|
||||
repeat = sched.get("repeat_type", "none")
|
||||
|
||||
if repeat == "cron":
|
||||
if self._make_cron is None:
|
||||
return None
|
||||
try:
|
||||
cron = self._make_cron(sched.get("cron_expression") or "")
|
||||
except Exception:
|
||||
# Any malformed-expression error the injected factory raises
|
||||
# (core/cron.py::CronError, or a fake's own error type in
|
||||
# tests) means "this schedule can't compute a next run" - not
|
||||
# a domain-layer crash.
|
||||
return None
|
||||
nxt = cron.next_after(after)
|
||||
guard = 0
|
||||
while nxt is not None and self.is_excluded_day(nxt, sched) and guard < _CRON_SEARCH_GUARD:
|
||||
nxt = cron.next_after(nxt)
|
||||
guard += 1
|
||||
return nxt
|
||||
|
||||
base = _parse_run_at(sched.get("run_at"))
|
||||
if base is None:
|
||||
return None
|
||||
if repeat == "daily":
|
||||
advance = lambda d: d + timedelta(days=1) # noqa: E731
|
||||
elif repeat == "weekly":
|
||||
advance = lambda d: d + timedelta(weeks=1) # noqa: E731
|
||||
elif repeat == "monthly":
|
||||
advance = self.add_month
|
||||
else:
|
||||
return None
|
||||
nxt = base
|
||||
while nxt <= after:
|
||||
nxt = advance(nxt)
|
||||
guard = 0
|
||||
while self.is_excluded_day(nxt, sched) and guard < _CRON_SEARCH_GUARD:
|
||||
nxt = advance(nxt)
|
||||
guard += 1
|
||||
return nxt
|
||||
|
||||
def due_tasks(self, tasks: List[Dict[str, Any]], now: datetime) -> List[Dict[str, Any]]:
|
||||
"""Tasks that should start now: Scheduled + schedule enabled +
|
||||
run_at due."""
|
||||
due = []
|
||||
for t in tasks:
|
||||
if t.get("status") != "scheduled":
|
||||
continue
|
||||
sched = t.get("schedule", {})
|
||||
if not sched.get("enabled"):
|
||||
continue
|
||||
run_at = _parse_run_at(sched.get("run_at"))
|
||||
if run_at is not None and run_at <= now:
|
||||
due.append(t)
|
||||
return due
|
||||
|
||||
|
||||
__all__ = ["ScheduleCalculator"]
|
||||
@@ -1,8 +1,9 @@
|
||||
"""JSON-file persistence adapters: crash-safe writes and the workspace/
|
||||
conversation repositories built on them (EPIC R06)."""
|
||||
conversation/task repositories built on them (EPIC R06, R07)."""
|
||||
|
||||
from .atomic_write import write_json
|
||||
from .conversation_repository_impl import ConversationRepository
|
||||
from .task_repository_impl import TaskRepository
|
||||
from .workspace_repository_impl import WorkspaceRepository
|
||||
|
||||
__all__ = ["write_json", "WorkspaceRepository", "ConversationRepository"]
|
||||
__all__ = ["write_json", "WorkspaceRepository", "ConversationRepository", "TaskRepository"]
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""TaskRepository - an object-shaped, atomic-write-backed facade over
|
||||
``core/tasks.py`` (R07-T01).
|
||||
|
||||
``core/tasks.py``'s module-level functions (``list_tasks``, ``load_task``,
|
||||
``save_task``, ``delete_task``, ``new_task``, ``duplicate_task``) are still
|
||||
what every existing call site (``core/task_scheduler.py``,
|
||||
``core/task_executors.py``, ``ui/schedule_task_tab.py``) uses, and stay that
|
||||
way - ``save_task`` now writes through :func:`atomic_write.write_json`
|
||||
itself (R07-T01, same class of durability fix already applied to
|
||||
``core/projects.py``/``core/history.py`` at R06-T02), so the fix applies
|
||||
whether or not a caller ever touches this class.
|
||||
|
||||
This repository exists for the application layer
|
||||
(``application/scheduling``, R07-T04) to depend on an interface instead of
|
||||
reaching into ``core/`` directly. It is a thin pass-through today, not a
|
||||
re-implementation: same on-disk format, same directory, same functions
|
||||
underneath.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from cowork_local.core.tasks import (
|
||||
TASKS_DIR,
|
||||
delete_task,
|
||||
duplicate_task,
|
||||
list_tasks,
|
||||
load_task,
|
||||
new_task,
|
||||
save_task,
|
||||
)
|
||||
|
||||
|
||||
class TaskRepository:
|
||||
"""CRUD over task dicts (see ``core/tasks.py::DEFAULT_TASK`` for shape),
|
||||
scoped to one ``directory`` (defaults to the app's real ``TASKS_DIR``;
|
||||
tests pass a ``tmp_path`` so nothing touches the user's real config
|
||||
folder)."""
|
||||
|
||||
def __init__(self, directory: Optional[Path] = None) -> None:
|
||||
self._directory = directory or TASKS_DIR
|
||||
|
||||
def list(self) -> List[Dict[str, Any]]:
|
||||
return list_tasks(self._directory)
|
||||
|
||||
def get(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
return load_task(task_id, self._directory)
|
||||
|
||||
def save(self, task: Dict[str, Any]) -> Path:
|
||||
return save_task(task, self._directory)
|
||||
|
||||
def create(self, title: str = "", **overrides: Any) -> Dict[str, Any]:
|
||||
return new_task(title, **overrides)
|
||||
|
||||
def duplicate(self, task: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return duplicate_task(task)
|
||||
|
||||
def delete(self, task_id: str) -> None:
|
||||
delete_task(task_id, self._directory)
|
||||
|
||||
|
||||
__all__ = ["TaskRepository"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Qt-backed adapters for pure interfaces used elsewhere in the app (EPIC R07).
|
||||
|
||||
Note: the original plan (``docs/refactor/Feature_Architecture_Proposal.md``)
|
||||
placed this adapter at a new top-level ``platform/qt/`` package. That name
|
||||
was dropped after it was shown to actually shadow the stdlib ``platform``
|
||||
module (used by ``core/windows_sandbox_vm.py``/``core/appcontainer_sandbox.
|
||||
py``) whenever the repo root ends up on ``sys.path`` directly - e.g. running
|
||||
``python -c "..."`` (or any script) with the repo root as the working
|
||||
directory, which resolves a bare ``import platform`` to this package instead
|
||||
of the standard library one. ``infrastructure/`` already exists as a layer
|
||||
for exactly this kind of toolkit-specific implementation
|
||||
(``infrastructure/filesystem/``, ``infrastructure/mcp/``, ...), so the
|
||||
adapter lives here instead - same content, safer location.
|
||||
"""
|
||||
|
||||
from .qt_scheduler_clock import QtSchedulerClock
|
||||
|
||||
__all__ = ["QtSchedulerClock"]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""QtSchedulerClock - the ``QTimer``-backed periodic ticker `TaskScheduler`
|
||||
needs, pulled out from ``core/task_scheduler.py`` into its own adapter
|
||||
(R07-T03).
|
||||
|
||||
``core/task_scheduler.py::TaskScheduler`` is the only file in the scheduling
|
||||
stack that imports Qt at all (confirmed by grep — ``core/tasks.py`` and
|
||||
``core/task_executors.py`` are Qt-free). Everything it needs Qt FOR is small
|
||||
and mechanical: an interval timer that calls back into ``tick()`` every
|
||||
``TICK_MS``, plus, during ``stop()``, a way to pump the event loop so a
|
||||
worker thread's queued ``finished_ok``/``failed`` signal still gets delivered
|
||||
while draining running tasks (see the long comment on ``TaskScheduler.stop()``
|
||||
for why that pump matters).
|
||||
|
||||
Wrapping exactly that surface — ``start(interval_ms, callback)``, ``stop()``,
|
||||
``pump()`` — behind :class:`QtSchedulerClock` lets ``TaskScheduler`` take a
|
||||
clock as a constructor parameter instead of constructing a ``QTimer``
|
||||
itself. Production wiring is unchanged (``TaskScheduler`` defaults to a real
|
||||
``QtSchedulerClock`` when no clock is passed); tests can inject
|
||||
``tests/fakes/fake_clock.py::FakeClock`` to control ticks by hand with no Qt
|
||||
event loop running at all.
|
||||
|
||||
See ``infrastructure/qt/__init__.py`` for why this lives under
|
||||
``infrastructure/qt/`` and not the ``platform/qt/`` path the original plan
|
||||
named.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QObject, QTimer
|
||||
|
||||
|
||||
class QtSchedulerClock:
|
||||
"""Owns one ``QTimer``. Not itself a ``QObject`` subclass — it OWNS a
|
||||
``QObject``-parented timer instead of inheriting from one, so callers
|
||||
(like ``FakeClock`` in tests) can satisfy the same duck-typed interface
|
||||
without any Qt base class at all."""
|
||||
|
||||
def __init__(self, parent: Optional[QObject] = None) -> None:
|
||||
# Parented so the timer is torn down with its owner instead of
|
||||
# outliving it — the same lifetime QTimer(self) gave it inside
|
||||
# TaskScheduler before this extraction.
|
||||
self._timer = QTimer(parent)
|
||||
self._timer.timeout.connect(self._on_timeout)
|
||||
self._callback: Optional[Callable[[], None]] = None
|
||||
|
||||
def _on_timeout(self) -> None:
|
||||
if self._callback is not None:
|
||||
self._callback()
|
||||
|
||||
def start(self, interval_ms: int, callback: Callable[[], None]) -> None:
|
||||
"""Arm and start the timer. Calling this again while already
|
||||
running re-arms it with the new interval/callback (matches
|
||||
``QTimer.start()``'s own restart-on-repeat-call behaviour)."""
|
||||
self._callback = callback
|
||||
self._timer.setInterval(interval_ms)
|
||||
self._timer.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._timer.stop()
|
||||
|
||||
def pump(self) -> None:
|
||||
"""Process one batch of pending Qt events — used by
|
||||
``TaskScheduler.stop()``'s bounded drain loop so a worker thread's
|
||||
queued completion signal can still be delivered while we wait for it
|
||||
to exit."""
|
||||
QCoreApplication.processEvents()
|
||||
|
||||
|
||||
__all__ = ["QtSchedulerClock"]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Presentation layer: Qt widgets, one screen/concern per file, assembled
|
||||
into thin shell containers (EPIC R08).
|
||||
|
||||
Nothing under here is imported by ``domain/`` or ``application/``
|
||||
(``scripts/check_imports.py`` rule I3) — data flows the other way, through
|
||||
application services these widgets call.
|
||||
"""
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Dashboard screen, split into single-responsibility widgets (R08-T13):
|
||||
``token_usage_card_widget``, ``usage_chart_widget``, ``habits_widget``,
|
||||
assembled by the ``dashboard_tab`` shell."""
|
||||
@@ -0,0 +1,91 @@
|
||||
"""DashboardTab shell (R08-T13) — assembles
|
||||
``token_usage_card_widget.py::TokenUsageCardWidget``,
|
||||
``usage_chart_widget.py::UsageChartWidget`` and
|
||||
``habits_widget.py::HabitsWidget`` behind the scroll area / header / 30s
|
||||
auto-refresh timer that used to be inline in
|
||||
``ui/dashboard_tab.py::DashboardTab.__init__`` (lines 40-193 of the original
|
||||
437-line file).
|
||||
|
||||
The one ``DashboardQueryService`` (R08-T13) instance is built here and
|
||||
shared by all three children so pricing/currency stay consistent across the
|
||||
whole screen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QTimer, Signal
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QScrollArea, QVBoxLayout, QWidget
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.dashboard.habits_widget import HabitsWidget
|
||||
from cowork_local.presentation.dashboard.token_usage_card_widget import TokenUsageCardWidget
|
||||
from cowork_local.presentation.dashboard.usage_chart_widget import UsageChartWidget
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class DashboardTab(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._query = DashboardQueryService(ctx)
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
content = QWidget()
|
||||
scroll.setWidget(content)
|
||||
outer.addWidget(scroll)
|
||||
root = QVBoxLayout(content)
|
||||
|
||||
head = QHBoxLayout()
|
||||
self._title = QLabel()
|
||||
self._title.setStyleSheet("font-weight:700; font-size:15px;")
|
||||
self.refresh_btn = QPushButton("")
|
||||
self.refresh_btn.setIcon(icon("refresh"))
|
||||
self.refresh_btn.setFixedWidth(34)
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
head.addWidget(self._title, 1)
|
||||
head.addWidget(self.refresh_btn)
|
||||
root.addLayout(head)
|
||||
|
||||
self.token_cards = TokenUsageCardWidget(ctx, self._query)
|
||||
root.addWidget(self.token_cards)
|
||||
|
||||
self.chart = UsageChartWidget(ctx, self._query)
|
||||
self.chart.period_changed.connect(self.refresh)
|
||||
self.chart.currency_changed.connect(self.refresh)
|
||||
root.addWidget(self.chart)
|
||||
|
||||
self.habits = HabitsWidget(ctx, self._query)
|
||||
self.habits.status_message.connect(self.status_message.emit)
|
||||
root.addWidget(self.habits, 1)
|
||||
|
||||
# Auto-refresh every 30s so numbers follow ongoing work.
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(30_000)
|
||||
self._timer.timeout.connect(self.refresh)
|
||||
self._timer.start()
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
self.refresh()
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self._title.setText(tr("dashboard.title"))
|
||||
self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip"))
|
||||
self.token_cards.retranslate()
|
||||
self.chart.retranslate()
|
||||
self.habits.retranslate()
|
||||
self.refresh()
|
||||
|
||||
def refresh(self, *_a) -> None:
|
||||
start, end = self.chart.period_range()
|
||||
self.token_cards.refresh(start, end)
|
||||
self.chart.refresh()
|
||||
self.habits.refresh(start, end)
|
||||
|
||||
|
||||
__all__ = ["DashboardTab"]
|
||||
@@ -0,0 +1,171 @@
|
||||
"""HabitsWidget — the usage-habits summary + AI recommendations panel of the
|
||||
Dashboard (R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``,
|
||||
lines 154-184/348-372/376-438 of the original 437-line file: the habits/AI
|
||||
layout, ``refresh()``'s habits-HTML section, ``_apply_saving_strategy``,
|
||||
``_ai_analyze``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextBrowser, QVBoxLayout, QWidget
|
||||
from PySide6.QtCore import Signal
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.widgets import fmt_tokens
|
||||
|
||||
|
||||
class HabitsWidget(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx, query: DashboardQueryService, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._query = query
|
||||
self._ai_worker: Optional[AgentWorker] = None
|
||||
self._period_range = (None, None) # set on each refresh(); _ai_analyze reuses it
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
self._habits_title = QLabel()
|
||||
self._habits_title.setStyleSheet("font-weight:600;")
|
||||
habits_head = QHBoxLayout()
|
||||
self.ai_analyze_btn = QPushButton()
|
||||
self.ai_analyze_btn.setIcon(icon("sparkle"))
|
||||
self.ai_analyze_btn.clicked.connect(self._ai_analyze)
|
||||
# Apply an AI-suggested cost-saving strategy — only after the user
|
||||
# clicks to approve it.
|
||||
self.apply_strategy_btn = QPushButton()
|
||||
self.apply_strategy_btn.setIcon(icon("bolt"))
|
||||
self.apply_strategy_btn.setVisible(False)
|
||||
self.apply_strategy_btn.clicked.connect(self._apply_saving_strategy)
|
||||
habits_head.addWidget(self._habits_title, 1)
|
||||
habits_head.addWidget(self.apply_strategy_btn)
|
||||
habits_head.addWidget(self.ai_analyze_btn)
|
||||
root.addLayout(habits_head)
|
||||
self.habits = QTextBrowser()
|
||||
self.habits.setOpenExternalLinks(False)
|
||||
self.habits.setMinimumHeight(160)
|
||||
root.addWidget(self.habits, 1)
|
||||
self._ai_title = QLabel()
|
||||
self._ai_title.setStyleSheet("font-weight:600;")
|
||||
self._ai_title.setVisible(False)
|
||||
root.addWidget(self._ai_title)
|
||||
self.ai_advice = QTextBrowser()
|
||||
self.ai_advice.setOpenExternalLinks(False)
|
||||
self.ai_advice.setMinimumHeight(140)
|
||||
self.ai_advice.setVisible(False)
|
||||
root.addWidget(self.ai_advice, 1)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
self.ai_analyze_btn.setToolTip(tr("dashboard.ai_analyze_tooltip"))
|
||||
self.apply_strategy_btn.setText(tr("dashboard.strategy_btn"))
|
||||
self.apply_strategy_btn.setToolTip(tr("dashboard.strategy_tooltip"))
|
||||
self._habits_title.setText(tr("dashboard.habits_title"))
|
||||
|
||||
def refresh(self, start: date, end: date) -> None:
|
||||
self._period_range = (start, end)
|
||||
summary = self._query.summary(start, end)
|
||||
s, events = summary["stats"], summary["events"]
|
||||
|
||||
lines: List[str] = []
|
||||
if not events:
|
||||
lines.append(f"<i>{tr('dashboard.no_data')}</i>")
|
||||
else:
|
||||
lines.append(f"<b>{tr('dashboard.h_top')}</b>")
|
||||
lines.append("<ol>")
|
||||
for label, tok in s["top_labels"]:
|
||||
pct = int(tok * 100 / s["total"]) if s["total"] else 0
|
||||
lines.append(f"<li>{label[:60]} — {fmt_tokens(tok)} tokens ({pct}%)</li>")
|
||||
lines.append("</ol>")
|
||||
src_parts = ", ".join(
|
||||
f"{tr(f'app.tab.{k}') if k in ('cowork', 'code') else k}: {fmt_tokens(v)}"
|
||||
for k, v in s["by_source"])
|
||||
lines.append(f"<b>{tr('dashboard.h_by_source')}</b>: {src_parts}<br>")
|
||||
lines.append(f"<b>{tr('dashboard.h_avg')}</b>: "
|
||||
f"{fmt_tokens(s['avg_per_turn'])} tokens<br>")
|
||||
if s["busiest_day"]:
|
||||
lines.append(f"<b>{tr('dashboard.h_busiest_day')}</b>: {s['busiest_day']}<br>")
|
||||
if s["busiest_hour"] is not None:
|
||||
lines.append(f"<b>{tr('dashboard.h_busiest_hour')}</b>: "
|
||||
f"{s['busiest_hour']:02d}:00–{s['busiest_hour']:02d}:59<br>")
|
||||
if s["estimated_share"] > 0:
|
||||
lines.append(f"<i>{tr('dashboard.estimated_note', pct=int(s['estimated_share'] * 100))}</i>")
|
||||
self.habits.setHtml("".join(lines))
|
||||
|
||||
def _apply_saving_strategy(self) -> None:
|
||||
"""Apply an AI-suggested cost-saving strategy AFTER the user
|
||||
approves: turn on auto-compress and compress earlier (lower
|
||||
threshold) + compress content before sending it to the agent."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
if QMessageBox.question(self, tr("dashboard.strategy_title"),
|
||||
tr("dashboard.strategy_confirm")) != QMessageBox.Yes:
|
||||
return
|
||||
cx = self.ctx.config.data.setdefault("context", {})
|
||||
cx["auto_compact"] = True
|
||||
cx["compact_threshold"] = 0.6 # compress at 60% of the window (was ~80%)
|
||||
cx["compress_before_send"] = True # digest context before each turn
|
||||
self.ctx.save()
|
||||
self.status_message.emit(tr("dashboard.strategy_applied"))
|
||||
|
||||
def _ai_analyze(self) -> None:
|
||||
"""✨ Send the aggregated numbers (never raw prompt text) to the
|
||||
active provider and show habit feedback + token-saving
|
||||
recommendations."""
|
||||
if self._ai_worker is not None:
|
||||
return
|
||||
start, end = self._period_range
|
||||
if start is None:
|
||||
return
|
||||
summary = self._query.summary(start, end)
|
||||
if not summary["events"]:
|
||||
self.status_message.emit(tr("dashboard.no_data"))
|
||||
return
|
||||
stats = summary["stats"]
|
||||
self.ai_analyze_btn.setEnabled(False)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyzing"))
|
||||
ctx = self.ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
from cowork_local.i18n import get_language
|
||||
|
||||
prompt = ut.build_ai_analysis_prompt(stats, get_language())
|
||||
provider = ctx.build_active_provider()
|
||||
reply = provider.chat([{"role": "user", "content": prompt}],
|
||||
cancel=worker.stop_event)
|
||||
return {"text": (reply.get("content") or "").strip()}
|
||||
|
||||
def done(result: dict) -> None:
|
||||
self._ai_worker = None
|
||||
self.ai_analyze_btn.setEnabled(True)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
text = result.get("text") or ""
|
||||
if text:
|
||||
self._ai_title.setText(tr("dashboard.ai_advice_title"))
|
||||
self._ai_title.setVisible(True)
|
||||
self.ai_advice.setMarkdown(text)
|
||||
self.ai_advice.setVisible(True)
|
||||
self.apply_strategy_btn.setVisible(True)
|
||||
|
||||
def failed(err: str) -> None:
|
||||
self._ai_worker = None
|
||||
self.ai_analyze_btn.setEnabled(True)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
self.status_message.emit(str(err))
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._ai_worker = w
|
||||
w.start()
|
||||
|
||||
|
||||
__all__ = ["HabitsWidget"]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""TokenUsageCardWidget — the stat-card grid + budget card of the Dashboard
|
||||
(R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``, lines
|
||||
116-141/226-254/337-346 of the original 437-line file: the card grid layout,
|
||||
``_apply_budget``, ``_refresh_budget``, and ``refresh()``'s card-filling
|
||||
section).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from PySide6.QtWidgets import QGridLayout, QWidget
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.widgets import BudgetCard, StatCard, fmt_tokens
|
||||
|
||||
|
||||
class TokenUsageCardWidget(QWidget):
|
||||
"""Cost is the headline this screen exists for, so it gets a card twice
|
||||
the height of the rest instead of being the fifth of five identical
|
||||
tiles — with six equal cards nothing said which number mattered."""
|
||||
|
||||
def __init__(self, ctx, query: DashboardQueryService, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._query = query
|
||||
|
||||
cards_grid = QGridLayout(self)
|
||||
cards_grid.setSpacing(8)
|
||||
self.card_total = StatCard()
|
||||
self.card_in = StatCard()
|
||||
self.card_out = StatCard()
|
||||
self.card_cache = StatCard()
|
||||
self.card_cost = StatCard().as_hero()
|
||||
# Hero on the left, spanning both rows; the four supporting figures
|
||||
# fill a 2x2 block beside it.
|
||||
cards_grid.addWidget(self.card_cost, 0, 0, 2, 1)
|
||||
for i, card in enumerate((self.card_total, self.card_in,
|
||||
self.card_out, self.card_cache)):
|
||||
cards_grid.addWidget(card, i // 2, 1 + i % 2)
|
||||
# Budget: remaining/budget, direct entry, auto-warns red past 85% used.
|
||||
self.budget_card = BudgetCard()
|
||||
self.budget_card.apply_btn.setIcon(icon("check"))
|
||||
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
|
||||
cards_grid.addWidget(self.budget_card, 0, 3, 2, 1)
|
||||
for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)):
|
||||
cards_grid.setColumnStretch(col, stretch)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
|
||||
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
|
||||
|
||||
def refresh(self, start: date, end: date) -> None:
|
||||
summary = self._query.summary(start, end)
|
||||
s, pricing, costs = summary["stats"], summary["pricing"], summary["costs"]
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
est_note = (tr("dashboard.estimated_note", pct=int(s["estimated_share"] * 100))
|
||||
if s["estimated_share"] > 0 else "")
|
||||
self.card_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"]),
|
||||
tr("dashboard.card_turns", n=s["turns"]))
|
||||
self.card_in.set(tr("dashboard.card_in"), fmt_tokens(s["in"]),
|
||||
ut.format_cost(costs["in"], pricing))
|
||||
self.card_out.set(tr("dashboard.card_out"), fmt_tokens(s["out"]),
|
||||
ut.format_cost(costs["out"], pricing))
|
||||
self.card_cache.set(tr("dashboard.card_cache"), fmt_tokens(s["cache"]),
|
||||
ut.format_cost(costs["cache"], pricing))
|
||||
self.card_cost.set(tr("dashboard.card_cost"),
|
||||
ut.format_cost(summary["total_cost"], pricing, digits=2), est_note)
|
||||
self._refresh_budget()
|
||||
|
||||
def _apply_budget(self) -> None:
|
||||
"""Persist the spin box's value as the new budget — starts a fresh
|
||||
remaining-balance window (spend before now is no longer counted)."""
|
||||
ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD")
|
||||
self._query.set_budget(self.budget_card.budget_spin.value(), ccy)
|
||||
self.ctx.save()
|
||||
self._refresh_budget()
|
||||
|
||||
def _refresh_budget(self) -> None:
|
||||
from cowork_local.core import model_pricing as mp
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
pricing = self._query.pricing()
|
||||
status = self._query.budget_status()
|
||||
if status is None:
|
||||
self.budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget"))
|
||||
self.budget_card.budget_spin.setValue(0.0)
|
||||
return
|
||||
remaining_disp = mp.convert(status["remaining_usd"], "USD",
|
||||
pricing.get("currency", "USD"), self.ctx.config)
|
||||
amount_disp = mp.convert(status["amount_usd"], "USD",
|
||||
pricing.get("currency", "USD"), self.ctx.config)
|
||||
value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}"
|
||||
f" / {ut.format_cost(status['amount_usd'], pricing, digits=2)}")
|
||||
pct = int(round(status["pct_used"] * 100))
|
||||
sub = tr("usage.budget_over_warning") if status["over_85"] else tr("usage.budget_used_pct", pct=pct)
|
||||
self.budget_card.set(tr("usage.budget_title"), value, sub, warn=status["over_85"])
|
||||
# keep the entry field showing the CURRENT budget (in display currency)
|
||||
# — only when it doesn't already have unsaved focus/edits from the user.
|
||||
if not self.budget_card.budget_spin.hasFocus():
|
||||
self.budget_card.budget_spin.setValue(round(amount_disp, 2))
|
||||
|
||||
|
||||
__all__ = ["TokenUsageCardWidget"]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""UsageChartWidget — the period pager + granularity/metric/currency
|
||||
controls + spline chart of the Dashboard (R08-T13, extracted from
|
||||
``ui/dashboard_tab.py::DashboardTab``, lines 53-114/143-152/201-207/
|
||||
263-323 of the original 437-line file).
|
||||
|
||||
Owns the period SELECTOR (granularity + prev/next offset) that the whole
|
||||
screen follows — ``token_usage_card_widget.py`` and ``habits_widget.py``
|
||||
read :meth:`period_range`/:meth:`granularity` rather than keeping their own
|
||||
copy, and the shell re-refreshes them on :attr:`period_changed`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.spline_chart import SplineChart
|
||||
from cowork_local.ui.widgets import fmt_tokens
|
||||
|
||||
|
||||
class UsageChartWidget(QWidget):
|
||||
period_changed = Signal() # granularity or offset changed — re-run every widget
|
||||
currency_changed = Signal() # display currency changed — same, cost text depends on it
|
||||
|
||||
def __init__(self, ctx, query: DashboardQueryService, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._query = query
|
||||
self._chart_offset = 0 # 0 = current period; <0 = a past period
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
controls = QHBoxLayout()
|
||||
controls.setSpacing(6)
|
||||
self.chart_prev_btn = QPushButton()
|
||||
self.chart_prev_btn.setIcon(icon("chevron-left"))
|
||||
self.chart_prev_btn.setFixedWidth(30)
|
||||
self.chart_prev_btn.clicked.connect(self._chart_prev)
|
||||
controls.addWidget(self.chart_prev_btn)
|
||||
self._chart_period_lbl = QLabel()
|
||||
self._chart_period_lbl.setObjectName("hint")
|
||||
self._chart_period_lbl.setAlignment(Qt.AlignCenter)
|
||||
self._chart_period_lbl.setMinimumWidth(170)
|
||||
controls.addWidget(self._chart_period_lbl)
|
||||
self.chart_next_btn = QPushButton()
|
||||
self.chart_next_btn.setIcon(icon("chevron-right"))
|
||||
self.chart_next_btn.setFixedWidth(30)
|
||||
self.chart_next_btn.clicked.connect(self._chart_next)
|
||||
controls.addWidget(self.chart_next_btn)
|
||||
controls.addSpacing(12)
|
||||
self.gran_combo = QComboBox()
|
||||
for g in ("week", "month", "year"):
|
||||
self.gran_combo.addItem(tr(f"dashboard.gran_{g}"), g)
|
||||
self.gran_combo.currentIndexChanged.connect(self._on_gran_changed)
|
||||
controls.addWidget(self.gran_combo)
|
||||
self.metric_combo = QComboBox()
|
||||
for m in ("cost", "tokens"):
|
||||
self.metric_combo.addItem(tr(f"dashboard.metric_{m}"), m)
|
||||
self.metric_combo.currentIndexChanged.connect(self.refresh)
|
||||
controls.addWidget(self.metric_combo)
|
||||
controls.addStretch(1)
|
||||
# Display-currency picker — both Dashboard and Monitoring read/write
|
||||
# the same usage.currency config key, so changing it here updates
|
||||
# cost text everywhere.
|
||||
self.currency_lbl = QLabel()
|
||||
self.currency_lbl.setObjectName("hint")
|
||||
controls.addWidget(self.currency_lbl)
|
||||
self.currency_combo = QComboBox()
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
for cur in ut.SUPPORTED_CURRENCIES:
|
||||
self.currency_combo.addItem(cur, cur)
|
||||
idx = self.currency_combo.findData(
|
||||
(self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
|
||||
self.currency_combo.setCurrentIndex(max(0, idx))
|
||||
self.currency_combo.currentIndexChanged.connect(self._on_currency_changed)
|
||||
controls.addWidget(self.currency_combo)
|
||||
root.addLayout(controls)
|
||||
|
||||
chart_head = QHBoxLayout()
|
||||
self._chart_title = QLabel()
|
||||
self._chart_title.setStyleSheet("font-weight:600;")
|
||||
chart_head.addWidget(self._chart_title, 1)
|
||||
root.addLayout(chart_head)
|
||||
self.chart = SplineChart()
|
||||
root.addWidget(self.chart)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.currency_lbl.setText(tr("monitoring.overview_currency"))
|
||||
self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
|
||||
self._chart_title.setText(tr("dashboard.chart_title"))
|
||||
self.chart_prev_btn.setToolTip(tr("dashboard.chart_prev"))
|
||||
self.chart_next_btn.setToolTip(tr("dashboard.chart_next"))
|
||||
|
||||
# ---- public: the period selector every other widget follows ------------- #
|
||||
def granularity(self) -> str:
|
||||
return self.gran_combo.currentData() or "week"
|
||||
|
||||
@property
|
||||
def chart_offset(self) -> int:
|
||||
return self._chart_offset
|
||||
|
||||
def period_range(self) -> Tuple:
|
||||
return self._query.period_range(self.granularity(), self._chart_offset)
|
||||
|
||||
# ---- navigation ------------------------------------------------------------ #
|
||||
def _on_gran_changed(self, *_a) -> None:
|
||||
self._chart_offset = 0 # period size changed → back to current
|
||||
self.period_changed.emit()
|
||||
|
||||
def _chart_prev(self) -> None:
|
||||
self._chart_offset -= 1
|
||||
self.period_changed.emit()
|
||||
|
||||
def _chart_next(self) -> None:
|
||||
self._chart_offset = min(0, self._chart_offset + 1) # never past the present
|
||||
self.period_changed.emit()
|
||||
|
||||
def _on_currency_changed(self, _idx: int) -> None:
|
||||
cur = self.currency_combo.currentData()
|
||||
if not cur:
|
||||
return
|
||||
self.ctx.config.data.setdefault("usage", {})["currency"] = cur
|
||||
self.ctx.save()
|
||||
self.currency_changed.emit()
|
||||
|
||||
@staticmethod
|
||||
def _delta_txt(cur: float, prev: float) -> str:
|
||||
"""▲/▼ percent change of ``cur`` vs ``prev`` (empty if no baseline)."""
|
||||
if not prev:
|
||||
return ""
|
||||
pct = (cur - prev) / prev * 100
|
||||
arrow = "▲" if pct > 0.5 else ("▼" if pct < -0.5 else "•")
|
||||
return f"{arrow}{abs(pct):.0f}%"
|
||||
|
||||
# ---- rendering --------------------------------------------------------------- #
|
||||
def refresh(self, *_a) -> None:
|
||||
"""Break the SELECTED period into its parts: WEEK -> 7 days (Mon-Sun)
|
||||
- MONTH -> weeks W1..Wn - YEAR -> 12 months. A dashed line marks the
|
||||
previous same-granularity period's average per point with the %
|
||||
change of the totals."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
gran = self.granularity()
|
||||
metric = self.metric_combo.currentData() or "cost"
|
||||
pts = self._query.chart_series(gran, self._chart_offset, metric)
|
||||
pricing = self._query.pricing()
|
||||
mi = 0 if metric == "tokens" else 1
|
||||
# Compact cost format (2 decimals, K/M above 1,000/1,000,000) — the
|
||||
# chart's y-axis label box is narrow; format_cost's full precision
|
||||
# overflowed it, clipping/obscuring the amount.
|
||||
fmt = fmt_tokens if metric == "tokens" else (lambda v: ut.format_cost_compact(v, pricing))
|
||||
|
||||
cur = self._query.period_totals(gran, self._chart_offset)
|
||||
prev = self._query.period_totals(gran, self._chart_offset - 1)
|
||||
ref_key = {"week": "dashboard.ref_last_week",
|
||||
"month": "dashboard.ref_last_month",
|
||||
"year": "dashboard.ref_last_year"}.get(gran, "dashboard.ref_last_week")
|
||||
n_points = max(1, len(pts))
|
||||
refs = []
|
||||
if prev[mi] > 0:
|
||||
# Muted on purpose: the comparison line is a reference, not the
|
||||
# series — it must not compete with the accent-coloured spline.
|
||||
refs.append((prev[mi] / n_points,
|
||||
f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}",
|
||||
current_palette().text_muted))
|
||||
self.chart.set_reference_lines(refs)
|
||||
self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}"))
|
||||
self._chart_period_lbl.setText(self._query.period_range_label(gran, self._chart_offset))
|
||||
self.chart_next_btn.setEnabled(self._chart_offset < 0)
|
||||
|
||||
|
||||
__all__ = ["UsageChartWidget"]
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Folder Explorer screen, split into single-responsibility widgets
|
||||
(R08-T12): ``workspace_file_tree``, ``document_preview_manager``,
|
||||
``ai_edit_model_resolver``, ``ai_file_editor_dialog``, assembled by the
|
||||
``folder_tab`` shell."""
|
||||
@@ -0,0 +1,248 @@
|
||||
"""AiEditModelResolver — model picker + Auto Model Routing + image-model
|
||||
discovery for the AI-Edit panel (R08-T12, extracted from
|
||||
``ui/folder_tab.py::FolderTab``, lines 802-1036/911-961 of the original
|
||||
1587-line file: ``refresh_ai_models``, ``_scan_all_image_models``,
|
||||
``_ai_provider``, ``_ai_apply_routing``, ``_confirm_routing_switch``,
|
||||
``_ai_image_model``, ``_maybe_suggest_image_model``,
|
||||
``_suggest_cross_provider_image``).
|
||||
|
||||
A plain (non-Qt-widget) helper composed BY
|
||||
``ai_file_editor_dialog.py::AiFileEditorDialog`` — this is genuinely a
|
||||
distinct concern (which provider/model answers THIS run) from the panel's
|
||||
send/plan/edit orchestration, and splitting it out is also what keeps
|
||||
``ai_file_editor_dialog.py`` under the 400-line cap.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
|
||||
class AiEditModelResolver:
|
||||
"""Owns the AI-edit model combo's contents and every "which provider/
|
||||
model should THIS run use" decision — independent of the Cowork/Settings
|
||||
agent, exactly like the original panel's own picker was.
|
||||
|
||||
Args:
|
||||
ctx: ``AppContext``.
|
||||
model_combo: the ``QComboBox`` populated by :meth:`refresh`.
|
||||
on_status: ``(text) -> None`` — posts a status line into the AI
|
||||
chat (production passes ``ai_chat.add_status``).
|
||||
confirm_switch: ``(self, decision, timeout) -> bool`` — the Qt
|
||||
confirm dialog for Manual routing mode (kept as a callback so
|
||||
this class never imports a dialog itself).
|
||||
"""
|
||||
|
||||
def __init__(self, ctx, model_combo, on_status, confirm_switch) -> None:
|
||||
self.ctx = ctx
|
||||
self._combo = model_combo
|
||||
self._on_status = on_status
|
||||
self._confirm_switch = confirm_switch
|
||||
self._models: List[str] = []
|
||||
self._models_provider = ""
|
||||
self._all_image_models: List[Tuple[str, str]] = [] # [(provider_key, model)]
|
||||
self._img_scan_worker = None
|
||||
self._pending_img_suggest = False
|
||||
self._routed_provider: Optional[str] = None
|
||||
self._routed_model: Optional[str] = None
|
||||
|
||||
@property
|
||||
def models(self) -> List[str]:
|
||||
return self._models
|
||||
|
||||
@property
|
||||
def models_provider(self) -> str:
|
||||
return self._models_provider
|
||||
|
||||
@property
|
||||
def routed_provider(self) -> Optional[str]:
|
||||
"""The provider :meth:`apply_routing` switched to for the current
|
||||
run, or ``None`` when it didn't switch (routing off/declined)."""
|
||||
return self._routed_provider
|
||||
|
||||
@property
|
||||
def routed_model(self) -> Optional[str]:
|
||||
return self._routed_model
|
||||
|
||||
def should_refresh(self) -> bool:
|
||||
"""True on first open, or when the active provider changed since
|
||||
the model list was last loaded — a stale list would resolve a pick
|
||||
to the wrong/default model at the new endpoint."""
|
||||
return self._combo.count() <= 1 or self._models_provider != self.ctx.config.active_provider
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Fetch the active provider's model list (background) into the
|
||||
picker. Also proactively scans ALL providers for image-capable
|
||||
models so a suggestion is ready the moment one is needed."""
|
||||
name = self.ctx.config.active_provider
|
||||
setting_model = self.ctx.config.provider_conf(name).get("model", "")
|
||||
|
||||
def job(worker):
|
||||
prov = self.ctx.build_provider_for(name)
|
||||
try:
|
||||
models = list(getattr(prov, "list_models", lambda: [])() or [])
|
||||
except Exception: # noqa: BLE001
|
||||
models = []
|
||||
return {"models": models}
|
||||
|
||||
def done(res):
|
||||
fetched = list(res.get("models", []))
|
||||
# Always offer the Settings-configured model as an explicit
|
||||
# choice, even when the provider can't list models.
|
||||
self._models = list(dict.fromkeys(
|
||||
([setting_model] if setting_model else []) + [m for m in fetched if m]))
|
||||
self._models_provider = name
|
||||
cur = self._combo.currentData()
|
||||
self._combo.blockSignals(True)
|
||||
self._combo.clear()
|
||||
self._combo.addItem(tr("folder.ai_model_auto"), None)
|
||||
for m in self._models:
|
||||
self._combo.addItem(m, m)
|
||||
idx = self._combo.findData(cur)
|
||||
self._combo.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
self._combo.blockSignals(False)
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
self._models_worker = w
|
||||
w.start()
|
||||
self._scan_all_image_models()
|
||||
|
||||
def _scan_all_image_models(self, then_suggest: bool = False) -> None:
|
||||
if self._img_scan_worker is not None:
|
||||
if then_suggest:
|
||||
self._pending_img_suggest = True
|
||||
return
|
||||
providers = dict(self.ctx.config.data.get("providers", {}))
|
||||
candidates = [k for k, c in providers.items()
|
||||
if (c.get("base_url") or c.get("api_key"))]
|
||||
|
||||
def job(worker):
|
||||
from cowork_local.core import image_gen
|
||||
found = []
|
||||
for key in candidates:
|
||||
try:
|
||||
prov = self.ctx.build_provider_for(key)
|
||||
models = list(getattr(prov, "list_models", lambda: [])() or [])
|
||||
except Exception: # noqa: BLE001 - a broken provider must not block the scan
|
||||
models = []
|
||||
for m in models:
|
||||
if image_gen.looks_like_image_model(m):
|
||||
found.append((key, m))
|
||||
return {"found": found}
|
||||
|
||||
def done(res):
|
||||
self._img_scan_worker = None
|
||||
self._all_image_models = list(res.get("found", []))
|
||||
if self._pending_img_suggest:
|
||||
self._pending_img_suggest = False
|
||||
self._suggest_cross_provider_image()
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(self._on_image_scan_failed)
|
||||
self._img_scan_worker = w
|
||||
if then_suggest:
|
||||
self._pending_img_suggest = True
|
||||
w.start()
|
||||
|
||||
def _on_image_scan_failed(self, _err) -> None:
|
||||
self._img_scan_worker = None
|
||||
|
||||
def provider(self) -> Any:
|
||||
"""Build a provider using the model chosen in the picker ('(auto)'
|
||||
-> the active provider's default), or an Auto/Manual routing
|
||||
override set by :meth:`apply_routing` for the current run."""
|
||||
if self._routed_provider or self._routed_model:
|
||||
provider = self._routed_provider or self.ctx.config.active_provider
|
||||
return self.ctx.build_provider_for(provider, self._routed_model or None)
|
||||
model = self._combo.currentData()
|
||||
return self.ctx.build_provider_for(self.ctx.config.active_provider, model or None)
|
||||
|
||||
def apply_routing(self, instruction: str) -> None:
|
||||
"""Auto Model Routing for the AI-Edit surface (always a CODING
|
||||
task). Sets the routing override :meth:`provider` honours."""
|
||||
from cowork_local.core.routing.models import TaskType
|
||||
|
||||
self._routed_provider = None
|
||||
self._routed_model = None
|
||||
cur_provider = self.ctx.config.active_provider
|
||||
picked = self._combo.currentData()
|
||||
cur_model = picked or self.ctx.config.provider_conf(cur_provider).get("model", "")
|
||||
decision = self.ctx.routing_application().route_turn(
|
||||
"ai_edit", instruction, cur_provider, cur_model,
|
||||
task_type=TaskType.CODING, confirm=self._confirm_switch,
|
||||
)
|
||||
if not decision.switched:
|
||||
return
|
||||
self._routed_provider, self._routed_model = decision.target()
|
||||
self._on_status(tr(
|
||||
"routing.switched_notice",
|
||||
model=decision.model, task=decision.task_type,
|
||||
gain=f"{decision.score_gain:.2f}"))
|
||||
|
||||
_IMAGE_WORDS = ("image", "picture", "photo", "illustration", "icon", "logo", "diagram",
|
||||
"ảnh", "hình", "minh họa", "biểu tượng", "画像", "イラスト")
|
||||
|
||||
def maybe_suggest_image_model(self, instruction: str) -> None:
|
||||
"""If the request looks image-related, suggest a suitable image
|
||||
model BEFORE running — active provider first, then ALL providers."""
|
||||
from cowork_local.core import image_gen
|
||||
low = (instruction or "").lower()
|
||||
if not any(w in low for w in self._IMAGE_WORDS):
|
||||
return
|
||||
picked = self._combo.currentData()
|
||||
if picked and image_gen.looks_like_image_model(picked):
|
||||
return
|
||||
local = image_gen.suggest_image_model(self._models)
|
||||
if local:
|
||||
self._on_status(tr("folder.ai_image_suggest", model=local))
|
||||
return
|
||||
if self._all_image_models:
|
||||
self._suggest_cross_provider_image()
|
||||
elif self._img_scan_worker is not None:
|
||||
self._pending_img_suggest = True
|
||||
else:
|
||||
self._scan_all_image_models(then_suggest=True)
|
||||
|
||||
def _suggest_cross_provider_image(self) -> None:
|
||||
from cowork_local.config import PROVIDER_LABELS
|
||||
if not self._all_image_models:
|
||||
picked = self._combo.currentData()
|
||||
if picked:
|
||||
self._on_status(tr("folder.ai_image_use_selected", model=picked))
|
||||
else:
|
||||
self._on_status(tr("folder.ai_image_none"))
|
||||
return
|
||||
seen, lines = set(), []
|
||||
for key, model in self._all_image_models:
|
||||
tag = (key, model)
|
||||
if tag in seen:
|
||||
continue
|
||||
seen.add(tag)
|
||||
lines.append(f"• {model} ({PROVIDER_LABELS.get(key, key)})")
|
||||
if len(lines) >= 5:
|
||||
break
|
||||
self._on_status(tr("folder.ai_image_suggest_all") + "\n" + "\n".join(lines))
|
||||
|
||||
def image_model(self) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""Resolve ``(model, base_url, api_key)`` for image generation,
|
||||
searching ALL providers — see the module docstring for priority
|
||||
order (picked model if image-capable -> active provider's image
|
||||
model -> any other provider's -> fall back to the picked model)."""
|
||||
from cowork_local.core import image_gen
|
||||
picked = self._combo.currentData()
|
||||
if picked and image_gen.looks_like_image_model(picked):
|
||||
return picked, None, None
|
||||
local = image_gen.suggest_image_model(self._models)
|
||||
if local:
|
||||
return local, None, None
|
||||
for key, model in self._all_image_models:
|
||||
conf = self.ctx.config.provider_conf(key)
|
||||
return model, (conf.get("base_url") or None), (conf.get("api_key") or None)
|
||||
return (picked or None), None, None
|
||||
|
||||
|
||||
__all__ = ["AiEditModelResolver"]
|
||||
@@ -0,0 +1,372 @@
|
||||
"""AiEditPipeline — the plan-then-edit-then-apply state machine behind the
|
||||
AI-Edit panel (R08-T12, extracted from ``ui/folder_tab.py::FolderTab``,
|
||||
lines 1068-1097/1119-1146/1147-1467 of the original 1587-line file:
|
||||
``_ai_start`` through ``_ai_failed``, minus the queue/busy-badge bookkeeping
|
||||
which stays on ``ai_file_editor_dialog.py::AiFileEditorDialog`` — see that
|
||||
module's docstring for the split rationale).
|
||||
|
||||
A plain (non-Qt-widget) helper composed BY ``AiFileEditorDialog`` — same
|
||||
composition-to-respect-the-400-line-cap pattern as
|
||||
``office_document_renderer.py``. Talks to the file only through
|
||||
``document_preview_manager.py``'s public API (``ensure_editable_for_ai``,
|
||||
``write_content``, ``create_new_file``) — it never touches disk itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from cowork_local.application.workspaces.ai_edit_output import parse_ai_output
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.theme import current_palette
|
||||
|
||||
|
||||
class AiEditPipeline:
|
||||
"""Runs one instruction through PLAN -> EDIT -> (review) -> APPLY/DISCARD.
|
||||
|
||||
Args:
|
||||
owner: the ``AiFileEditorDialog`` — supplies ``ai_chat``, ``preview``
|
||||
(``DocumentPreviewManager``), ``resolver``
|
||||
(``AiEditModelResolver``), ``ctx``, ``cowork_context()``, and is
|
||||
told about status changes via ``on_busy_changed``/``on_flag_done``
|
||||
so the panel's queue/badge bookkeeping stays in one place.
|
||||
"""
|
||||
|
||||
def __init__(self, owner) -> None:
|
||||
self._owner = owner
|
||||
self.worker: Optional[AgentWorker] = None
|
||||
self.pending: Optional[dict] = None # proposed content awaiting confirmation
|
||||
self._ctx: dict = {}
|
||||
self._prompt_usage: dict = {"in": 0, "out": 0, "cache": 0, "cost": 0.0}
|
||||
self._running_file = ""
|
||||
|
||||
def start(self, instruction: str) -> None:
|
||||
"""Begin processing one instruction. Assumes the pipeline is idle
|
||||
(the panel's queue calls this when the previous run finishes)."""
|
||||
o = self._owner
|
||||
preview = o.preview
|
||||
editable = preview.stack.currentWidget() is preview.editor
|
||||
if not editable:
|
||||
editable = preview.ensure_editable_for_ai()
|
||||
o.resolver.maybe_suggest_image_model(instruction)
|
||||
o.resolver.apply_routing(instruction) # may switch to the best coding model
|
||||
has_file = editable and bool(preview.current_file)
|
||||
self._running_file = Path(preview.current_file).name if has_file else tr("folder.ai_new_file")
|
||||
o.set_busy(True)
|
||||
o.status_message.emit(tr("folder.ai_running", name=self._running_file))
|
||||
# Two phases so the PLAN is shown INLINE *before* the edit runs.
|
||||
self._ctx = {
|
||||
"filename": Path(preview.current_file).name if has_file else "",
|
||||
"content": preview.editor.toPlainText() if has_file else "",
|
||||
"convo": o.cowork_context(),
|
||||
"instruction": instruction,
|
||||
"provider": o.resolver.provider(),
|
||||
"plan": "",
|
||||
"edit_kind": preview.edit_kind,
|
||||
}
|
||||
self._prompt_usage = {"in": 0, "out": 0, "cache": 0, "cost": 0.0}
|
||||
self._run_plan()
|
||||
|
||||
# ---- usage accounting (like Cowork's per-message footer) --------------- #
|
||||
def _add_usage(self, usage) -> None:
|
||||
if not isinstance(usage, dict):
|
||||
return
|
||||
tot = self._prompt_usage
|
||||
tot["in"] += int(usage.get("in", 0) or 0)
|
||||
tot["out"] += int(usage.get("out", 0) or 0)
|
||||
tot["cache"] += int(usage.get("cache", 0) or 0)
|
||||
tot["cost"] += float(usage.get("cost_usd", 0.0) or 0.0)
|
||||
|
||||
def _show_usage(self, bubble) -> None:
|
||||
tot = self._prompt_usage
|
||||
if bubble is None or not (tot["in"] or tot["out"]):
|
||||
return
|
||||
from cowork_local.core import model_pricing as mp, usage_tracker as ut
|
||||
pricing = {**ut.DEFAULT_PRICING, **(self._owner.ctx.config.data.get("usage") or {})}
|
||||
line = (f"↓{mp.format_tokens(int(tot['in']))} ↑{mp.format_tokens(int(tot['out']))} "
|
||||
f"▤{mp.format_tokens(int(tot['in'] + tot['out'] + tot['cache']))} "
|
||||
f"{ut.format_cost(tot['cost'], pricing)}")
|
||||
try:
|
||||
bubble.add_usage(line)
|
||||
except Exception: # noqa: BLE001 - a usage footer must never break the edit
|
||||
pass
|
||||
|
||||
# ---- phase 1: plan ------------------------------------------------------- #
|
||||
def _run_plan(self) -> None:
|
||||
o = self._owner
|
||||
c = self._ctx
|
||||
plan_bubble = o.ai_chat.add_plan(tr("folder.ai_planning"))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
|
||||
def job(worker):
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
from cowork_local.core.co4e_runner import _usage_delta
|
||||
provider = c["provider"]
|
||||
messages = [{"role": "system", "content":
|
||||
"You are an AI file editor. Give a SHORT numbered plan (2-4 steps) for "
|
||||
"the requested change. Plan ONLY — do NOT output any code."}]
|
||||
if c["convo"]:
|
||||
messages.append({"role": "system",
|
||||
"content": "Context from the user's Cowork conversation:\n" + c["convo"]})
|
||||
messages.append({"role": "user", "content":
|
||||
f"File: {c['filename']}\n\nCurrent content:\n```\n{c['content']}\n```\n\n"
|
||||
f"Request: {c['instruction']}"})
|
||||
ut.set_context("folder", c.get("filename") or "AI edit")
|
||||
ut.begin_accumulation(); base = ut.accumulated()
|
||||
try:
|
||||
r = provider.chat(messages, tools=None, cancel=worker.is_cancelled)
|
||||
txt = r.get("content", "") if isinstance(r, dict) else str(r)
|
||||
usage = _usage_delta(base, o.ctx.config)
|
||||
finally:
|
||||
ut.end_accumulation()
|
||||
return {"plan": provider.strip_think(txt) or "", "usage": usage}
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.finished_ok.connect(lambda res, b=plan_bubble: self._plan_done(res, b))
|
||||
worker.failed.connect(lambda err, b=plan_bubble: self._failed(err, b))
|
||||
self.worker = worker
|
||||
worker.start()
|
||||
|
||||
def _plan_done(self, result, plan_bubble) -> None:
|
||||
self._add_usage((result or {}).get("usage"))
|
||||
plan = ((result or {}).get("plan") or "").strip()
|
||||
self._ctx["plan"] = plan
|
||||
plan_bubble.set_plain(plan or tr("folder.ai_empty"))
|
||||
self._owner.ai_chat.scroll_to_bottom()
|
||||
self._run_edit()
|
||||
|
||||
# ---- phase 2: execute (edit the file) ------------------------------------ #
|
||||
def _run_edit(self) -> None:
|
||||
o = self._owner
|
||||
c = self._ctx
|
||||
bubble = o.ai_chat.add_assistant(tr("folder.ai_edit"))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
|
||||
pptx_note = ("\nThis is a PPTX shown as marker blocks '### Slide N / Box M', where N is the "
|
||||
"1-based SLIDE NUMBER and M the box on that slide. When the user refers to a "
|
||||
"slide by number (e.g. 'edit slide 3'), change ONLY the blocks under '### Slide "
|
||||
"3' and leave every other slide's block exactly as-is. Each block has fields "
|
||||
"type/pos/size/font/text (and image for pictures). To change TEXT COLOUR or "
|
||||
"FONT edit the `font:` line — e.g. `font: name=Arial size=28 bold=1 "
|
||||
"color=FF0000`. Keep all block markers and structure.") if c["edit_kind"] == "pptx" else ""
|
||||
|
||||
_pptx_words = ("pptx", "powerpoint", "slide", "presentation", "deck",
|
||||
"スライド", "プレゼン", "trình chiếu", "trinh chieu", "bài thuyết trình")
|
||||
wants_new_pptx = (c["edit_kind"] != "pptx"
|
||||
and any(w in c["instruction"].lower() for w in _pptx_words))
|
||||
new_pptx_note = ("\nTo CREATE a PowerPoint, name it `FILE: <name>.pptx` and output the slides "
|
||||
"as marker blocks — one block per shape:\n"
|
||||
"### Slide 1 / Box 1\ntype: text\npos: 0.5, 0.4\nsize: 9.0, 1.2\n"
|
||||
"font: name=Calibri size=32 bold=1 color=1F3864\ntext:\nTitle here\n\n"
|
||||
"### Slide 1 / Box 2\ntype: text\npos: 0.5, 1.8\nsize: 9.0, 4.5\n"
|
||||
"text:\nBullet one\nBullet two\n\n"
|
||||
"Increment the Slide number for each new slide; pos/size are in inches; "
|
||||
"font color is RRGGBB hex.") if wants_new_pptx else ""
|
||||
|
||||
imggen_note = ""
|
||||
try:
|
||||
from cowork_local.core import image_gen
|
||||
if image_gen.is_configured(o.ctx.config):
|
||||
imggen_note = ("\nYou can also GENERATE an illustration image: add a line "
|
||||
"`IMAGE_GEN: <describe the image> => <relative/path.png>`. Use a "
|
||||
"generated image e.g. as a new picture, or (for pptx) set a picture "
|
||||
"box's `image:` field to that same path to insert it.")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def job(worker):
|
||||
provider = c["provider"]
|
||||
open_note = (f"the currently-open file '{c['filename']}'" if c["filename"]
|
||||
else "no file is open")
|
||||
messages = [{"role": "system", "content":
|
||||
"You are an AI file editor inside an app. Following the plan, output the "
|
||||
"COMPLETE file content in ONE fenced code block (```), and nothing after "
|
||||
"it. Preserve everything you were not asked to change.\n"
|
||||
"If the request is to CREATE A NEW file (or a different file than the one "
|
||||
"open), put a line `FILE: <relative/path/name.ext>` (relative to the "
|
||||
"current folder) immediately before the code block. Omit FILE to edit the "
|
||||
f"open file. Right now, {open_note}." + pptx_note + new_pptx_note + imggen_note}]
|
||||
if c["convo"]:
|
||||
messages.append({"role": "system",
|
||||
"content": "Context from the user's Cowork conversation:\n" + c["convo"]})
|
||||
if c["plan"]:
|
||||
messages.append({"role": "system", "content": "Plan to follow:\n" + c["plan"]})
|
||||
cur = (f"File: {c['filename']}\n\nCurrent content:\n```\n{c['content']}\n```\n\n"
|
||||
if c["filename"] else "No file is currently open.\n\n")
|
||||
messages.append({"role": "user", "content": cur + f"Request: {c['instruction']}"})
|
||||
|
||||
def on_text(piece: str) -> None:
|
||||
worker.emit_event({"type": "text", "delta": piece})
|
||||
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
from cowork_local.core.co4e_runner import _usage_delta
|
||||
ut.set_context("folder", c.get("filename") or "AI edit")
|
||||
ut.begin_accumulation(); base = ut.accumulated()
|
||||
try:
|
||||
r = provider.chat(messages, tools=None, on_text=on_text, cancel=worker.is_cancelled)
|
||||
txt = r.get("content", "") if isinstance(r, dict) else str(r)
|
||||
usage = _usage_delta(base, o.ctx.config)
|
||||
finally:
|
||||
ut.end_accumulation()
|
||||
return {"text": provider.strip_think(txt) or "", "usage": usage}
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.event.connect(lambda ev, b=bubble: self._stream(ev, b))
|
||||
worker.finished_ok.connect(lambda res, b=bubble: self._done(res, b))
|
||||
worker.failed.connect(lambda err, b=bubble: self._failed(err, b))
|
||||
self.worker = worker
|
||||
worker.start()
|
||||
|
||||
def _stream(self, ev, bubble) -> None:
|
||||
if isinstance(ev, dict) and ev.get("type") == "text":
|
||||
bubble.append_delta(ev.get("delta", ""))
|
||||
self._owner.ai_chat.scroll_to_bottom()
|
||||
|
||||
def _done(self, result, bubble) -> None:
|
||||
o = self._owner
|
||||
self.worker = None
|
||||
o.set_busy(False)
|
||||
self._add_usage((result or {}).get("usage"))
|
||||
self._show_usage(bubble)
|
||||
text = ((result or {}).get("text") or "").strip()
|
||||
target, new_content, summary, image_gens = parse_ai_output(text)
|
||||
if new_content is None and not image_gens:
|
||||
bubble.set_markdown(text or tr("folder.ai_empty"))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
o.flag_done()
|
||||
return
|
||||
create = bool(target) and (not o.preview.current_file
|
||||
or Path(target).name != Path(o.preview.current_file).name)
|
||||
self.pending = {"content": new_content, "target": target if create else None,
|
||||
"image_gens": image_gens}
|
||||
hint = tr("folder.ai_review_hint")
|
||||
bubble.set_markdown((summary + "\n\n" if summary else "") + "_" + hint + "_")
|
||||
if new_content is not None:
|
||||
old = "" if create else o.preview.editor.toPlainText()
|
||||
diff = "".join(difflib.unified_diff(
|
||||
old.splitlines(keepends=True), new_content.splitlines(keepends=True),
|
||||
fromfile=("(new file)" if create else "current"),
|
||||
tofile=(target if create else "proposed"))) or "(no textual difference)"
|
||||
title = tr("folder.ai_proposed_new", name=target) if create else tr("folder.ai_proposed")
|
||||
o.ai_chat.add_diff(title, diff)
|
||||
if image_gens:
|
||||
listing = "\n".join(f"• {p} ({prompt[:60]})" for prompt, p in image_gens)
|
||||
o.ai_chat.add_status(tr("folder.ai_image_plan") + "\n" + listing)
|
||||
o.show_confirm_row(True)
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
name = target if create else self._running_file
|
||||
o.status_message.emit(tr("folder.ai_proposed_status", name=name))
|
||||
o.set_review_status("● " + hint, current_palette().warning)
|
||||
|
||||
# ---- apply / discard ------------------------------------------------------ #
|
||||
def apply(self) -> None:
|
||||
"""Confirmed by the user. If the edit GENERATES images, ask the
|
||||
image gate then generate them (off-thread) before finalising."""
|
||||
if not self.pending:
|
||||
return
|
||||
p = self.pending
|
||||
self.pending = None
|
||||
self._owner.show_confirm_row(False)
|
||||
if p.get("image_gens"):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
if QMessageBox.question(self._owner, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm_gen")) != QMessageBox.Yes:
|
||||
self._owner.status_message.emit(tr("folder.ai_image_declined"))
|
||||
return
|
||||
self._generate_then_finalize(p)
|
||||
return
|
||||
self._finalize_apply(p)
|
||||
|
||||
def _generate_then_finalize(self, p: dict) -> None:
|
||||
o = self._owner
|
||||
imgs = p.get("image_gens") or []
|
||||
root = os.path.normpath(o.preview.root)
|
||||
img_model, img_base, img_key = o.resolver.image_model()
|
||||
o.set_busy(True)
|
||||
o.status_message.emit(tr("folder.ai_generating"))
|
||||
|
||||
def job(worker):
|
||||
from cowork_local.core import image_gen
|
||||
results = []
|
||||
for prompt, rel in imgs:
|
||||
dest = rel if os.path.isabs(rel) else os.path.join(root, rel)
|
||||
dest = os.path.normpath(dest)
|
||||
if os.path.commonpath([dest, root]) != root:
|
||||
results.append((rel, False, "path escapes the folder"))
|
||||
continue
|
||||
try:
|
||||
os.makedirs(os.path.dirname(dest) or root, exist_ok=True)
|
||||
except OSError as exc:
|
||||
results.append((rel, False, str(exc)))
|
||||
continue
|
||||
ok, msg = image_gen.generate_image(o.ctx.config, prompt, dest,
|
||||
model=img_model, base_url=img_base, api_key=img_key)
|
||||
results.append((dest, ok, msg))
|
||||
return {"results": results}
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.finished_ok.connect(lambda res, pp=p: self._images_done(res, pp))
|
||||
worker.failed.connect(lambda err, pp=p: self._images_done({"results": [], "err": err}, pp))
|
||||
self.worker = worker
|
||||
worker.start()
|
||||
|
||||
def _images_done(self, res: dict, p: dict) -> None:
|
||||
o = self._owner
|
||||
self.worker = None
|
||||
o.set_busy(False)
|
||||
created = []
|
||||
for dest, ok, msg in res.get("results", []):
|
||||
if ok:
|
||||
created.append(dest)
|
||||
o.ai_chat.add_success("✓ " + tr("folder.ai_image_created", name=Path(dest).name))
|
||||
else:
|
||||
o.ai_chat.add_error(tr("folder.ai_image_failed", err=msg))
|
||||
self._finalize_apply(p, images_done=True)
|
||||
if p.get("content") is None and not p.get("target") and created:
|
||||
o.preview.open_file(created[0], reset_ai=False)
|
||||
|
||||
def _finalize_apply(self, p: dict, images_done: bool = False) -> None:
|
||||
o = self._owner
|
||||
content = p.get("content")
|
||||
target = p.get("target")
|
||||
if content is None:
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
o.flag_done()
|
||||
o.status_message.emit(tr("folder.ai_done", name=self._running_file))
|
||||
return
|
||||
if target:
|
||||
dest = o.preview.create_new_file(target, content)
|
||||
if dest is None:
|
||||
return
|
||||
o.ai_chat.add_success("✓ " + tr("folder.ai_created", name=Path(dest).name))
|
||||
o.status_message.emit(tr("folder.ai_created", name=Path(dest).name))
|
||||
else:
|
||||
o.preview.editor.setPlainText(content) # live update in the editor/preview
|
||||
o.preview.write_content(content, skip_image_confirm=images_done)
|
||||
o.ai_chat.add_success("✓ " + tr("folder.ai_applied"))
|
||||
o.status_message.emit(tr("folder.ai_done", name=self._running_file))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
o.flag_done()
|
||||
|
||||
def discard(self) -> None:
|
||||
o = self._owner
|
||||
self.pending = None
|
||||
o.show_confirm_row(False)
|
||||
o.ai_chat.add_status(tr("folder.ai_discarded"))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
o.set_review_status("", None)
|
||||
o.maybe_dequeue() # discarding resolves the gate → run the next queued edit
|
||||
|
||||
def _failed(self, err, bubble) -> None:
|
||||
o = self._owner
|
||||
self.worker = None
|
||||
bubble.set_markdown(tr("folder.ai_error", err=err))
|
||||
o.set_busy(False)
|
||||
o.status_message.emit(tr("folder.ai_error", err=err))
|
||||
o.flag_done()
|
||||
|
||||
|
||||
__all__ = ["AiEditPipeline"]
|
||||
@@ -0,0 +1,237 @@
|
||||
"""AiFileEditorDialog — the collapsible AI-edit panel of the Folder Explorer
|
||||
(R08-T12, extracted from ``ui/folder_tab.py::FolderTab``, lines 701-800/
|
||||
1039-1066/1099-1118/1468-1517 of the original 1587-line file:
|
||||
``_build_ai_panel``, panel open/reset, the instruction queue, and the busy/
|
||||
done status line).
|
||||
|
||||
Despite the name (matching ``docs/refactor/Feature_Architecture_Proposal.md``'s
|
||||
R08-T12 file list), this is an inline collapsible ``QWidget`` panel, not a
|
||||
modal ``QDialog`` — exactly like the original ``_ai_panel`` was.
|
||||
|
||||
Composes two helpers to stay under the 400-line cap:
|
||||
``ai_edit_model_resolver.py::AiEditModelResolver`` (which provider/model
|
||||
answers a run) and ``ai_edit_pipeline.py::AiEditPipeline`` (the actual
|
||||
plan-then-edit-then-apply state machine). This class owns the widget itself,
|
||||
the instruction queue, and the busy/done status line/badge — the parts that
|
||||
needed to stay together because the queue decides when the pipeline's next
|
||||
``start()`` call happens.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget,
|
||||
)
|
||||
from PySide6.QtCore import Signal
|
||||
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.folder.ai_edit_model_resolver import AiEditModelResolver
|
||||
from cowork_local.presentation.folder.ai_edit_pipeline import AiEditPipeline
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.chat_view import ChatView
|
||||
|
||||
|
||||
class AiFileEditorDialog(QWidget):
|
||||
"""Collapsible panel: a Cowork-style inline chat timeline, this panel's
|
||||
OWN model picker + routing toggle, an instruction box, and an Apply/
|
||||
Discard confirmation bar for the proposed edit.
|
||||
|
||||
Args:
|
||||
ctx: ``AppContext``.
|
||||
preview: ``document_preview_manager.py::DocumentPreviewManager`` —
|
||||
every read/write of the actual file content goes through it.
|
||||
cowork: the shared Cowork tab (optional) — its recent messages are
|
||||
included as background context for the edit.
|
||||
"""
|
||||
|
||||
status_message = Signal(str)
|
||||
badge_changed = Signal(str) # "" | " ⏳" | " ✓" — the shell mirrors this onto its toggle button
|
||||
|
||||
def __init__(self, ctx, preview, cowork=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self.preview = preview
|
||||
self._cowork = cowork
|
||||
self._ai_queue: List[str] = []
|
||||
self.pipeline = AiEditPipeline(self)
|
||||
self.resolver: Optional[AiEditModelResolver] = None # built after ai_model_combo exists
|
||||
|
||||
preview.ai_reset_requested.connect(self.reset_conversation)
|
||||
preview.status_message.connect(self.status_message.emit)
|
||||
|
||||
v = QVBoxLayout(self)
|
||||
v.setContentsMargins(6, 0, 0, 0)
|
||||
v.setSpacing(4)
|
||||
title_row = QHBoxLayout()
|
||||
self._ai_title = QLabel(tr("folder.ai_edit"))
|
||||
self._ai_title.setStyleSheet("font-weight:600;")
|
||||
title_row.addWidget(self._ai_title)
|
||||
title_row.addStretch(1)
|
||||
self._ai_status = QLabel("")
|
||||
self._ai_status.setObjectName("hint")
|
||||
title_row.addWidget(self._ai_status)
|
||||
v.addLayout(title_row)
|
||||
self.ai_chat = ChatView()
|
||||
v.addWidget(self.ai_chat, 1)
|
||||
|
||||
model_row = QHBoxLayout()
|
||||
self._ai_model_lbl = QLabel(tr("folder.ai_model_label"))
|
||||
self._ai_model_lbl.setObjectName("hint")
|
||||
model_row.addWidget(self._ai_model_lbl)
|
||||
self.ai_model_combo = QComboBox()
|
||||
self.ai_model_combo.addItem(tr("folder.ai_model_auto"), None)
|
||||
model_row.addWidget(self.ai_model_combo, 1)
|
||||
from cowork_local.ui.routing_toggle import RoutingToggle
|
||||
self.ai_routing_toggle = RoutingToggle(self.ctx, "ai_edit")
|
||||
model_row.addWidget(self.ai_routing_toggle)
|
||||
v.addLayout(model_row)
|
||||
self.resolver = AiEditModelResolver(
|
||||
ctx, self.ai_model_combo, self.ai_chat.add_status, self._confirm_routing_switch)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self.ai_input = QLineEdit()
|
||||
self.ai_input.setPlaceholderText(tr("folder.ai_placeholder"))
|
||||
self.ai_input.returnPressed.connect(self._ai_send)
|
||||
row.addWidget(self.ai_input, 1)
|
||||
self.ai_send_btn = QPushButton(tr("folder.ai_send"))
|
||||
self.ai_send_btn.setObjectName("primary")
|
||||
self.ai_send_btn.clicked.connect(self._ai_send)
|
||||
row.addWidget(self.ai_send_btn)
|
||||
v.addLayout(row)
|
||||
|
||||
self._ai_confirm_row = QWidget()
|
||||
cf = QHBoxLayout(self._ai_confirm_row)
|
||||
cf.setContentsMargins(0, 0, 0, 0)
|
||||
cf.addStretch(1)
|
||||
self._ai_discard_btn = QPushButton(tr("folder.ai_discard"))
|
||||
self._ai_discard_btn.clicked.connect(self.pipeline.discard)
|
||||
cf.addWidget(self._ai_discard_btn)
|
||||
self._ai_apply_btn = QPushButton(tr("folder.ai_apply"))
|
||||
self._ai_apply_btn.setObjectName("primary")
|
||||
self._ai_apply_btn.clicked.connect(self.pipeline.apply)
|
||||
cf.addWidget(self._ai_apply_btn)
|
||||
self._ai_confirm_row.setVisible(False)
|
||||
v.addWidget(self._ai_confirm_row)
|
||||
|
||||
on_language_changed(self.retranslate)
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self._ai_title.setText(tr("folder.ai_edit"))
|
||||
self.ai_input.setPlaceholderText(tr("folder.ai_placeholder"))
|
||||
self.ai_send_btn.setText(tr("folder.ai_send"))
|
||||
self._ai_model_lbl.setText(tr("folder.ai_model_label"))
|
||||
if self.ai_model_combo.count() >= 1 and self.ai_model_combo.itemData(0) is None:
|
||||
self.ai_model_combo.setItemText(0, tr("folder.ai_model_auto"))
|
||||
self._ai_apply_btn.setText(tr("folder.ai_apply"))
|
||||
self._ai_discard_btn.setText(tr("folder.ai_discard"))
|
||||
|
||||
# ---- called by the shell (header button, splitter owner) --------------- #
|
||||
def on_opened(self) -> None:
|
||||
"""The shell's AI toggle button was just checked ON."""
|
||||
self.ai_input.setFocus()
|
||||
if self.resolver.should_refresh():
|
||||
self.resolver.refresh()
|
||||
if self.pipeline.worker is None:
|
||||
self.badge_changed.emit("")
|
||||
self._ai_status.setText("")
|
||||
|
||||
def reset_conversation(self) -> None:
|
||||
"""Clear the AI-edit chat so each file starts a clean conversation. A
|
||||
run in progress (editing the previous file) is left untouched — the
|
||||
reset applies the next time a file is opened while idle."""
|
||||
if self.pipeline.worker is not None:
|
||||
return
|
||||
self.ai_chat.clear()
|
||||
self.badge_changed.emit("")
|
||||
self.pipeline.pending = None
|
||||
self._ai_confirm_row.setVisible(False)
|
||||
self._ai_status.setText("")
|
||||
|
||||
def cowork_context(self) -> str:
|
||||
"""The whole Cowork conversation (recent turns) as background context."""
|
||||
cw = self._cowork
|
||||
msgs = getattr(cw, "messages", None) if cw is not None else None
|
||||
if not msgs:
|
||||
return ""
|
||||
lines = [f"{m['role']}: {str(m['content'])[:1000]}"
|
||||
for m in msgs if m.get("role") in ("user", "assistant") and m.get("content")]
|
||||
return "\n".join(lines[-12:])
|
||||
|
||||
# ---- send / queue -------------------------------------------------------- #
|
||||
def _ai_send(self) -> None:
|
||||
if not self.preview.root:
|
||||
self.ai_chat.add_error(tr("folder.ai_no_file"))
|
||||
return
|
||||
instruction = self.ai_input.text().strip()
|
||||
if not instruction:
|
||||
return
|
||||
self.ai_input.clear()
|
||||
self.ai_chat.add_user(instruction)
|
||||
# QUEUE: while a run is active OR a proposal is awaiting Apply/Discard,
|
||||
# hold the new instruction and run it once the pipeline goes idle.
|
||||
if self.pipeline.worker is not None or self.pipeline.pending is not None:
|
||||
self._ai_queue.append(instruction)
|
||||
self.ai_chat.add_status(tr("folder.ai_queued", n=len(self._ai_queue)))
|
||||
self._update_queue_status()
|
||||
return
|
||||
self.pipeline.start(instruction)
|
||||
|
||||
def _update_queue_status(self) -> None:
|
||||
n = len(self._ai_queue)
|
||||
if n:
|
||||
self._ai_status.setText("⏳ " + tr("folder.ai_status_running")
|
||||
+ " · " + tr("folder.ai_queue_count", n=n))
|
||||
self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
|
||||
|
||||
def maybe_dequeue(self) -> None:
|
||||
"""When the pipeline is fully idle, start the next queued instruction."""
|
||||
if self.pipeline.worker is not None or self.pipeline.pending is not None:
|
||||
return
|
||||
if not self._ai_queue:
|
||||
return
|
||||
nxt = self._ai_queue.pop(0)
|
||||
self._update_queue_status()
|
||||
self.pipeline.start(nxt)
|
||||
|
||||
# ---- pipeline callbacks (see ai_edit_pipeline.py) ------------------------- #
|
||||
def set_busy(self, busy: bool) -> None:
|
||||
self.ai_input.setEnabled(not busy)
|
||||
self.ai_send_btn.setEnabled(not busy)
|
||||
if busy:
|
||||
self._ai_status.setText("⏳ " + tr("folder.ai_status_running"))
|
||||
self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
|
||||
self.badge_changed.emit(" ⏳") # visible even when collapsed
|
||||
else:
|
||||
self._ai_status.setText("")
|
||||
self.badge_changed.emit("")
|
||||
|
||||
def flag_done(self) -> None:
|
||||
"""After a background run, show a 'done' badge so the user notices
|
||||
the result when they return to the tab; cleared on reopen. If more
|
||||
instructions are queued, start the next one instead."""
|
||||
if self.pipeline.worker is None and self.pipeline.pending is None and self._ai_queue:
|
||||
self.maybe_dequeue()
|
||||
return
|
||||
self._ai_status.setText("✓ " + tr("folder.ai_status_done"))
|
||||
self._ai_status.setStyleSheet(f"color:{current_palette().success};")
|
||||
self.badge_changed.emit(" ✓")
|
||||
|
||||
def show_confirm_row(self, visible: bool) -> None:
|
||||
self._ai_confirm_row.setVisible(visible)
|
||||
|
||||
def set_review_status(self, text: str, color) -> None:
|
||||
self._ai_status.setText(text)
|
||||
if color:
|
||||
self._ai_status.setStyleSheet(f"color:{color};")
|
||||
|
||||
def _confirm_routing_switch(self, decision) -> bool:
|
||||
"""Manual mode: ask before moving this AI-Edit run to another model."""
|
||||
from cowork_local.ui.routing_toggle import confirm_switch
|
||||
|
||||
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
|
||||
return bool(confirm_switch(self, decision, timeout))
|
||||
|
||||
|
||||
__all__ = ["AiFileEditorDialog"]
|
||||
@@ -0,0 +1,190 @@
|
||||
"""CodeEditor — the VS-Code-style code/text editor widget (R08-T12, split
|
||||
out of ``document_preview_manager.py`` to keep that file under the 400-line
|
||||
cap; originally ``ui/folder_tab.py``, lines 61-236 of the original
|
||||
1587-line file: the Pygments token-colour helper, ``PygmentsHighlighter``,
|
||||
``_LineNumbers``, ``CodeEditor``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QRect, QSize, Qt, QTimer
|
||||
from PySide6.QtGui import QColor, QFont, QPainter, QSyntaxHighlighter, QTextCharFormat
|
||||
from PySide6.QtWidgets import QPlainTextEdit, QWidget
|
||||
|
||||
from cowork_local.theme import current_palette
|
||||
|
||||
_MAX_HIGHLIGHT_CHARS = 300_000 # skip colouring huge files (keeps typing snappy)
|
||||
|
||||
|
||||
# ── VS-Code-Dark+-ish token palette ────────────────────────────────────────
|
||||
def _fmt(color: str, *, italic: bool = False, bold: bool = False) -> QTextCharFormat:
|
||||
f = QTextCharFormat()
|
||||
f.setForeground(QColor(color))
|
||||
if italic:
|
||||
f.setFontItalic(True)
|
||||
if bold:
|
||||
f.setFontWeight(QFont.Bold)
|
||||
return f
|
||||
|
||||
|
||||
class PygmentsHighlighter(QSyntaxHighlighter):
|
||||
"""Colour the whole document with Pygments and apply per-block. Re-lexes the
|
||||
full text (debounced) so multi-line strings/comments colour correctly."""
|
||||
|
||||
def __init__(self, document):
|
||||
super().__init__(document)
|
||||
from pygments.lexers.special import TextLexer
|
||||
self._lexer = TextLexer(stripnl=False)
|
||||
self._ranges: list[tuple[int, int, QTextCharFormat]] = []
|
||||
self._rules = self._build_rules()
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.setInterval(250)
|
||||
self._timer.timeout.connect(self._retokenize)
|
||||
document.contentsChanged.connect(self._timer.start)
|
||||
|
||||
@staticmethod
|
||||
def _build_rules():
|
||||
from pygments.token import (
|
||||
Comment, Error, Keyword, Name, Number, Operator, Punctuation, String,
|
||||
)
|
||||
p = current_palette()
|
||||
return [
|
||||
(Comment, _fmt(p.code_comment, italic=True)),
|
||||
(Keyword.Type, _fmt(p.code_type)),
|
||||
(Keyword, _fmt(p.code_keyword)),
|
||||
(Name.Function, _fmt(p.code_func)),
|
||||
(Name.Class, _fmt(p.code_type)),
|
||||
(Name.Decorator, _fmt(p.code_func)),
|
||||
(Name.Builtin, _fmt(p.code_type)),
|
||||
(Name.Tag, _fmt(p.code_keyword)),
|
||||
(Name.Attribute, _fmt(p.code_attr)),
|
||||
(String.Doc, _fmt(p.code_comment, italic=True)),
|
||||
(String, _fmt(p.code_string)),
|
||||
(Number, _fmt(p.code_number)),
|
||||
(Operator, _fmt(p.code_fg)),
|
||||
(Punctuation, _fmt(p.code_fg)),
|
||||
(Error, _fmt(p.code_error)),
|
||||
]
|
||||
|
||||
def set_filename(self, filename: str, text: str = "") -> None:
|
||||
from pygments.lexers import get_lexer_for_filename, guess_lexer
|
||||
from pygments.lexers.special import TextLexer
|
||||
from pygments.util import ClassNotFound
|
||||
try:
|
||||
self._lexer = get_lexer_for_filename(filename, stripnl=False)
|
||||
except ClassNotFound:
|
||||
try:
|
||||
self._lexer = guess_lexer(text) if text.strip() else TextLexer()
|
||||
except ClassNotFound:
|
||||
self._lexer = TextLexer(stripnl=False)
|
||||
self._retokenize()
|
||||
|
||||
def _fmt_for(self, tok):
|
||||
for ttype, fmt in self._rules:
|
||||
if tok in ttype:
|
||||
return fmt
|
||||
return None
|
||||
|
||||
def _retokenize(self) -> None:
|
||||
from pygments import lex
|
||||
text = self.document().toPlainText()
|
||||
self._ranges = []
|
||||
if len(text) <= _MAX_HIGHLIGHT_CHARS:
|
||||
pos = 0
|
||||
for tok, val in lex(text, self._lexer):
|
||||
fmt = self._fmt_for(tok)
|
||||
if fmt is not None and val:
|
||||
self._ranges.append((pos, pos + len(val), fmt))
|
||||
pos += len(val)
|
||||
self.rehighlight()
|
||||
|
||||
def highlightBlock(self, text: str) -> None: # noqa: N802 - Qt override
|
||||
if not self._ranges:
|
||||
return
|
||||
bstart = self.currentBlock().position()
|
||||
bend = bstart + len(text)
|
||||
for start, end, fmt in self._ranges:
|
||||
if end <= bstart or start >= bend:
|
||||
continue
|
||||
s = max(start, bstart) - bstart
|
||||
e = min(end, bend) - bstart
|
||||
if e > s:
|
||||
self.setFormat(s, e - s, fmt)
|
||||
|
||||
|
||||
class _LineNumbers(QWidget):
|
||||
def __init__(self, editor):
|
||||
super().__init__(editor)
|
||||
self._editor = editor
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(self._editor.line_number_width(), 0)
|
||||
|
||||
def paintEvent(self, event): # noqa: N802
|
||||
self._editor.paint_line_numbers(event)
|
||||
|
||||
|
||||
class CodeEditor(QPlainTextEdit):
|
||||
"""A dark, monospaced editor with a line-number gutter + Pygments colouring —
|
||||
the Sublime/VS-Code look for viewing & editing source files."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setObjectName("codeEditor")
|
||||
self.setLineWrapMode(QPlainTextEdit.NoWrap)
|
||||
self.setTabStopDistance(4 * self.fontMetrics().horizontalAdvance(" "))
|
||||
font = QFont("Consolas")
|
||||
font.setStyleHint(QFont.Monospace)
|
||||
font.setPointSize(10)
|
||||
self.setFont(font)
|
||||
self._gutter = _LineNumbers(self)
|
||||
self.blockCountChanged.connect(lambda _=0: self._update_gutter_width())
|
||||
self.updateRequest.connect(self._on_update_request)
|
||||
self._highlighter = PygmentsHighlighter(self.document())
|
||||
self._update_gutter_width()
|
||||
|
||||
def line_number_width(self) -> int:
|
||||
digits = max(2, len(str(max(1, self.blockCount()))))
|
||||
return 12 + self.fontMetrics().horizontalAdvance("9") * digits
|
||||
|
||||
def _update_gutter_width(self) -> None:
|
||||
self.setViewportMargins(self.line_number_width(), 0, 0, 0)
|
||||
|
||||
def _on_update_request(self, rect, dy: int) -> None:
|
||||
if dy:
|
||||
self._gutter.scroll(0, dy)
|
||||
else:
|
||||
self._gutter.update(0, rect.y(), self._gutter.width(), rect.height())
|
||||
if rect.contains(self.viewport().rect()):
|
||||
self._update_gutter_width()
|
||||
|
||||
def resizeEvent(self, event): # noqa: N802
|
||||
super().resizeEvent(event)
|
||||
cr = self.contentsRect()
|
||||
self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.line_number_width(), cr.height()))
|
||||
|
||||
def paint_line_numbers(self, event) -> None:
|
||||
p = current_palette()
|
||||
painter = QPainter(self._gutter)
|
||||
painter.fillRect(event.rect(), QColor(p.code_gutter_bg))
|
||||
block = self.firstVisibleBlock()
|
||||
num = block.blockNumber()
|
||||
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
|
||||
bottom = top + self.blockBoundingRect(block).height()
|
||||
painter.setPen(QColor(p.code_gutter_fg))
|
||||
while block.isValid() and top <= event.rect().bottom():
|
||||
if block.isVisible() and bottom >= event.rect().top():
|
||||
painter.drawText(0, int(top), self._gutter.width() - 6,
|
||||
self.fontMetrics().height(), Qt.AlignRight,
|
||||
str(num + 1))
|
||||
block = block.next()
|
||||
top = bottom
|
||||
bottom = top + self.blockBoundingRect(block).height()
|
||||
num += 1
|
||||
|
||||
def load_file(self, path: str, text: str) -> None:
|
||||
self.setPlainText(text)
|
||||
self._highlighter.set_filename(path, text)
|
||||
|
||||
|
||||
__all__ = ["CodeEditor", "PygmentsHighlighter"]
|
||||
@@ -0,0 +1,304 @@
|
||||
"""DocumentPreviewManager — the view/edit pane of the Folder Explorer
|
||||
(R08-T12, extracted from ``ui/folder_tab.py::FolderTab``, lines 295-373/
|
||||
410-449/649-699 of the original 1587-line file: the preview
|
||||
``QStackedWidget`` + open/save/create/external dispatch. HTML/PPTX/Excel/
|
||||
PDF/office rendering lives in ``office_document_renderer.py``; the code
|
||||
editor widget lives in ``code_editor.py`` — both split out to keep this file
|
||||
under the 400-line cap.
|
||||
|
||||
**Closes the R06-T05 loop**: ``application/workspaces/file_workspace_service.
|
||||
py::FileWorkspaceService`` existed since R06 but had zero production call
|
||||
sites (confirmed by grep before this task — ``ui/folder_tab.py`` wrote files
|
||||
with raw ``Path.write_text`` instead). Every plain-text write this class does
|
||||
(``save``, ``create_new_file``, ``write_content``) now goes through it —
|
||||
same path-containment check, same auto ``mkdir``, and (new, from
|
||||
``infrastructure/filesystem/file_tools.py::write_file``) a Python-syntax
|
||||
warning on a bad ``.py`` write, which the original code never had. A ``.pptx``
|
||||
save still goes through ``core/pptx_edit.py`` directly — that's a binary
|
||||
package build, not a text write, and ``FileWorkspaceService`` has no opinion
|
||||
on it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QLabel, QPushButton, QScrollArea, QStackedWidget,
|
||||
QTextBrowser, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.application.workspaces import FileWorkspaceService
|
||||
from cowork_local.application.workspaces.file_preview_helpers import (
|
||||
is_probably_text, pptx_available, read_text,
|
||||
)
|
||||
from cowork_local.domain.workspaces.workspace_session import WorkspaceSession
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.presentation.folder.code_editor import CodeEditor
|
||||
from cowork_local.presentation.folder.office_document_renderer import OfficeDocumentRenderer
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.libreoffice_view import DOC_SUFFIXES
|
||||
|
||||
_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico"}
|
||||
_HTML_SUFFIXES = {".html", ".htm"}
|
||||
_PPTX_SUFFIXES = {".pptx"} # text-editable in-app via python-pptx (no PowerPoint)
|
||||
_EXCEL_SUFFIXES = {".xlsx", ".xlsm"} # viewed as a real table via openpyxl (no LibreOffice)
|
||||
_MAX_EDIT_BYTES = 2_000_000 # above this, view read-only / note only
|
||||
|
||||
|
||||
class DocumentPreviewManager(QWidget):
|
||||
"""View/edit pane: header (file name, Preview⇄Edit toggle, Save, Open
|
||||
externally) above a ``QStackedWidget`` that renders whichever preview a
|
||||
file's suffix calls for."""
|
||||
|
||||
status_message = Signal(str)
|
||||
ai_reset_requested = Signal() # a DIFFERENT file was opened by the user
|
||||
|
||||
def __init__(self, root: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._root = root
|
||||
self._file_service = FileWorkspaceService(WorkspaceSession.unscoped(Path(root)))
|
||||
self._office = OfficeDocumentRenderer(self)
|
||||
self._edit_kind: Optional[str] = None # None | "html" | "pptx"
|
||||
self._current_file: Optional[str] = None
|
||||
|
||||
rl = QVBoxLayout(self)
|
||||
rl.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
hdr = QHBoxLayout()
|
||||
self.file_label = QLabel("")
|
||||
self.file_label.setStyleSheet("font-weight:600;")
|
||||
self.file_label.setWordWrap(True)
|
||||
hdr.addWidget(self.file_label, 1)
|
||||
self.mode_btn = QPushButton() # Preview⇄Edit toggle (HTML / PPTX)
|
||||
self.mode_btn.setCheckable(True)
|
||||
self.mode_btn.clicked.connect(self._office.toggle_edit_mode)
|
||||
self.mode_btn.setVisible(False)
|
||||
hdr.addWidget(self.mode_btn)
|
||||
self.save_btn = QPushButton()
|
||||
self.save_btn.setIcon(icon("save"))
|
||||
self.save_btn.setObjectName("primary")
|
||||
self.save_btn.clicked.connect(self.save)
|
||||
self.save_btn.setVisible(False)
|
||||
hdr.addWidget(self.save_btn)
|
||||
self.ext_btn = QPushButton()
|
||||
self.ext_btn.setIcon(icon("upload"))
|
||||
self.ext_btn.clicked.connect(self.open_external)
|
||||
self.ext_btn.setVisible(False)
|
||||
hdr.addWidget(self.ext_btn)
|
||||
rl.addLayout(hdr)
|
||||
# Exposed so the shell can insert its own AI-panel toggle button into
|
||||
# this same header row (between mode_btn and save_btn, matching the
|
||||
# original single-class layout) without this class knowing the AI
|
||||
# panel exists.
|
||||
self.header_layout = hdr
|
||||
|
||||
self.stack = QStackedWidget()
|
||||
self._placeholder = QLabel("")
|
||||
self._placeholder.setObjectName("hint")
|
||||
self._placeholder.setAlignment(Qt.AlignCenter)
|
||||
self.stack.addWidget(self._placeholder) # 0
|
||||
|
||||
self.editor = CodeEditor() # 1
|
||||
self.stack.addWidget(self.editor)
|
||||
|
||||
self.web = QTextBrowser() # 2
|
||||
self.web.setOpenExternalLinks(True)
|
||||
self.stack.addWidget(self.web)
|
||||
|
||||
self.doc_view = QTextBrowser() # 3
|
||||
self.doc_view.setObjectName("docPreview")
|
||||
self.stack.addWidget(self.doc_view)
|
||||
|
||||
self._img_scroll = QScrollArea() # 4
|
||||
self._img_scroll.setWidgetResizable(True)
|
||||
self._img_label = QLabel("")
|
||||
self._img_label.setAlignment(Qt.AlignCenter)
|
||||
self._img_scroll.setWidget(self._img_label)
|
||||
self.stack.addWidget(self._img_scroll)
|
||||
|
||||
rl.addWidget(self.stack, 1)
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.save_btn.setText(tr("folder.save"))
|
||||
self.ext_btn.setText(tr("folder.open_external"))
|
||||
if not self._current_file:
|
||||
self._placeholder.setText(tr("folder.select_file"))
|
||||
self._retranslate_mode_btn()
|
||||
|
||||
def _retranslate_mode_btn(self) -> None:
|
||||
self.mode_btn.setText(tr("folder.edit") if not self.mode_btn.isChecked()
|
||||
else tr("folder.preview"))
|
||||
|
||||
# ---- public API used by the shell / AI panel --------------------------- #
|
||||
@property
|
||||
def current_file(self) -> Optional[str]:
|
||||
return self._current_file
|
||||
|
||||
@property
|
||||
def edit_kind(self) -> Optional[str]:
|
||||
return self._edit_kind
|
||||
|
||||
@property
|
||||
def root(self) -> str:
|
||||
return self._root
|
||||
|
||||
def set_root(self, root: str) -> None:
|
||||
self._root = root
|
||||
self._file_service = FileWorkspaceService(WorkspaceSession.unscoped(Path(root)))
|
||||
|
||||
def open_file(self, path: str, reset_ai: bool = True) -> None:
|
||||
# Switching to a DIFFERENT file starts a fresh AI-edit conversation
|
||||
# (reset_ai=False when the AI itself just CREATED this file — keep
|
||||
# that chat). Whether/how to reset is the AI panel's own business —
|
||||
# this class only announces that a genuine file switch happened.
|
||||
if reset_ai and path != self._current_file:
|
||||
self.ai_reset_requested.emit()
|
||||
self._current_file = path
|
||||
self.file_label.setText(path)
|
||||
suffix = Path(path).suffix.lower()
|
||||
self.mode_btn.setVisible(False)
|
||||
self.save_btn.setVisible(False)
|
||||
self.ext_btn.setVisible(False)
|
||||
self._edit_kind = None
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
size = 0
|
||||
|
||||
if suffix in _IMAGE_SUFFIXES:
|
||||
self._show_image(path)
|
||||
elif suffix in _HTML_SUFFIXES:
|
||||
self._office.show_html(path, mode_preview=True)
|
||||
elif suffix in _PPTX_SUFFIXES and pptx_available():
|
||||
self._office.show_pptx(path, mode_preview=True)
|
||||
elif suffix in _EXCEL_SUFFIXES:
|
||||
self._office.show_excel(path)
|
||||
elif suffix in DOC_SUFFIXES:
|
||||
self._office.show_document(path)
|
||||
elif size > _MAX_EDIT_BYTES or not is_probably_text(path):
|
||||
self._show_binary(path)
|
||||
else:
|
||||
self._show_code(path)
|
||||
|
||||
def ensure_editable_for_ai(self) -> bool:
|
||||
"""Make the current file editable in the code editor (switching an
|
||||
HTML preview to edit, or loading a text file). Returns False when
|
||||
there's no file open or it isn't a text/code file."""
|
||||
path = self._current_file
|
||||
if not path or not os.path.isfile(path):
|
||||
return False
|
||||
suffix = Path(path).suffix.lower()
|
||||
if suffix in _HTML_SUFFIXES:
|
||||
self._office.show_html(path, mode_preview=False)
|
||||
return True
|
||||
if suffix in _PPTX_SUFFIXES and pptx_available():
|
||||
self._office.show_pptx(path, mode_preview=False)
|
||||
return True
|
||||
if suffix in DOC_SUFFIXES or suffix in _IMAGE_SUFFIXES:
|
||||
return False
|
||||
if is_probably_text(path):
|
||||
self._show_code(path)
|
||||
return True
|
||||
return False
|
||||
|
||||
def save(self) -> None:
|
||||
if not self._current_file:
|
||||
return
|
||||
try:
|
||||
if self._edit_kind == "pptx":
|
||||
if not self._office.write_pptx(self.editor.toPlainText()):
|
||||
return
|
||||
else:
|
||||
self._write_plain_text(self._current_file, self.editor.toPlainText())
|
||||
self.status_message.emit(tr("folder.saved", name=Path(self._current_file).name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.status_message.emit(tr("folder.save_error", err=str(exc)))
|
||||
|
||||
def write_content(self, content: str, skip_image_confirm: bool = False) -> None:
|
||||
"""Persist AI-confirmed content to disk AND refresh the preview.
|
||||
pptx text is written back into the deck (no PowerPoint window)."""
|
||||
if not self._current_file:
|
||||
return
|
||||
try:
|
||||
if self._edit_kind == "pptx":
|
||||
if not self._office.write_pptx(content, skip_confirm=skip_image_confirm):
|
||||
return
|
||||
else:
|
||||
self._write_plain_text(self._current_file, content)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.status_message.emit(tr("folder.save_error", err=str(exc)))
|
||||
return
|
||||
suffix = Path(self._current_file).suffix.lower()
|
||||
if suffix in _HTML_SUFFIXES:
|
||||
self._office.show_html(self._current_file, mode_preview=True)
|
||||
elif suffix in _PPTX_SUFFIXES:
|
||||
self._office.show_pptx(self._current_file, mode_preview=True)
|
||||
|
||||
def create_new_file(self, target: str, content: str) -> Optional[str]:
|
||||
"""Create ``target`` (relative to the folder root) with ``content``
|
||||
and open it — like Cowork's save_file. Refuses paths escaping the
|
||||
root (enforced by ``FileWorkspaceService``/``WorkspaceSession``)."""
|
||||
root = os.path.normpath(self._root)
|
||||
dest = target if os.path.isabs(target) else os.path.join(root, target)
|
||||
dest = os.path.normpath(dest)
|
||||
try:
|
||||
if Path(dest).suffix.lower() in _PPTX_SUFFIXES and pptx_available():
|
||||
# A .pptx is a binary package — build a real deck from the
|
||||
# marker text (writing text straight to .pptx would corrupt it).
|
||||
from cowork_local.core import pptx_edit
|
||||
os.makedirs(os.path.dirname(dest) or root, exist_ok=True)
|
||||
pptx_edit.create_pptx_from_text(dest, content)
|
||||
else:
|
||||
self._write_plain_text(dest, content)
|
||||
except Exception as exc: # noqa: BLE001 - OS error, containment error, or pptx build failure
|
||||
self.status_message.emit(tr("folder.save_error", err=str(exc)))
|
||||
return None
|
||||
self.open_file(dest, reset_ai=False) # show the new file; keep the AI chat
|
||||
return dest
|
||||
|
||||
def open_external(self) -> None:
|
||||
if self._current_file:
|
||||
from cowork_local.ui.osutil import open_location
|
||||
open_location(self._current_file)
|
||||
|
||||
# ---- writes ------------------------------------------------------------- #
|
||||
def _write_plain_text(self, path: str, content: str) -> None:
|
||||
"""Write ``content`` to ``path`` (must resolve inside the current
|
||||
root) via ``FileWorkspaceService`` — same containment check, ``mkdir``
|
||||
and Python-syntax warning the agent's own ``write_file`` tool gets."""
|
||||
rel = os.path.relpath(path, self._root)
|
||||
result = self._file_service.write_file(rel, content)
|
||||
if not result.get("ok"):
|
||||
raise OSError(result.get("output") or "write failed")
|
||||
|
||||
# ---- simple renderers (HTML/PPTX/Excel/PDF/office live in
|
||||
# office_document_renderer.py) --------------------------------------------- #
|
||||
def _show_code(self, path: str) -> None:
|
||||
text = read_text(path)
|
||||
self.editor.setReadOnly(False)
|
||||
self.editor.load_file(path, text)
|
||||
self.save_btn.setVisible(True)
|
||||
self.stack.setCurrentWidget(self.editor)
|
||||
|
||||
def _show_image(self, path: str) -> None:
|
||||
from PySide6.QtGui import QPixmap
|
||||
pix = QPixmap(path)
|
||||
if pix.isNull():
|
||||
self._show_binary(path)
|
||||
return
|
||||
self._img_label.setPixmap(pix)
|
||||
self._img_label.resize(pix.size())
|
||||
self.ext_btn.setVisible(True)
|
||||
self.stack.setCurrentWidget(self._img_scroll)
|
||||
|
||||
def _show_binary(self, path: str) -> None:
|
||||
self._placeholder.setText(tr("folder.binary_file"))
|
||||
self.ext_btn.setVisible(True)
|
||||
self.stack.setCurrentWidget(self._placeholder)
|
||||
|
||||
|
||||
__all__ = ["DocumentPreviewManager"]
|
||||
@@ -0,0 +1,120 @@
|
||||
"""FolderTab shell (R08-T12) — assembles
|
||||
``workspace_file_tree.py::WorkspaceFileTree``,
|
||||
``document_preview_manager.py::DocumentPreviewManager`` and
|
||||
``ai_file_editor_dialog.py::AiFileEditorDialog`` behind the splitter/terminal
|
||||
layout that used to be inline in ``ui/folder_tab.py::FolderTab.__init__``
|
||||
(lines 238-384 of the original 1587-line file).
|
||||
|
||||
The AI-panel toggle button (``ai_btn``) lives here because it controls
|
||||
things two different children own: the panel's own visibility AND the
|
||||
content splitter's sizing — a genuine shell-level concern, not either
|
||||
child's.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSplitter, QVBoxLayout, QWidget
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.folder.ai_file_editor_dialog import AiFileEditorDialog
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class FolderTab(QWidget):
|
||||
"""Two-pane file explorer: directory tree + view/edit pane (+ collapsible
|
||||
AI-edit panel, + collapsible terminal)."""
|
||||
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext, cowork=None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._root = str(ctx.config.cowork_output_dir())
|
||||
|
||||
root_layout = QVBoxLayout(self)
|
||||
split = QSplitter(Qt.Horizontal)
|
||||
|
||||
self.tree = WorkspaceFileTree(self._root)
|
||||
split.addWidget(self.tree)
|
||||
|
||||
right = QWidget()
|
||||
rl = QVBoxLayout(right)
|
||||
rl.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.preview = DocumentPreviewManager(self._root)
|
||||
self.ai_panel = AiFileEditorDialog(ctx, self.preview, cowork=cowork)
|
||||
|
||||
self.ai_btn = QPushButton() # expand/collapse the AI-edit panel
|
||||
self.ai_btn.setIcon(icon("sparkle"))
|
||||
self.ai_btn.setCheckable(True)
|
||||
self.ai_btn.clicked.connect(self._toggle_ai_panel)
|
||||
# Same visual position as the original single-class header: between
|
||||
# the Preview⇄Edit toggle and Save (file_label=0, mode_btn=1).
|
||||
self.preview.header_layout.insertWidget(2, self.ai_btn)
|
||||
self.ai_panel.badge_changed.connect(self._on_ai_badge_changed)
|
||||
|
||||
content_split = QSplitter(Qt.Horizontal)
|
||||
content_split.addWidget(self.preview)
|
||||
content_split.addWidget(self.ai_panel)
|
||||
content_split.setStretchFactor(0, 1)
|
||||
content_split.setStretchFactor(1, 0)
|
||||
content_split.setSizes([700, 320])
|
||||
self._content_split = content_split
|
||||
self.ai_panel.setVisible(False) # default collapsed
|
||||
rl.addWidget(content_split, 1)
|
||||
|
||||
split.addWidget(right)
|
||||
split.setStretchFactor(0, 0)
|
||||
split.setStretchFactor(1, 1)
|
||||
split.setSizes([300, 800])
|
||||
root_layout.addWidget(split, 1)
|
||||
|
||||
# Terminal CLI below the file view — collapsible, default collapsed;
|
||||
# opening it points the shell at the current workspace folder.
|
||||
from cowork_local.ui.terminal_panel import TerminalPanel
|
||||
|
||||
self.terminal = TerminalPanel()
|
||||
self.terminal.set_cwd(self._root)
|
||||
self.terminal.expanded.connect(lambda: self.terminal.set_cwd(self._root))
|
||||
root_layout.addWidget(self.terminal)
|
||||
|
||||
self.tree.file_selected.connect(self.preview.open_file)
|
||||
self.preview.status_message.connect(self.status_message.emit)
|
||||
self.ai_panel.status_message.connect(self.status_message.emit)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
self._retranslate()
|
||||
|
||||
# ---- public API ---------------------------------------------------------
|
||||
def set_root(self, path: str) -> None:
|
||||
self.tree.set_root(path)
|
||||
# WorkspaceFileTree silently no-ops on an invalid path (same guard
|
||||
# the original single-class _root setter had) — mirror that here by
|
||||
# only propagating when the tree actually accepted it.
|
||||
if self.tree.root == path:
|
||||
self._root = path
|
||||
self.preview.set_root(path)
|
||||
self.terminal.set_cwd(path)
|
||||
|
||||
def _toggle_ai_panel(self) -> None:
|
||||
show = self.ai_btn.isChecked()
|
||||
self.ai_panel.setVisible(show)
|
||||
if show:
|
||||
self._content_split.setSizes([700, 320])
|
||||
self.ai_panel.on_opened()
|
||||
|
||||
def _on_ai_badge_changed(self, suffix: str) -> None:
|
||||
self.ai_btn.setText(tr("folder.ai_edit") + suffix)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self.tree.retranslate()
|
||||
self.preview.retranslate()
|
||||
self.ai_panel.retranslate()
|
||||
self.ai_btn.setText(tr("folder.ai_edit"))
|
||||
self.ai_btn.setToolTip(tr("folder.ai_edit_tooltip"))
|
||||
|
||||
|
||||
__all__ = ["FolderTab"]
|
||||
@@ -0,0 +1,269 @@
|
||||
"""OfficeDocumentRenderer — HTML/PPTX/Excel/PDF/office-doc preview for
|
||||
``document_preview_manager.py`` (R08-T12, split out to keep that file under
|
||||
the 400-line cap; originally ``ui/folder_tab.py``, lines 451-647/679-694 of
|
||||
the original 1587-line file).
|
||||
|
||||
A plain (non-Qt-widget) helper composed BY a ``DocumentPreviewManager``
|
||||
rather than a widget of its own: these renderers are tightly coupled to the
|
||||
manager's shared ``QStackedWidget``/toolbar/editor — genuinely one screen's
|
||||
internal state, not an independent concern — so this is a composition split
|
||||
to respect the line-count cap, the same way
|
||||
``presentation/scheduling/kanban_board_widget.py`` composes
|
||||
``TaskApplicationService`` rather than owning that logic inline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtWidgets import QTabWidget, QTableWidget, QTableWidgetItem
|
||||
|
||||
from cowork_local.application.workspaces.file_preview_helpers import read_text
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE
|
||||
|
||||
try:
|
||||
from PySide6.QtPdf import QPdfDocument # noqa: F401
|
||||
from PySide6.QtPdfWidgets import QPdfView # noqa: F401
|
||||
HAS_PDF = True
|
||||
except Exception: # pragma: no cover - QtPdf not bundled
|
||||
HAS_PDF = False
|
||||
|
||||
|
||||
class OfficeDocumentRenderer:
|
||||
"""Renders HTML/PPTX/Excel/PDF/office docs into ``owner.stack``.
|
||||
|
||||
``owner`` is the ``DocumentPreviewManager`` — this class reaches into
|
||||
``owner.stack``/``owner.editor``/``owner.mode_btn``/``owner.ext_btn``/
|
||||
``owner.save_btn``/``owner.doc_view``/``owner.web`` because those widgets
|
||||
are shared with the manager's simpler renderers (code/image/binary);
|
||||
duplicating them here would mean two stacked widgets fighting over which
|
||||
one is "the" preview.
|
||||
"""
|
||||
|
||||
def __init__(self, owner) -> None:
|
||||
self._owner = owner
|
||||
self._engine = None
|
||||
self._pdf_view = None
|
||||
self._pdf_doc = None
|
||||
self._pdf_tmp: Optional[str] = None
|
||||
self._pdf_cache: dict = {}
|
||||
self._convert_worker = None
|
||||
self._xlsx_view = None
|
||||
|
||||
def show_html(self, path: str, mode_preview: bool) -> None:
|
||||
o = self._owner
|
||||
o._edit_kind = "html"
|
||||
o.mode_btn.setVisible(True)
|
||||
o.mode_btn.setChecked(not mode_preview) # checked = Edit
|
||||
o._retranslate_mode_btn()
|
||||
if mode_preview:
|
||||
from PySide6.QtCore import QUrl
|
||||
html = read_text(path)
|
||||
engine = self._ensure_engine()
|
||||
if engine is not None:
|
||||
engine.setHtml(html, QUrl.fromLocalFile(path))
|
||||
o.stack.setCurrentWidget(engine)
|
||||
else:
|
||||
o.web.setHtml(html)
|
||||
o.stack.setCurrentWidget(o.web)
|
||||
o.save_btn.setVisible(False)
|
||||
else:
|
||||
o._show_code(path)
|
||||
|
||||
def show_pptx(self, path: str, mode_preview: bool) -> None:
|
||||
"""PPTX: Preview renders the slides (PDF via LibreOffice); Edit shows the
|
||||
deck's text (marker-delimited per box) in the editor."""
|
||||
o = self._owner
|
||||
o._edit_kind = "pptx"
|
||||
o.mode_btn.setVisible(True)
|
||||
o.mode_btn.setChecked(not mode_preview) # checked = Edit
|
||||
o._retranslate_mode_btn()
|
||||
o.ext_btn.setVisible(True)
|
||||
if mode_preview:
|
||||
self.show_document(path) # PDF render of the slides
|
||||
o.mode_btn.setVisible(True) # show_document doesn't touch it
|
||||
else:
|
||||
from cowork_local.core.pptx_edit import pptx_to_text
|
||||
try:
|
||||
text = pptx_to_text(path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
text = f"[could not read pptx text: {exc}]"
|
||||
o.editor.setReadOnly(False)
|
||||
o.editor.load_file(path + ".txt", text) # .txt → plain highlighting
|
||||
o.save_btn.setVisible(True)
|
||||
o.stack.setCurrentWidget(o.editor)
|
||||
|
||||
def _ensure_engine(self):
|
||||
"""Create the QWebEngineView on first HTML preview (only when WebEngine
|
||||
is safe to use); otherwise stay on the QTextBrowser fallback."""
|
||||
if not HAS_WEB_ENGINE:
|
||||
return None
|
||||
if self._engine is None:
|
||||
try:
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
self._engine = QWebEngineView()
|
||||
self._owner.stack.addWidget(self._engine)
|
||||
except Exception: # noqa: BLE001
|
||||
self._engine = None
|
||||
return self._engine
|
||||
|
||||
def toggle_edit_mode(self) -> None:
|
||||
o = self._owner
|
||||
if not o.current_file:
|
||||
return
|
||||
preview = not o.mode_btn.isChecked() # checked = Edit
|
||||
if o._edit_kind == "pptx":
|
||||
self.show_pptx(o.current_file, mode_preview=preview)
|
||||
else:
|
||||
self.show_html(o.current_file, mode_preview=preview)
|
||||
|
||||
def show_excel(self, path: str) -> None:
|
||||
"""View a spreadsheet as a real TABLE (openpyxl) — one tab per sheet."""
|
||||
o = self._owner
|
||||
o.ext_btn.setVisible(True)
|
||||
try:
|
||||
from cowork_local.core.deps import ensure_module
|
||||
ensure_module("openpyxl", "openpyxl")
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(path, read_only=True, data_only=True)
|
||||
except Exception: # noqa: BLE001 - no openpyxl / unreadable → try PDF/text
|
||||
self.show_document(path)
|
||||
return
|
||||
MAX_ROWS, MAX_COLS = 2000, 100
|
||||
if self._xlsx_view is None:
|
||||
self._xlsx_view = QTabWidget()
|
||||
o.stack.addWidget(self._xlsx_view)
|
||||
tabs = self._xlsx_view
|
||||
while tabs.count():
|
||||
w = tabs.widget(0); tabs.removeTab(0); w.deleteLater()
|
||||
try:
|
||||
for ws in wb.worksheets:
|
||||
rows = list(ws.iter_rows(max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True))
|
||||
ncols = max((len(r) for r in rows), default=0)
|
||||
table = QTableWidget(len(rows), ncols)
|
||||
table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
table.horizontalHeader().setVisible(False)
|
||||
for r, row in enumerate(rows):
|
||||
for c, val in enumerate(row):
|
||||
if val is not None:
|
||||
table.setItem(r, c, QTableWidgetItem(str(val)))
|
||||
table.resizeColumnsToContents()
|
||||
title = ws.title + (" (…)" if (ws.max_row or 0) > MAX_ROWS
|
||||
or (ws.max_column or 0) > MAX_COLS else "")
|
||||
tabs.addTab(table, title)
|
||||
finally:
|
||||
wb.close()
|
||||
if tabs.count() == 0:
|
||||
self.show_document(path)
|
||||
return
|
||||
o.stack.setCurrentWidget(tabs)
|
||||
|
||||
def show_document(self, path: str) -> None:
|
||||
"""Office docs + PDF are RENDERED via QtPdf — LibreOffice converts
|
||||
them to PDF first. Falls back to text extraction when QtPdf/
|
||||
LibreOffice aren't available."""
|
||||
o = self._owner
|
||||
o.ext_btn.setVisible(True)
|
||||
suffix = Path(path).suffix.lower()
|
||||
if not HAS_PDF:
|
||||
self.show_document_text(path)
|
||||
return
|
||||
if suffix == ".pdf":
|
||||
self._render_pdf(path)
|
||||
return
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
except OSError:
|
||||
mtime = 0
|
||||
cached = self._pdf_cache.get((path, mtime))
|
||||
if cached and os.path.exists(cached):
|
||||
self._render_pdf(cached)
|
||||
return
|
||||
from cowork_local.core.doc_extract import convert_to_pdf, find_soffice
|
||||
if not find_soffice() and os.name != "nt":
|
||||
self.show_document_text(path)
|
||||
return
|
||||
o.doc_view.setPlainText(tr("folder.converting"))
|
||||
o.stack.setCurrentWidget(o.doc_view)
|
||||
if self._pdf_tmp is None:
|
||||
import tempfile
|
||||
self._pdf_tmp = tempfile.mkdtemp(prefix="cowork_folder_pdf_")
|
||||
src, out_dir = path, self._pdf_tmp
|
||||
|
||||
def job(worker):
|
||||
return {"src": src, "mtime": mtime, "pdf": convert_to_pdf(src, out_dir)}
|
||||
|
||||
def done(result):
|
||||
if result.get("src") != o.current_file:
|
||||
return # user moved on to another file
|
||||
pdf = result.get("pdf")
|
||||
if pdf:
|
||||
self._pdf_cache[(result["src"], result["mtime"])] = pdf
|
||||
self._render_pdf(pdf)
|
||||
else:
|
||||
self.show_document_text(src)
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.finished_ok.connect(done)
|
||||
worker.failed.connect(lambda _e, p=src: self.show_document_text(p))
|
||||
self._convert_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _ensure_pdf_view(self):
|
||||
if not HAS_PDF:
|
||||
return None
|
||||
if self._pdf_view is None:
|
||||
from PySide6.QtPdf import QPdfDocument
|
||||
from PySide6.QtPdfWidgets import QPdfView
|
||||
self._pdf_doc = QPdfDocument(self._owner)
|
||||
self._pdf_view = QPdfView(self._owner)
|
||||
self._pdf_view.setDocument(self._pdf_doc)
|
||||
try:
|
||||
self._pdf_view.setPageMode(QPdfView.PageMode.MultiPage)
|
||||
self._pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
|
||||
except Exception: # noqa: BLE001 - enum names vary slightly across versions
|
||||
pass
|
||||
self._owner.stack.addWidget(self._pdf_view)
|
||||
return self._pdf_view
|
||||
|
||||
def _render_pdf(self, pdf_path: str) -> None:
|
||||
view = self._ensure_pdf_view()
|
||||
if view is None:
|
||||
self.show_document_text(pdf_path)
|
||||
return
|
||||
self._pdf_doc.load(pdf_path)
|
||||
self._owner.stack.setCurrentWidget(view)
|
||||
|
||||
def show_document_text(self, path: str) -> None:
|
||||
from cowork_local.core.doc_extract import extract_text
|
||||
o = self._owner
|
||||
try:
|
||||
text, note = extract_text(path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
text, note = None, str(exc)
|
||||
body = text if text else tr("folder.doc_unreadable", note=note or "?")
|
||||
o.doc_view.setPlainText(body)
|
||||
o.stack.setCurrentWidget(o.doc_view)
|
||||
|
||||
def write_pptx(self, content: str, skip_confirm: bool = False) -> bool:
|
||||
"""Write edited pptx text back into the deck. If the edit REPLACES any
|
||||
image, ask the user to confirm first. ``skip_confirm`` is used when
|
||||
the image was already confirmed (e.g. just generated). Returns False
|
||||
if the user declined."""
|
||||
from cowork_local.core import pptx_edit
|
||||
o = self._owner
|
||||
if not skip_confirm and pptx_edit.image_change_requested(content):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
ok = QMessageBox.question(o, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm"))
|
||||
if ok != QMessageBox.Yes:
|
||||
o.status_message.emit(tr("folder.ai_image_declined"))
|
||||
return False
|
||||
pptx_edit.apply_text_to_pptx(o.current_file, content)
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["OfficeDocumentRenderer", "HAS_PDF"]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""WorkspaceFileTree — the folder-picker bar + directory tree pane of the
|
||||
Folder Explorer (R08-T12, extracted from ``ui/folder_tab.py::FolderTab``,
|
||||
lines 264-293/386-408 of the original 1587-line file).
|
||||
|
||||
Owns navigation only: which root is browsed and which file was clicked.
|
||||
Rendering/editing the SELECTED file is
|
||||
``document_preview_manager.py::DocumentPreviewManager``'s job — this widget
|
||||
just emits :attr:`file_selected`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFileDialog, QFileSystemModel, QHBoxLayout, QLabel, QPushButton,
|
||||
QTreeView, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class WorkspaceFileTree(QWidget):
|
||||
"""The left-hand tree pane: a path bar (label + "open folder" button)
|
||||
above a ``QFileSystemModel``-backed ``QTreeView``."""
|
||||
|
||||
file_selected = Signal(str) # absolute path of the clicked file
|
||||
root_changed = Signal(str) # absolute path of the new root
|
||||
|
||||
def __init__(self, initial_root: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._root = initial_root
|
||||
|
||||
root_layout = QVBoxLayout(self)
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# The path IS the title of this screen, so it is written as one
|
||||
# rather than shown in a read-only text box that looks editable.
|
||||
# Full path on hover; the button still opens the folder picker.
|
||||
bar = QHBoxLayout()
|
||||
self.path_lbl = QLabel(self._root)
|
||||
self.path_lbl.setObjectName("folderTitle")
|
||||
self.path_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
self.path_lbl.setToolTip(self._root)
|
||||
self._open_btn = QPushButton()
|
||||
self._open_btn.setIcon(icon("folder"))
|
||||
self._open_btn.setObjectName("primary")
|
||||
self._open_btn.clicked.connect(self._pick_root)
|
||||
bar.addWidget(self.path_lbl, 1)
|
||||
bar.addWidget(self._open_btn)
|
||||
root_layout.addLayout(bar)
|
||||
|
||||
self.model = QFileSystemModel()
|
||||
self.model.setRootPath(self._root)
|
||||
self.tree = QTreeView()
|
||||
self.tree.setModel(self.model)
|
||||
self.tree.setRootIndex(self.model.index(self._root))
|
||||
for col in (1, 2, 3): # hide Size / Type / Date-modified columns
|
||||
self.tree.hideColumn(col)
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.clicked.connect(self._on_tree_clicked)
|
||||
root_layout.addWidget(self.tree, 1)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self._open_btn.setToolTip(tr("folder.path_placeholder"))
|
||||
self._open_btn.setText(tr("folder.open_folder"))
|
||||
|
||||
@property
|
||||
def root(self) -> str:
|
||||
return self._root
|
||||
|
||||
def set_root(self, path: str) -> None:
|
||||
p = str(path or "").strip()
|
||||
if not p or not os.path.isdir(p):
|
||||
return
|
||||
self._root = p
|
||||
self.path_lbl.setText(p)
|
||||
self.path_lbl.setToolTip(p)
|
||||
self.model.setRootPath(p)
|
||||
self.tree.setRootIndex(self.model.index(p))
|
||||
self.root_changed.emit(p)
|
||||
|
||||
def _pick_root(self) -> None:
|
||||
chosen = QFileDialog.getExistingDirectory(self, tr("folder.open_folder"), self._root)
|
||||
if chosen:
|
||||
self.set_root(chosen)
|
||||
|
||||
def _on_tree_clicked(self, index) -> None:
|
||||
path = self.model.filePath(index)
|
||||
if path and os.path.isfile(path):
|
||||
self.file_selected.emit(path)
|
||||
|
||||
|
||||
__all__ = ["WorkspaceFileTree"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""GraphRAG (Structure) screen, split into single-responsibility widgets
|
||||
(R08-T14): ``graph_scene_items``, ``graph_renderer``, ``graph_qa_widget``,
|
||||
assembled by the ``structure_graph_view`` shell."""
|
||||
@@ -0,0 +1,99 @@
|
||||
"""GraphMessagesView — the "Messages by day" tab of GraphRAG (R08-T14, split
|
||||
out of ``graph_renderer.py`` to keep that file under the 400-line cap;
|
||||
originally ``ui/structure_graph_view.py``, lines 427-497 of the original
|
||||
1035-line file: ``_on_view_tab``, ``_toggle_messages``, ``_reload_messages``,
|
||||
``_show_msg_json``).
|
||||
|
||||
A plain (non-Qt-widget) helper composed BY ``GraphRenderer`` — same
|
||||
composition-to-respect-the-line-cap pattern as
|
||||
``office_document_renderer.py``. Owns the ``QTreeWidget`` itself (built
|
||||
here, added to the owner's stack at construction) since nothing else needs
|
||||
it, but reaches into ``owner._stack``/``owner.web``/``owner.view``/
|
||||
``owner.active_project_id`` to switch the shared stack and scope by project.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
|
||||
class GraphMessagesView:
|
||||
def __init__(self, owner) -> None:
|
||||
self._owner = owner
|
||||
self.widget = QTreeWidget()
|
||||
self.widget.setHeaderHidden(True)
|
||||
self.widget.itemClicked.connect(self._show_msg_json)
|
||||
owner._stack.addWidget(self.widget)
|
||||
|
||||
def on_view_tab(self, index: int) -> None:
|
||||
"""Tab 0 = graph, tab 1 = messages."""
|
||||
o = self._owner
|
||||
if index == 1:
|
||||
self.reload()
|
||||
o._stack.setCurrentWidget(self.widget)
|
||||
else:
|
||||
o._stack.setCurrentWidget(o.web if o.web is not None else o.view)
|
||||
|
||||
def toggle(self) -> None:
|
||||
"""Kept for callers that still ask for a flip (e.g. keyboard paths)."""
|
||||
o = self._owner
|
||||
showing = o._stack.currentWidget() is self.widget
|
||||
o.view_tabs.setCurrentIndex(0 if showing else 1)
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Build the tree: day -> conversation. Click a conversation to see
|
||||
its messages as JSON. Scoped to the current project's history."""
|
||||
from cowork_local.core.history import list_conversations
|
||||
|
||||
o = self._owner
|
||||
self.widget.clear()
|
||||
pid = o.active_project_id or ""
|
||||
by_day: "OrderedDict[str, list]" = OrderedDict()
|
||||
try:
|
||||
convs = list_conversations(o.ctx.config.history_dir())
|
||||
except Exception: # noqa: BLE001
|
||||
convs = []
|
||||
for conv in convs:
|
||||
if pid and conv.get("project_id", "default") != pid:
|
||||
continue
|
||||
day = (conv.get("created") or "")[:10] or "—"
|
||||
by_day.setdefault(day, []).append(conv)
|
||||
if not by_day:
|
||||
self.widget.addTopLevelItem(QTreeWidgetItem([tr("structure.msgs_none")]))
|
||||
return
|
||||
for day in sorted(by_day, reverse=True):
|
||||
convs_d = by_day[day]
|
||||
day_item = QTreeWidgetItem([f"{day} ({len(convs_d)})"])
|
||||
for conv in convs_d:
|
||||
it = QTreeWidgetItem([conv.get("title", "(untitled)")])
|
||||
it.setData(0, Qt.UserRole, str(conv.get("path", "")))
|
||||
day_item.addChild(it)
|
||||
self.widget.addTopLevelItem(day_item)
|
||||
day_item.setExpanded(True)
|
||||
|
||||
def _show_msg_json(self, item, _col: int = 0) -> None:
|
||||
import html
|
||||
import json
|
||||
|
||||
from cowork_local.core.history import load_conversation
|
||||
path = item.data(0, Qt.UserRole)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
conv = load_conversation(path)
|
||||
payload = {"title": conv.get("title", ""), "created": conv.get("created", ""),
|
||||
"kind": conv.get("kind", ""), "project_id": conv.get("project_id", ""),
|
||||
"messages": conv.get("messages", [])}
|
||||
text = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
text = f"(could not read: {exc})"
|
||||
self._owner.raw_json_ready.emit(
|
||||
f'<pre style="white-space:pre-wrap; font-family:Consolas,monospace; '
|
||||
f'font-size:12px;">{html.escape(text)}</pre>')
|
||||
|
||||
|
||||
__all__ = ["GraphMessagesView"]
|
||||
@@ -0,0 +1,374 @@
|
||||
"""GraphQaWidget — the right-side "ask questions about this graph" panel of
|
||||
GraphRAG (R08-T14, extracted from
|
||||
``ui/structure_graph_view.py::StructureGraphView``, lines 288-334/342-360
|
||||
(partial)/647-663/704-955 of the original 1035-line file).
|
||||
|
||||
Reads the current graph and scene selection from a
|
||||
``graph_renderer.py::GraphRenderer`` instance passed at construction
|
||||
(``renderer.graph``, ``renderer.selected_node_data()``,
|
||||
``renderer.active_project_id``) and reacts to its
|
||||
``node_selected``/``graph_rendered``/``raw_json_ready`` signals — this class
|
||||
has no rendering state of its own, matching how
|
||||
``presentation/folder/ai_file_editor_dialog.py`` reads
|
||||
``DocumentPreviewManager`` rather than duplicating file state.
|
||||
|
||||
File-content extraction for grounding the answer goes through
|
||||
``application/workspaces/graph_index_service.py`` (R08-T14 also moved that
|
||||
out of this file, as pure Python — see its own docstring).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from PySide6.QtCore import QUrl, Signal
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QTextBrowser, QVBoxLayout, QWidget
|
||||
|
||||
from cowork_local.application.workspaces.graph_index_service import extract_file_contents
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.icons import collapse_right_icon, icon
|
||||
from cowork_local.ui.osutil import open_folder, open_location
|
||||
from cowork_local.ui.widgets import CollapseStrip
|
||||
|
||||
|
||||
class GraphQaWidget(QWidget):
|
||||
"""The collapsible pane itself (strip + header + ask row + detail
|
||||
browser) — the shell adds ONE widget to its splitter."""
|
||||
|
||||
status_message = Signal(str)
|
||||
collapse_changed = Signal(bool) # so the shell can resize its own splitter
|
||||
|
||||
def __init__(self, ctx, renderer, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._renderer = renderer
|
||||
self._ask_worker: Optional[AgentWorker] = None
|
||||
self._answer = ""
|
||||
self._detail_mode = "idle" # "answer" | "node" | "idle"
|
||||
self._extract_cache: dict = {}
|
||||
self._extract_dir = None
|
||||
|
||||
outer = QHBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(0)
|
||||
self._strip = CollapseStrip(tr("structure.expand_agent_tooltip"), expand_dir="left")
|
||||
self._strip.clicked.connect(lambda: self._set_collapsed(False))
|
||||
self._strip.setVisible(False)
|
||||
outer.addWidget(self._strip)
|
||||
|
||||
self._panel = QWidget()
|
||||
rl = QVBoxLayout(self._panel)
|
||||
rl.setContentsMargins(0, 0, 0, 0)
|
||||
ag_hdr = QHBoxLayout()
|
||||
self._collapse_btn = QPushButton()
|
||||
self._collapse_btn.setIcon(collapse_right_icon())
|
||||
self._collapse_btn.setFixedWidth(28)
|
||||
self._collapse_btn.clicked.connect(lambda: self._set_collapsed(True))
|
||||
self._label = QLabel()
|
||||
ag_hdr.addWidget(self._collapse_btn)
|
||||
ag_hdr.addWidget(self._label, 1)
|
||||
rl.addLayout(ag_hdr)
|
||||
|
||||
ask_row = QHBoxLayout()
|
||||
self.ask_edit = QLineEdit()
|
||||
self.ask_edit.returnPressed.connect(self._ask)
|
||||
self._ask_btn = QPushButton()
|
||||
self._ask_btn.setIcon(icon("chat"))
|
||||
self._ask_btn.setObjectName("primary")
|
||||
self._ask_btn.clicked.connect(self._ask)
|
||||
ask_row.addWidget(self.ask_edit, 1)
|
||||
ask_row.addWidget(self._ask_btn)
|
||||
rl.addLayout(ask_row)
|
||||
|
||||
self.detail = QTextBrowser()
|
||||
self.detail.setReadOnly(True)
|
||||
self.detail.setOpenLinks(False)
|
||||
self.detail.anchorClicked.connect(self._on_detail_link)
|
||||
rl.addWidget(self.detail, 1)
|
||||
outer.addWidget(self._panel, 1)
|
||||
|
||||
renderer.node_selected.connect(self._on_node_selected)
|
||||
renderer.graph_rendered.connect(self._preserve_answer)
|
||||
renderer.raw_json_ready.connect(self._show_raw_json)
|
||||
renderer.project_changed.connect(self.clear_extracts)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self._collapse_btn.setToolTip(tr("structure.collapse_agent_tooltip"))
|
||||
self._label.setText(tr("structure.agent_header"))
|
||||
self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder"))
|
||||
self._ask_btn.setText(tr("structure.ask"))
|
||||
if self._detail_mode == "idle":
|
||||
self.detail.setPlaceholderText(tr("structure.detail_placeholder"))
|
||||
self._strip.setToolTip(tr("structure.expand_agent_tooltip"))
|
||||
|
||||
# ---- collapse ------------------------------------------------------------- #
|
||||
def _set_collapsed(self, collapsed: bool) -> None:
|
||||
self._panel.setVisible(not collapsed)
|
||||
self._strip.setVisible(collapsed)
|
||||
self.collapse_changed.emit(collapsed)
|
||||
|
||||
# ---- reacting to the renderer ----------------------------------------------- #
|
||||
def _on_node_selected(self, data) -> None:
|
||||
self.detail.setPlainText(f"[{data.kind.upper()}] {data.label}\n\n{data.detail}")
|
||||
self._detail_mode = "node"
|
||||
|
||||
def _show_raw_json(self, html_text: str) -> None:
|
||||
self.detail.setHtml(html_text)
|
||||
|
||||
def _preserve_answer(self) -> None:
|
||||
if self._detail_mode == "answer" and self._answer.strip():
|
||||
self._render_answer()
|
||||
|
||||
# ---- Q&A -------------------------------------------------------------------- #
|
||||
@staticmethod
|
||||
def _graph_context(graph) -> str:
|
||||
from collections import defaultdict
|
||||
by_kind = defaultdict(list)
|
||||
for n in graph.nodes:
|
||||
by_kind[n.kind].append(n.label)
|
||||
lines = []
|
||||
for kind in ("file", "class", "function", "method", "module", "section"):
|
||||
items = by_kind.get(kind, [])
|
||||
if items:
|
||||
lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60]))
|
||||
id2label = {n.id: n.label for n in graph.nodes}
|
||||
rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}"
|
||||
for e in graph.edges[:140]]
|
||||
if rels:
|
||||
lines.append("Relationships (sample):\n" + "\n".join(rels))
|
||||
return "\n".join(lines)[:7000]
|
||||
|
||||
def _matched_sources(self, text: str):
|
||||
graph = self._renderer.graph
|
||||
if graph is None or not text:
|
||||
return []
|
||||
found: dict = {}
|
||||
for n in graph.nodes:
|
||||
if not n.path:
|
||||
continue
|
||||
label = n.label.rstrip("()")
|
||||
if len(label) < 3:
|
||||
continue
|
||||
if n.path not in found and re.search(rf"\b{re.escape(label)}\b", text):
|
||||
found[n.path] = (n.kind, n.label, n.detail or n.path)
|
||||
return sorted(found.items(), key=lambda kv: kv[1][1].lower())[:12]
|
||||
|
||||
def _linkify_files(self, text: str, sources) -> str:
|
||||
"""Turn file/entity NAMES mentioned in the answer into clickable
|
||||
links that open the file."""
|
||||
for path, (kind, label, rel) in sources:
|
||||
href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
|
||||
tokens = []
|
||||
base = Path(path).name
|
||||
if base and len(base) >= 3:
|
||||
tokens.append(base)
|
||||
lab = (label or "").rstrip("()").strip()
|
||||
if lab and lab != base and len(lab) >= 3:
|
||||
tokens.append(lab)
|
||||
for tok in tokens:
|
||||
esc = re.escape(tok)
|
||||
text = re.sub(rf"`{esc}`", f"[`{tok}`]({href})", text)
|
||||
text = re.sub(rf"(?<![\w`/\\.\]\)]){esc}(?![\w`\]\(])", f"[{tok}]({href})", text)
|
||||
return text
|
||||
|
||||
def _render_answer(self) -> None:
|
||||
text = self._answer
|
||||
sources = self._matched_sources(text)
|
||||
if sources:
|
||||
text = self._linkify_files(text, sources)
|
||||
lines = [text, "", "---", f"**{tr('structure.related_sources')}**"]
|
||||
for path, (kind, label, rel) in sources:
|
||||
href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
|
||||
kind_badge = f" [{kind.upper()}]" if kind not in ("file",) else ""
|
||||
lines.append(f"- **[{label}⧉]({href})**{kind_badge} — `{rel}`")
|
||||
text = "\n".join(lines)
|
||||
self.detail.setMarkdown(text)
|
||||
|
||||
def _on_detail_link(self, url: QUrl) -> None:
|
||||
if url.isLocalFile():
|
||||
p = url.toLocalFile()
|
||||
if Path(p).is_file():
|
||||
open_location(p)
|
||||
else:
|
||||
open_folder(p)
|
||||
|
||||
def _ask(self) -> None:
|
||||
question = self.ask_edit.text().strip()
|
||||
if not question:
|
||||
return
|
||||
from cowork_local.core.skills import parse_skill_command
|
||||
skill_prefix, question, info = parse_skill_command(question)
|
||||
if info is not None:
|
||||
self.detail.setMarkdown(info)
|
||||
self._detail_mode = "answer"
|
||||
self.ask_edit.clear()
|
||||
return
|
||||
graph = self._renderer.graph
|
||||
if graph is None:
|
||||
self.status_message.emit(tr("structure.scan_first"))
|
||||
return
|
||||
context = self._graph_context(graph)
|
||||
file_paths = self._candidate_file_paths()
|
||||
extract_cache = dict(self._extract_cache)
|
||||
extract_dir = str(self._extract_tmp_dir())
|
||||
self._answer = ""
|
||||
self._detail_mode = "answer"
|
||||
self.detail.setPlainText("…")
|
||||
self.ask_edit.clear()
|
||||
|
||||
active_project_id = self._renderer.active_project_id
|
||||
selected_nodes = self._renderer.selected_node_data()
|
||||
selected_context = self._selection_context(selected_nodes, graph)
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
provider = self.ctx.build_active_provider()
|
||||
system = self._system_prompt(skill_prefix, active_project_id)
|
||||
user_content = f"Graph context:\n{context}"
|
||||
if selected_context:
|
||||
user_content += f"\n\nSelected node(s) context (focus your answer on these):\n{selected_context}"
|
||||
content_block, new_cache = extract_file_contents(file_paths, extract_cache, extract_dir)
|
||||
if content_block:
|
||||
user_content += ("\n\nExtracted file contents (read these to answer about file "
|
||||
"details/data; cite the file path):\n" + content_block)
|
||||
user_content += f"\n\nQuestion: {question}"
|
||||
messages = [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
from cowork_local.core import agent_roles, audit_log
|
||||
ok = True
|
||||
try:
|
||||
provider.chat(messages, on_text=lambda t: worker.emit_event({"type": "text", "delta": t}),
|
||||
cancel=worker.is_cancelled)
|
||||
except Exception:
|
||||
ok = False
|
||||
raise
|
||||
finally:
|
||||
audit_log.record("tool_call", "graphrag_ask", ok, question[:500],
|
||||
agent_role=agent_roles.KNOWLEDGE)
|
||||
return {"extracted": new_cache}
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.event.connect(self._on_ask_event)
|
||||
w.finished_ok.connect(self._on_ask_done)
|
||||
w.failed.connect(lambda e: self.detail.setPlainText(f"Error: {e}"))
|
||||
self._ask_worker = w
|
||||
w.start()
|
||||
|
||||
@staticmethod
|
||||
def _selection_context(selected_nodes, graph) -> str:
|
||||
if not selected_nodes:
|
||||
return ""
|
||||
node_lines = []
|
||||
for nd in selected_nodes:
|
||||
node_lines.append(f"- {nd.label} (kind: {nd.kind}, path: {getattr(nd, 'path', '')})")
|
||||
if nd.detail:
|
||||
node_lines.append(f" detail: {nd.detail}")
|
||||
connected_ids = set()
|
||||
for nd in selected_nodes:
|
||||
for edge in graph.edges:
|
||||
if edge.source == nd.id:
|
||||
connected_ids.add(edge.target)
|
||||
elif edge.target == nd.id:
|
||||
connected_ids.add(edge.source)
|
||||
connected_nodes = [n for n in graph.nodes if n.id in connected_ids]
|
||||
if connected_nodes:
|
||||
node_lines.append("\nConnected nodes:")
|
||||
for cn in connected_nodes:
|
||||
node_lines.append(f"- {cn.label} (kind: {cn.kind})")
|
||||
return "\n".join(node_lines)
|
||||
|
||||
@staticmethod
|
||||
def _system_prompt(skill_prefix: str, active_project_id: str) -> str:
|
||||
system = ("You answer questions about a code/document knowledge graph. Use the provided "
|
||||
"graph context AND the extracted file contents to retrieve, synthesize and "
|
||||
"explain the answer. Be concise. Answer ONLY from what is provided (graph "
|
||||
"context + extracted contents) — never invent files, functions, or facts that "
|
||||
"aren't in it.\n\n"
|
||||
"EACH answer MUST include source citations so the user can verify where "
|
||||
"information came from. For every factual claim, file reference, or code "
|
||||
"element you mention, add a citation using this format:\n\n"
|
||||
" [source: filename.ext, line/section: XXX]\n\n"
|
||||
"Rules for citations:\n"
|
||||
" 1. Cite the EXACT file path from the graph context (use the path field).\n"
|
||||
" 2. For Python files: cite the function/class name and approximate line "
|
||||
" if available, or the module name.\n"
|
||||
" 3. For document files (.md, .txt): cite the section heading.\n"
|
||||
" 4. For JSON files: cite the key path (e.g. settings > database > host).\n"
|
||||
" 5. Place citations inline after the relevant sentence or fact.\n"
|
||||
" 6. At the end of your answer, add a '---' separator followed by a "
|
||||
" numbered **Sources cited:** section listing each unique source with "
|
||||
" its full path so the user can click to open it.\n\n"
|
||||
"Example citation format in text:\n"
|
||||
" The `process_data()` function handles CSV parsing "
|
||||
"[source: src/utils/parser.py, function: process_data].\n\n"
|
||||
"Example end-of-answer source list:\n"
|
||||
" ---\n"
|
||||
" **Sources cited:**\n"
|
||||
" 1. `src/utils/parser.py` — process_data function\n"
|
||||
" 2. `docs/api.md` — Section: Authentication\n")
|
||||
if skill_prefix:
|
||||
system += "\n\nFollow this skill:\n" + skill_prefix
|
||||
if active_project_id:
|
||||
from cowork_local.core.projects import load_project, project_context_text
|
||||
proj_ctx = project_context_text(load_project(active_project_id))
|
||||
if proj_ctx:
|
||||
system += "\n\n" + proj_ctx
|
||||
return system
|
||||
|
||||
def _on_ask_event(self, ev: dict) -> None:
|
||||
if ev.get("type") == "text":
|
||||
if self._answer == "":
|
||||
self.detail.clear()
|
||||
self._answer += ev.get("delta", "")
|
||||
self.detail.setPlainText(self._answer)
|
||||
|
||||
def _on_ask_done(self, result: dict) -> None:
|
||||
# Keep the (temporary) extracted content so repeated questions reuse
|
||||
# it without re-extracting — dropped when leaving the tab.
|
||||
if isinstance(result, dict):
|
||||
self._extract_cache.update(result.get("extracted", {}) or {})
|
||||
self._render_answer()
|
||||
|
||||
# ---- temporary file-content extraction for Q&A -------------------------------- #
|
||||
def _candidate_file_paths(self) -> List[str]:
|
||||
"""File paths to read for a question: the SELECTED file nodes if
|
||||
any, else every file node in the graph (capped downstream)."""
|
||||
graph = self._renderer.graph
|
||||
if graph is None:
|
||||
return []
|
||||
sel = self._renderer.selected_node_data()
|
||||
nodes = sel or list(graph.nodes)
|
||||
out, seen = [], set()
|
||||
for nd in nodes:
|
||||
p = (getattr(nd, "path", "") or "").strip()
|
||||
if p and p not in seen and Path(p).is_file():
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
def _extract_tmp_dir(self) -> Path:
|
||||
if self._extract_dir is None:
|
||||
import tempfile
|
||||
from cowork_local.config import CONFIG_DIR
|
||||
base = CONFIG_DIR / "tmp" / "graphrag_extract"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
self._extract_dir = Path(tempfile.mkdtemp(dir=str(base)))
|
||||
return self._extract_dir
|
||||
|
||||
def clear_extracts(self) -> None:
|
||||
"""Discard the temporary extracted content (on leaving the tab /
|
||||
switching project). The extraction is a scratch aid, never
|
||||
persisted."""
|
||||
self._extract_cache = {}
|
||||
d, self._extract_dir = self._extract_dir, None
|
||||
if d is not None:
|
||||
import shutil
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
__all__ = ["GraphQaWidget"]
|
||||
@@ -0,0 +1,391 @@
|
||||
"""GraphRenderer — the toolbar, scan/render pipeline, and graph/messages
|
||||
stack of GraphRAG (R08-T14, extracted from
|
||||
``ui/structure_graph_view.py::StructureGraphView``, lines 188-286/336-661/
|
||||
664-702 of the original 1035-line file — everything except the right-side
|
||||
Q&A panel, which is ``graph_qa_widget.py::GraphQaWidget``).
|
||||
|
||||
Talks to the Q&A panel only through signals (:attr:`node_selected`,
|
||||
:attr:`graph_rendered`) and a small read API (:attr:`graph`,
|
||||
:meth:`selected_node_data`, :attr:`active_project_id`) — this class has no
|
||||
idea ``GraphQaWidget`` exists, matching how
|
||||
``presentation/folder/document_preview_manager.py`` doesn't know about the
|
||||
AI-edit panel either.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import QPointF, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QFileDialog, QGraphicsScene, QHBoxLayout, QLineEdit,
|
||||
QPushButton, QStackedWidget, QTabBar, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.graph.graph_messages_view import GraphMessagesView
|
||||
from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class GraphRenderer(QWidget):
|
||||
status_message = Signal(str)
|
||||
node_selected = Signal(object) # a node's .data, whenever the scene selection changes
|
||||
graph_rendered = Signal() # a scan just finished rendering (fresh OR re-fit)
|
||||
raw_json_ready = Signal(str) # pre-formatted HTML for a clicked Messages entry
|
||||
project_changed = Signal() # a DIFFERENT project was selected (or cleared)
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._worker: Optional[AgentWorker] = None
|
||||
self._node_items: List[_Node] = []
|
||||
self._edge_items: List[_Edge] = []
|
||||
self._centroid = QPointF(0, 0)
|
||||
self._graph = None
|
||||
self._needs_scan = False
|
||||
self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite)
|
||||
self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox
|
||||
|
||||
self._rescan_timer = QTimer(self)
|
||||
self._rescan_timer.setSingleShot(True)
|
||||
self._rescan_timer.setInterval(1500)
|
||||
self._rescan_timer.timeout.connect(self._scan)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
bar = QHBoxLayout()
|
||||
self.path_edit = QLineEdit(str(ctx.config.cowork_output_dir()))
|
||||
self.path_edit.setPlaceholderText(tr("structure.path_placeholder"))
|
||||
self._pick_btn = QPushButton()
|
||||
self._pick_btn.setIcon(icon("folder"))
|
||||
self._pick_btn.setObjectName("primary")
|
||||
self._pick_btn.clicked.connect(self._pick)
|
||||
self.project_combo = QComboBox()
|
||||
self.project_combo.currentIndexChanged.connect(self._on_project_changed)
|
||||
self._scan_btn = QPushButton()
|
||||
self._scan_btn.setIcon(icon("search"))
|
||||
self._scan_btn.setObjectName("primary")
|
||||
self._scan_btn.clicked.connect(self._scan)
|
||||
self._export_btn = QPushButton()
|
||||
self._export_btn.setIcon(icon("upload"))
|
||||
self._export_btn.setObjectName("primary")
|
||||
self._export_btn.clicked.connect(self._export)
|
||||
bar.addWidget(self.path_edit, 1)
|
||||
bar.addWidget(self._pick_btn)
|
||||
bar.addWidget(self.project_combo)
|
||||
bar.addWidget(self._scan_btn)
|
||||
bar.addWidget(self._export_btn)
|
||||
root.addLayout(bar)
|
||||
self._refresh_project_combo()
|
||||
|
||||
# Đồ thị | Tin nhắn as a real pair of tabs.
|
||||
self.view_tabs = QTabBar()
|
||||
self.view_tabs.setObjectName("viewTabs")
|
||||
self.view_tabs.setDrawBase(False)
|
||||
self.view_tabs.setExpanding(False)
|
||||
self.view_tabs.addTab(icon("graph"), "")
|
||||
self.view_tabs.addTab(icon("message"), "")
|
||||
self.view_tabs.currentChanged.connect(self._on_view_tab)
|
||||
tab_row = QHBoxLayout()
|
||||
tab_row.setContentsMargins(0, 0, 0, 0)
|
||||
tab_row.addWidget(self.view_tabs)
|
||||
tab_row.addStretch(1)
|
||||
root.addLayout(tab_row)
|
||||
|
||||
self.scene = QGraphicsScene()
|
||||
self.scene.setBackgroundBrush(QColor(current_palette().bg))
|
||||
self.scene.selectionChanged.connect(self._on_selection)
|
||||
self.view = _GraphView(self.scene)
|
||||
|
||||
self._stack = QStackedWidget()
|
||||
self._stack.addWidget(self.view)
|
||||
self.web = None
|
||||
self._bridge = None
|
||||
self._channel = None
|
||||
root.addWidget(self._stack, 1)
|
||||
# "Messages" view: all conversation messages grouped BY DAY, shown as
|
||||
# JSON — a separate concern composed in (see graph_messages_view.py).
|
||||
self._messages = GraphMessagesView(self)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self.path_edit.setPlaceholderText(tr("structure.path_placeholder"))
|
||||
self._pick_btn.setText(tr("structure.browse"))
|
||||
self._scan_btn.setText(tr("structure.scan"))
|
||||
self._export_btn.setText(tr("structure.export_png"))
|
||||
self.view_tabs.setTabText(0, tr("structure.graph_btn"))
|
||||
self.view_tabs.setTabText(1, tr("structure.msgs_btn"))
|
||||
self.view_tabs.setTabToolTip(1, tr("structure.msgs_tooltip"))
|
||||
self.project_combo.setToolTip(tr("structure.project_tooltip"))
|
||||
self._refresh_project_combo()
|
||||
|
||||
# ---- public read API for GraphQaWidget ----------------------------------- #
|
||||
@property
|
||||
def graph(self):
|
||||
return self._graph
|
||||
|
||||
@property
|
||||
def active_project_id(self) -> str:
|
||||
return self._active_project_id
|
||||
|
||||
def selected_node_data(self) -> list:
|
||||
return [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
|
||||
|
||||
# ---- project sandbox lock ------------------------------------------------- #
|
||||
def _refresh_project_combo(self) -> None:
|
||||
from cowork_local.core.projects import list_projects
|
||||
|
||||
keep = self._active_project_id
|
||||
self.project_combo.blockSignals(True)
|
||||
self.project_combo.clear()
|
||||
self.project_combo.addItem(tr("structure.project_none"), "")
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects(), start=1):
|
||||
self.project_combo.addItem(p.name, p.project_id)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_combo.setCurrentIndex(row_to_select)
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
pid = project_id or ""
|
||||
self._refresh_project_combo()
|
||||
target = self.project_combo.findData(pid)
|
||||
if target < 0:
|
||||
target = 0
|
||||
if self.project_combo.currentIndex() == target:
|
||||
self._on_project_changed(target)
|
||||
else:
|
||||
self.project_combo.setCurrentIndex(target)
|
||||
|
||||
def _on_project_changed(self, _idx: int) -> None:
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
self._pick_btn.setEnabled(not locked)
|
||||
if locked:
|
||||
project = load_project(pid)
|
||||
if project is not None:
|
||||
self.path_edit.setText(str(project.workspace_dir()))
|
||||
if project_changed:
|
||||
self.project_changed.emit() # GraphQaWidget drops its temp extraction cache
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
|
||||
# ---- helpers ---------------------------------------------------------------- #
|
||||
def _pick(self) -> None:
|
||||
chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text())
|
||||
if chosen:
|
||||
self.path_edit.setText(chosen)
|
||||
|
||||
def schedule_rescan(self, path: str = "") -> None:
|
||||
if self._graph is None:
|
||||
self._needs_scan = True
|
||||
return
|
||||
self._rescan_timer.start()
|
||||
|
||||
# ---- Messages (by day, as JSON) — see graph_messages_view.py --------------- #
|
||||
def _on_view_tab(self, index: int) -> None:
|
||||
self._messages.on_view_tab(index)
|
||||
|
||||
def _toggle_messages(self) -> None:
|
||||
"""Kept for callers that still ask for a flip (e.g. keyboard paths)."""
|
||||
self._messages.toggle()
|
||||
|
||||
# ---- prewarm / scan lifecycle -------------------------------------------------- #
|
||||
def prewarm(self) -> None:
|
||||
"""Pay for the graph view before it is clicked on, not during."""
|
||||
if not HAS_WEB_ENGINE or self.web is not None:
|
||||
return
|
||||
self._ensure_web()
|
||||
if self._graph is None and self.path_edit.text().strip():
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
|
||||
def _ensure_web(self) -> None:
|
||||
if self.web is not None or not HAS_WEB_ENGINE:
|
||||
return
|
||||
from PySide6.QtWebChannel import QWebChannel
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
self.web = QWebEngineView()
|
||||
self.web.setHtml(
|
||||
f"<body style='margin:0;background:{current_palette().bg}'></body>")
|
||||
self._bridge = _Bridge()
|
||||
self._channel = QWebChannel()
|
||||
self._channel.registerObject("py", self._bridge)
|
||||
self.web.page().setWebChannel(self._channel)
|
||||
self._stack.addWidget(self.web)
|
||||
self._stack.setCurrentWidget(self.web)
|
||||
if self._graph is not None:
|
||||
self._render_d3()
|
||||
|
||||
def auto_scan_and_fit(self) -> None:
|
||||
self._ensure_web()
|
||||
if not self.path_edit.text().strip():
|
||||
return
|
||||
if self._worker is not None and self._worker.isRunning():
|
||||
self._fit()
|
||||
self.graph_rendered.emit()
|
||||
return
|
||||
if self._graph is not None and not self._needs_scan:
|
||||
self._fit()
|
||||
self.graph_rendered.emit()
|
||||
return
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
|
||||
# ---- scan --------------------------------------------------------------------- #
|
||||
def _scan(self) -> None:
|
||||
path = self.path_edit.text().strip() or str(Path.cwd())
|
||||
mode = "files"
|
||||
use_cmem = bool(self.ctx.config.codebase_memory.get("enabled"))
|
||||
cmem_bin = self.ctx.config.codebase_memory.get("binary_path", "")
|
||||
st = self.ctx.config.structure
|
||||
max_nodes = int(st.get("max_nodes", 500) or 0)
|
||||
max_edges = int(st.get("max_edges", 500) or 0)
|
||||
self._scan_seq += 1
|
||||
seq = self._scan_seq
|
||||
self.status_message.emit(tr("structure.scanning"))
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from cowork_local.core.structure_graph import (
|
||||
build_from_codebase_memory, build_from_directory, force_layout,
|
||||
)
|
||||
if use_cmem:
|
||||
from cowork_local.core.codebase_memory import CodebaseMemory
|
||||
mem = CodebaseMemory(cmem_bin)
|
||||
graph = (build_from_codebase_memory(mem, path, mode, max_nodes, max_edges)
|
||||
if mem.available else build_from_directory(path, mode, max_nodes, max_edges))
|
||||
else:
|
||||
graph = build_from_directory(path, mode, max_nodes, max_edges)
|
||||
pos = force_layout(graph)
|
||||
return {"graph": graph, "pos": pos, "seq": seq}
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(self._render)
|
||||
w.failed.connect(lambda e: self.status_message.emit(tr("structure.scan_error", err=e)))
|
||||
self._worker = w
|
||||
w.start()
|
||||
|
||||
def _render(self, result: dict) -> None:
|
||||
if result.get("seq") is not None and result["seq"] != self._scan_seq:
|
||||
return
|
||||
graph = result.get("graph")
|
||||
pos = result.get("pos", {})
|
||||
if graph is None:
|
||||
return
|
||||
self._graph = graph
|
||||
|
||||
self.scene.clear()
|
||||
self.scene.setBackgroundBrush(QColor(current_palette().bg))
|
||||
self._node_items = []
|
||||
self._edge_items = []
|
||||
degree = {n.id: 0 for n in graph.nodes}
|
||||
for e in graph.edges:
|
||||
if e.source in degree:
|
||||
degree[e.source] += 1
|
||||
if e.target in degree:
|
||||
degree[e.target] += 1
|
||||
items = {}
|
||||
sx = sy = 0.0
|
||||
for node in graph.nodes:
|
||||
radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0))))
|
||||
item = _Node(node, radius)
|
||||
x, y = pos.get(node.id, (0, 0))
|
||||
item.setPos(x, y)
|
||||
self.scene.addItem(item)
|
||||
items[node.id] = item
|
||||
self._node_items.append(item)
|
||||
sx += x
|
||||
sy += y
|
||||
for edge in graph.edges:
|
||||
a, b = items.get(edge.source), items.get(edge.target)
|
||||
if a and b:
|
||||
e = _Edge(a, b, getattr(edge, "type", ""))
|
||||
self.scene.addItem(e)
|
||||
self._edge_items.append(e)
|
||||
n = max(1, len(self._node_items))
|
||||
self._centroid = QPointF(sx / n, sy / n)
|
||||
self._fit()
|
||||
|
||||
if self.web is not None:
|
||||
self._render_d3()
|
||||
|
||||
note = tr("structure.truncated_note") if getattr(graph, "truncated", False) else ""
|
||||
self.status_message.emit(tr(
|
||||
"structure.graph_summary", nodes=len(graph.nodes), edges=len(graph.edges), note=note))
|
||||
self.graph_rendered.emit()
|
||||
|
||||
def _render_d3(self) -> None:
|
||||
if self.web is None or self._graph is None:
|
||||
return
|
||||
from cowork_local.core.d3_graph import build_html
|
||||
try:
|
||||
self.web.setHtml(build_html(self._graph), QUrl("https://cowork.local/"))
|
||||
except Exception as exc:
|
||||
self.status_message.emit(f"D3 view error: {exc}")
|
||||
|
||||
# ---- native interactions ------------------------------------------------------- #
|
||||
def _on_selection(self) -> None:
|
||||
for item in self.scene.selectedItems():
|
||||
if isinstance(item, _Node):
|
||||
self.node_selected.emit(item.data)
|
||||
return
|
||||
|
||||
def _fit(self) -> None:
|
||||
if self.web is not None and self._stack.currentWidget() is self.web:
|
||||
self.web.page().runJavaScript("window.fitGraph && window.fitGraph();")
|
||||
return
|
||||
rect = self.scene.itemsBoundingRect()
|
||||
if not rect.isNull():
|
||||
self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
|
||||
|
||||
def _export(self) -> None:
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)")
|
||||
if not path:
|
||||
return
|
||||
showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web)
|
||||
if showing_d3:
|
||||
self._export_d3_png(path)
|
||||
else:
|
||||
self._export_widget_grab(path)
|
||||
|
||||
def _export_d3_png(self, path: str) -> None:
|
||||
def on_result(data_url) -> None:
|
||||
if not isinstance(data_url, str) or "," not in data_url:
|
||||
self._export_widget_grab(path)
|
||||
return
|
||||
import base64
|
||||
try:
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(data_url.split(",", 1)[1]))
|
||||
self.status_message.emit(tr("structure.export_done", path=path))
|
||||
except (OSError, ValueError) as exc:
|
||||
self.status_message.emit(tr("structure.export_failed", err=str(exc)))
|
||||
self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result)
|
||||
|
||||
def _export_widget_grab(self, path: str) -> None:
|
||||
ok = self._stack.currentWidget().grab().save(path, "PNG")
|
||||
if ok:
|
||||
self.status_message.emit(tr("structure.export_done", path=path))
|
||||
else:
|
||||
self.status_message.emit(tr("structure.export_failed", err="grab() returned no image"))
|
||||
|
||||
|
||||
__all__ = ["GraphRenderer"]
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Native QGraphicsScene primitives for the fallback (non-WebEngine) graph
|
||||
view (R08-T14, split out of ``graph_renderer.py`` to keep it under the
|
||||
400-line cap; originally ``ui/structure_graph_view.py``, lines 65-186 of the
|
||||
original 1035-line file: ``_Bridge``, ``_Edge``, ``_Node``, ``_GraphView``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from PySide6.QtCore import QObject, QPointF, Qt, Slot
|
||||
from PySide6.QtGui import QBrush, QColor, QFont, QPen
|
||||
from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsSimpleTextItem, QGraphicsView
|
||||
|
||||
from cowork_local.core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.osutil import open_folder, open_location
|
||||
|
||||
|
||||
class _Bridge(QObject):
|
||||
"""Exposed to the D3 page so a Shift+click on a node can open its
|
||||
storage folder/link (local path or URL — see osutil.open_location)."""
|
||||
|
||||
@Slot(str)
|
||||
def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name
|
||||
if path:
|
||||
open_location(path)
|
||||
|
||||
|
||||
class _Edge(QGraphicsLineItem):
|
||||
def __init__(self, a: "_Node", b: "_Node", type_: str = ""):
|
||||
super().__init__()
|
||||
self.a, self.b = a, b
|
||||
self.type = type_
|
||||
# Colour the edge by its RELATIONSHIP type (contains/defines/method/…),
|
||||
# so the graph shows what each connection MEANS — falling back to the
|
||||
# source node's tint for any untyped edge.
|
||||
color = QColor(EDGE_KIND_COLORS.get(type_, "")) if type_ else QColor()
|
||||
if not color.isValid():
|
||||
color = a.brush().color().lighter(130)
|
||||
self._color = color
|
||||
self.setPen(QPen(color, 1.4))
|
||||
self.setZValue(-1)
|
||||
# A small label naming the relationship, shown at the edge midpoint.
|
||||
self._label = None
|
||||
if type_:
|
||||
self._label = QGraphicsSimpleTextItem(type_, self)
|
||||
self._label.setBrush(QBrush(color.lighter(140)))
|
||||
f = QFont()
|
||||
f.setPointSize(7)
|
||||
self._label.setFont(f)
|
||||
self._label.setZValue(0)
|
||||
a.edges.append(self)
|
||||
b.edges.append(self)
|
||||
self.adjust()
|
||||
|
||||
def adjust(self) -> None:
|
||||
pa, pb = self.a.scenePos(), self.b.scenePos()
|
||||
self.setLine(pa.x(), pa.y(), pb.x(), pb.y())
|
||||
if self._label is not None:
|
||||
br = self._label.boundingRect()
|
||||
self._label.setPos((pa.x() + pb.x()) / 2 - br.width() / 2,
|
||||
(pa.y() + pb.y()) / 2 - br.height() / 2)
|
||||
|
||||
|
||||
class _Node(QGraphicsEllipseItem):
|
||||
def __init__(self, data, radius: int):
|
||||
super().__init__(-radius, -radius, 2 * radius, 2 * radius)
|
||||
self.data = data
|
||||
self.edges = []
|
||||
tok = current_palette()
|
||||
# NODE_KIND_COLORS is a categorical data encoding (one hue per node
|
||||
# kind), not UI chrome — it stays fixed across themes on purpose so a
|
||||
# given kind is always the same colour. Only the chrome follows tokens.
|
||||
color = QColor(NODE_KIND_COLORS.get(data.kind, tok.text_muted))
|
||||
self.setBrush(QBrush(color))
|
||||
self.setPen(QPen(color.darker(160), 1.5))
|
||||
self.setFlags(
|
||||
QGraphicsEllipseItem.ItemIsMovable
|
||||
| QGraphicsEllipseItem.ItemIsSelectable
|
||||
| QGraphicsEllipseItem.ItemSendsGeometryChanges
|
||||
)
|
||||
self.setZValue(1)
|
||||
label = QGraphicsSimpleTextItem(data.label, self)
|
||||
label.setBrush(QBrush(QColor(tok.text)))
|
||||
label.setPos(radius + 3, -8)
|
||||
|
||||
def itemChange(self, change, value): # noqa: N802
|
||||
if change == QGraphicsEllipseItem.ItemPositionHasChanged:
|
||||
for edge in self.edges:
|
||||
edge.adjust()
|
||||
return super().itemChange(change, value)
|
||||
|
||||
|
||||
class _GraphView(QGraphicsView):
|
||||
def __init__(self, scene):
|
||||
super().__init__(scene)
|
||||
self.setDragMode(QGraphicsView.NoDrag)
|
||||
self._panning = False
|
||||
self._pan_start = QPointF()
|
||||
|
||||
def wheelEvent(self, e): # noqa: N802
|
||||
self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15,
|
||||
1.15 if e.angleDelta().y() > 0 else 1 / 1.15)
|
||||
|
||||
def mousePressEvent(self, e): # noqa: N802
|
||||
if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None:
|
||||
self._panning = True
|
||||
self._pan_start = e.position()
|
||||
self.setCursor(Qt.ClosedHandCursor)
|
||||
e.accept()
|
||||
return
|
||||
super().mousePressEvent(e)
|
||||
|
||||
def mouseMoveEvent(self, e): # noqa: N802
|
||||
if self._panning:
|
||||
delta = e.position() - self._pan_start
|
||||
self._pan_start = e.position()
|
||||
self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().value() - delta.x()))
|
||||
self.verticalScrollBar().setValue(int(self.verticalScrollBar().value() - delta.y()))
|
||||
e.accept()
|
||||
return
|
||||
super().mouseMoveEvent(e)
|
||||
|
||||
def mouseReleaseEvent(self, e): # noqa: N802
|
||||
if self._panning:
|
||||
self._panning = False
|
||||
self.setCursor(Qt.ArrowCursor)
|
||||
e.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(e)
|
||||
|
||||
def mouseDoubleClickEvent(self, e): # noqa: N802
|
||||
"""Double-click or Ctrl+click on a node opens its storage folder."""
|
||||
item = self.itemAt(e.pos())
|
||||
if isinstance(item, _Node) and getattr(item.data, "path", ""):
|
||||
open_folder(item.data.path)
|
||||
e.accept()
|
||||
return
|
||||
super().mouseDoubleClickEvent(e)
|
||||
|
||||
|
||||
__all__ = ["_Bridge", "_Edge", "_Node", "_GraphView"]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""StructureGraphView shell (R08-T14) — assembles
|
||||
``graph_renderer.py::GraphRenderer`` and
|
||||
``graph_qa_widget.py::GraphQaWidget`` behind the splitter that used to be
|
||||
inline in ``ui/structure_graph_view.py::StructureGraphView.__init__`` (lines
|
||||
188-343 of the original 1035-line file), and forwards the public methods
|
||||
``app.py``/``ui/workspace_tab.py`` call: ``schedule_rescan``,
|
||||
``auto_scan_and_fit``, ``set_project``, ``prewarm``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QSplitter, QVBoxLayout, QWidget
|
||||
|
||||
from cowork_local.i18n import on_language_changed
|
||||
from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.widgets import CollapseStrip
|
||||
|
||||
_COLLAPSED_SIZES_HINT = (840, 320) # matches the original single-class default
|
||||
|
||||
|
||||
class StructureGraphView(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
self.renderer = GraphRenderer(ctx)
|
||||
self.renderer.status_message.connect(self.status_message.emit)
|
||||
self.qa = GraphQaWidget(ctx, self.renderer)
|
||||
self.qa.status_message.connect(self.status_message.emit)
|
||||
self.qa.collapse_changed.connect(self._on_qa_collapse_changed)
|
||||
|
||||
self._split = QSplitter(Qt.Horizontal)
|
||||
self._split.addWidget(self.renderer)
|
||||
self._split.addWidget(self.qa)
|
||||
self._split.setChildrenCollapsible(False)
|
||||
self._split.setSizes(list(_COLLAPSED_SIZES_HINT))
|
||||
root.addWidget(self._split, 1)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self.renderer._retranslate()
|
||||
self.qa.retranslate()
|
||||
|
||||
def _on_qa_collapse_changed(self, collapsed: bool) -> None:
|
||||
strip_w = CollapseStrip.WIDTH + 2
|
||||
if collapsed:
|
||||
self.qa.setMaximumWidth(strip_w)
|
||||
sizes = self._split.sizes()
|
||||
if len(sizes) == 2:
|
||||
self._split.setSizes([max(1, sum(sizes) - strip_w), strip_w])
|
||||
else:
|
||||
self.qa.setMaximumWidth(16777215)
|
||||
self._split.setSizes(list(_COLLAPSED_SIZES_HINT))
|
||||
|
||||
# ---- public API (app.py / ui/workspace_tab.py) --------------------------- #
|
||||
def schedule_rescan(self, path: str = "") -> None:
|
||||
self.renderer.schedule_rescan(path)
|
||||
|
||||
def auto_scan_and_fit(self) -> None:
|
||||
self.renderer.auto_scan_and_fit()
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
self.renderer.set_project(project_id)
|
||||
|
||||
def prewarm(self) -> None:
|
||||
self.renderer.prewarm()
|
||||
|
||||
def hideEvent(self, e): # noqa: N802
|
||||
# Leaving the GraphRAG tab → drop the temporary extracted info.
|
||||
self.qa.clear_extracts()
|
||||
super().hideEvent(e)
|
||||
|
||||
|
||||
__all__ = ["StructureGraphView"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""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."""
|
||||
@@ -0,0 +1,196 @@
|
||||
"""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).
|
||||
|
||||
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`.
|
||||
|
||||
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
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPlainTextEdit, QPushButton, QTabWidget, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
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 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/CSV/JSON file."""
|
||||
|
||||
def __init__(self, ctx: AppContext, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._planner = AiTaskPlannerService(provider_factory=ctx.build_active_provider)
|
||||
self.created_tasks: 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)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
ws_row = QHBoxLayout()
|
||||
ws_row.addWidget(QLabel(tr("schedtask.f_workspace")))
|
||||
self.workspace_combo = QComboBox()
|
||||
self.workspace_combo.addItem(tr("schedtask.no_workspace"), "")
|
||||
for p in list_projects():
|
||||
self.workspace_combo.addItem(p.name, p.project_id)
|
||||
self.workspace_combo.setToolTip(tr("schedtask.hint_workspace"))
|
||||
ws_row.addWidget(self.workspace_combo, 1)
|
||||
root.addLayout(ws_row)
|
||||
self.tabs = QTabWidget()
|
||||
root.addWidget(self.tabs, 1)
|
||||
|
||||
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")))
|
||||
self.desc_edit = QPlainTextEdit()
|
||||
self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph"))
|
||||
self.desc_edit.setMaximumHeight(110)
|
||||
al.addWidget(self.desc_edit)
|
||||
# Attachments (files + links) — merged into every task this generates,
|
||||
# AND into the planning prompt so the AI knows they exist.
|
||||
attach_row = QHBoxLayout()
|
||||
self.ai_files_edit = QLineEdit()
|
||||
self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder"))
|
||||
ai_pick_btn = QPushButton(tr("schedtask.pick_files"))
|
||||
ai_pick_btn.setIcon(icon("folder"))
|
||||
ai_pick_btn.clicked.connect(self._ai_pick_files)
|
||||
attach_row.addWidget(self.ai_files_edit, 1)
|
||||
attach_row.addWidget(ai_pick_btn)
|
||||
al.addWidget(QLabel(tr("schedtask.f_files")))
|
||||
al.addLayout(attach_row)
|
||||
self.ai_links_edit = QLineEdit()
|
||||
self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder"))
|
||||
al.addWidget(QLabel(tr("schedtask.f_links")))
|
||||
al.addWidget(self.ai_links_edit)
|
||||
self.gen_btn = QPushButton(tr("schedtask.ai_generate"))
|
||||
self.gen_btn.setIcon(icon("sparkle"))
|
||||
self.gen_btn.setObjectName("primary")
|
||||
self.gen_btn.clicked.connect(self._generate)
|
||||
al.addWidget(self.gen_btn)
|
||||
al.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
||||
self.preview = QPlainTextEdit()
|
||||
self.preview.setReadOnly(True)
|
||||
al.addWidget(self.preview, 1)
|
||||
return ai_page
|
||||
|
||||
def _ai_pick_files(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files"))
|
||||
if files:
|
||||
existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()]
|
||||
self.ai_files_edit.setText("; ".join(existing + files))
|
||||
|
||||
def _attached_files(self) -> List[str]:
|
||||
return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()]
|
||||
|
||||
def _attached_links(self) -> List[str]:
|
||||
return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()]
|
||||
|
||||
def _generate(self) -> None:
|
||||
description = self.desc_edit.toPlainText().strip()
|
||||
if not description or self._worker is not None:
|
||||
return
|
||||
files, links = self._attached_files(), self._attached_links()
|
||||
self.gen_btn.setEnabled(False)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generating"))
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
full_desc = description
|
||||
if files or links:
|
||||
attach_note = "; ".join(files + links)
|
||||
full_desc += f"\n\n(Attached references available: {attach_note})"
|
||||
planned = self._planner.plan(
|
||||
full_desc, file_paths=files, links=links, cancel=worker.is_cancelled)
|
||||
return {"tasks": planned}
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(self._on_planned)
|
||||
w.failed.connect(self._on_failed)
|
||||
self._worker = w
|
||||
w.start()
|
||||
|
||||
def _on_planned(self, result: dict) -> None:
|
||||
self._worker = None
|
||||
self.gen_btn.setEnabled(True)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
||||
self._ai_planned = result.get("tasks") or []
|
||||
self._active_source = "ai"
|
||||
lines = []
|
||||
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", {})
|
||||
chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else ""
|
||||
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
||||
f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n"
|
||||
f" {t.get('description', '')[:150]}")
|
||||
self.preview.setPlainText("\n\n".join(lines))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._ai_planned))
|
||||
|
||||
def _on_failed(self, err: str) -> None:
|
||||
self._worker = None
|
||||
self.gen_btn.setEnabled(True)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
||||
self.preview.setPlainText(str(err))
|
||||
|
||||
# ---- 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 planned:
|
||||
t["project_id"] = project_id
|
||||
self.created_tasks = planned
|
||||
self.accept()
|
||||
|
||||
|
||||
__all__ = ["AiTaskCreatorDialog"]
|
||||
@@ -0,0 +1,148 @@
|
||||
"""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).
|
||||
|
||||
: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
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QLabel, QMessageBox, QPlainTextEdit, QPushButton,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
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 _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 cowork_local.core.task_excel import export_template
|
||||
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, tr("schedtask.export_template_btn"),
|
||||
"cowork_tasks_template.xlsx", "Excel (*.xlsx)")
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
export_template(path)
|
||||
open_path(str(Path(path).parent))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc))
|
||||
|
||||
def _pick_import_file(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from 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:
|
||||
try:
|
||||
self.planned = self._planner.import_file(path)
|
||||
except ValueError as exc:
|
||||
# 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}
|
||||
lines = []
|
||||
for i, t in enumerate(self.planned, 1):
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
||||
deps = t.get("dependency", {}).get("depends_on") or []
|
||||
dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else ""
|
||||
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
||||
f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}")
|
||||
self.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 .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"]
|
||||
@@ -0,0 +1,369 @@
|
||||
"""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).
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import QEvent, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||
QMenu, QMessageBox, QScrollArea, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
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):
|
||||
"""One status lane. Accepts drops from sibling columns; a drop means
|
||||
'move this task to my status'."""
|
||||
|
||||
task_dropped = Signal(str, str) # task_id, new_status
|
||||
|
||||
def __init__(self, status: str):
|
||||
super().__init__()
|
||||
self.status = status
|
||||
self.setDragDropMode(QAbstractItemView.DragDrop)
|
||||
self.setDefaultDropAction(Qt.MoveAction)
|
||||
# Shift/Ctrl-click several cards in the SAME column, then right-click
|
||||
# → "Delete N selected" to bulk-remove tasks instead of one at a time.
|
||||
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||
self.setWordWrap(True)
|
||||
# Cards wrap, so there is never anything to reach by scrolling
|
||||
# sideways — but QListWidget's own column hint runs 1-6px past the
|
||||
# viewport; 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
|
||||
|
||||
def dropEvent(self, event): # noqa: N802
|
||||
source = event.source()
|
||||
if isinstance(source, _KanbanColumn) and source is not self:
|
||||
item = source.currentItem()
|
||||
tid = item.data(Qt.UserRole) if item else None
|
||||
if tid:
|
||||
event.acceptProposedAction()
|
||||
self.task_dropped.emit(tid, self.status)
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
|
||||
class 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`.
|
||||
|
||||
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``.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
_LANE_FLOOR_CH = 8 # roughly eight characters of a task title, plus padding
|
||||
|
||||
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"]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""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
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView, QDialog, QDialogButtonBox, QLabel, QTableWidget,
|
||||
QTableWidgetItem, QVBoxLayout,
|
||||
)
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.osutil import open_path
|
||||
|
||||
|
||||
class RunHistoryDialog(QDialog):
|
||||
"""Run history of one task as a table (newest first): time, status, error;
|
||||
double-click a row to open that run's artifact folder."""
|
||||
|
||||
def __init__(self, task: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self._task = task
|
||||
self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}")
|
||||
self.resize(620, 380)
|
||||
root = QVBoxLayout(self)
|
||||
hint = QLabel(tr("schedtask.hist_hint"))
|
||||
hint.setObjectName("hint")
|
||||
root.addWidget(hint)
|
||||
|
||||
runs = list(reversed(task.get("runs", []) or []))
|
||||
self.table = QTableWidget(len(runs), 4)
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"),
|
||||
tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"),
|
||||
])
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
for row, run in enumerate(runs):
|
||||
cells = (
|
||||
run.get("finished_at", ""),
|
||||
str(run.get("status", "")),
|
||||
run.get("run_id", ""),
|
||||
(run.get("error") or "")[:200],
|
||||
)
|
||||
for col, text in enumerate(cells):
|
||||
item = QTableWidgetItem(str(text))
|
||||
if col == 0:
|
||||
item.setData(Qt.UserRole, run.get("run_id", ""))
|
||||
self.table.setItem(row, col, item)
|
||||
self.table.resizeColumnsToContents()
|
||||
self.table.horizontalHeader().setStretchLastSection(True)
|
||||
self.table.itemDoubleClicked.connect(self._open_artifact)
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
buttons.rejected.connect(self.reject)
|
||||
buttons.accepted.connect(self.accept)
|
||||
root.addWidget(buttons)
|
||||
|
||||
def _open_artifact(self, item: QTableWidgetItem) -> None:
|
||||
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 = 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"]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Small pieces shared across more than one presentation screen (EPIC R08).
|
||||
|
||||
Kept intentionally minimal — this is NOT a dumping ground for every reusable
|
||||
widget (``ui/icons.py``, ``ui/widgets.py``, ``ui/routing_toggle.py`` stay
|
||||
where they are; migrating those is a separate concern from R08-T12/T14).
|
||||
Only ``HAS_WEB_ENGINE`` lives here so far — it was one module-level flag
|
||||
duplicated between two God files being split by two different R08 tasks
|
||||
(``ui/folder_tab.py`` and ``ui/structure_graph_view.py``), and a shared
|
||||
constant beats one screen importing another screen's module.
|
||||
"""
|
||||
|
||||
from .web_engine_support import HAS_WEB_ENGINE
|
||||
|
||||
__all__ = ["HAS_WEB_ENGINE"]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""HAS_WEB_ENGINE — whether ``QWebEngineView`` is safe to construct here
|
||||
(R08-T12/T14, extracted from ``ui/structure_graph_view.py``, lines 25-48 of
|
||||
its original 1035-line version — the ONLY place this detection logic lived;
|
||||
``ui/folder_tab.py`` used to import it FROM that module via a try/except).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _frozen_onefile() -> bool:
|
||||
"""True only for a PyInstaller ONEFILE build. Onefile extracts itself to a
|
||||
temp dir (sys._MEIPASS under %TEMP%), where the QtWebEngine helper process
|
||||
can't run — creating a QWebEngineView hard-crashes the app (reported as
|
||||
"click the graph tab → app closes"). A ONEDIR build keeps _MEIPASS as the
|
||||
``_internal`` folder right next to the exe, where WebEngine works fine, so
|
||||
it keeps the full embedded D3/HTML view."""
|
||||
if not getattr(sys, "frozen", False):
|
||||
return False
|
||||
meipass = getattr(sys, "_MEIPASS", "")
|
||||
if not meipass:
|
||||
return False
|
||||
try:
|
||||
return Path(meipass).resolve().parent != Path(sys.executable).resolve().parent
|
||||
except OSError: # can't tell → play safe: use the native/fallback view
|
||||
return True
|
||||
|
||||
|
||||
try: # WebEngine + WebChannel are optional PySide6 add-ons
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401
|
||||
from PySide6.QtWebChannel import QWebChannel # noqa: F401
|
||||
HAS_WEB_ENGINE = not _frozen_onefile()
|
||||
except Exception: # pragma: no cover
|
||||
HAS_WEB_ENGINE = False
|
||||
|
||||
|
||||
__all__ = ["HAS_WEB_ENGINE"]
|
||||
@@ -9,8 +9,13 @@ laptop, in CI and on a machine with no API keys configured.
|
||||
* :class:`~tests.fakes.fake_tool_executor.FakeToolExecutor` - a scripted stand-in
|
||||
for the ``extra_executor`` callable that ``core.chat_agent.run_cowork`` routes
|
||||
MCP/connector tool calls to.
|
||||
* :class:`~tests.fakes.fake_clock.FakeClock` - a manually-fired stand-in for
|
||||
``platform/qt/qt_scheduler_clock.py::QtSchedulerClock`` (R07-T03), so
|
||||
``TaskScheduler`` dispatch logic can be tested tick-by-tick with no Qt
|
||||
event loop running.
|
||||
"""
|
||||
from .fake_clock import FakeClock
|
||||
from .fake_provider import FakeProvider, ScriptedTurn
|
||||
from .fake_tool_executor import FakeToolExecutor, ToolInvocation
|
||||
|
||||
__all__ = ["FakeProvider", "ScriptedTurn", "FakeToolExecutor", "ToolInvocation"]
|
||||
__all__ = ["FakeProvider", "ScriptedTurn", "FakeToolExecutor", "ToolInvocation", "FakeClock"]
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""FakeClock - offline stand-in for ``platform/qt/qt_scheduler_clock.py::
|
||||
QtSchedulerClock`` (R07-T03).
|
||||
|
||||
``TaskScheduler`` (``core/task_scheduler.py``) needs a clock that can
|
||||
``start(interval_ms, callback)`` / ``stop()`` / ``pump()``. In production
|
||||
that's a real ``QTimer``, which means testing dispatch logic (what runs, in
|
||||
what order, what gets re-armed) would otherwise require a live Qt event loop
|
||||
ticking every 30 seconds. This double satisfies the same duck-typed
|
||||
interface with manual control: ``fire()`` calls the scripted callback once,
|
||||
synchronously, on whichever thread the test is running on - no timers, no
|
||||
event loop, no waiting.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""Scriptable stand-in for :class:`QtSchedulerClock`.
|
||||
|
||||
Args:
|
||||
running: whether ``start()`` has been called and ``stop()`` hasn't
|
||||
since - a test can assert on this to check lifecycle wiring.
|
||||
pump_count: how many times ``pump()`` was called - lets a test on
|
||||
``TaskScheduler.stop()``'s drain loop assert the event loop was
|
||||
actually pumped while waiting for workers.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._callback: Optional[Callable[[], None]] = None
|
||||
self.interval_ms: Optional[int] = None
|
||||
self.running: bool = False
|
||||
self.pump_count: int = 0
|
||||
|
||||
def start(self, interval_ms: int, callback: Callable[[], None]) -> None:
|
||||
self.interval_ms = interval_ms
|
||||
self._callback = callback
|
||||
self.running = True
|
||||
|
||||
def stop(self) -> None:
|
||||
self.running = False
|
||||
|
||||
def pump(self) -> None:
|
||||
self.pump_count += 1
|
||||
|
||||
def fire(self) -> None:
|
||||
"""Test helper: manually trigger one tick, as if the interval had
|
||||
elapsed. A no-op when the clock isn't running (matches a real
|
||||
``QTimer`` never firing after ``stop()``)."""
|
||||
if self.running and self._callback is not None:
|
||||
self._callback()
|
||||
|
||||
|
||||
__all__ = ["FakeClock"]
|
||||
@@ -0,0 +1,142 @@
|
||||
"""EPIC R08-T13: TokenUsageCardWidget / UsageChartWidget / HabitsWidget /
|
||||
DashboardTab shell, real Qt offscreen.
|
||||
|
||||
``DashboardQueryService``'s own logic is unit tested (R08-T13, no Qt) in
|
||||
``tests/unit/test_dashboard_query_service.py``; this file proves the three
|
||||
widgets and the shell are actually wired to it and to each other (the period
|
||||
selector living on ``UsageChartWidget`` driving all three).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.core import usage_tracker as ut # noqa: E402
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def usage_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "usage"
|
||||
monkeypatch.setattr(ut, "USAGE_DIR", d)
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(qt_app, tmp_path):
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
def _write_event(usage_dir, day: date, **overrides):
|
||||
usage_dir.mkdir(parents=True, exist_ok=True)
|
||||
event = {
|
||||
"ts": f"{day.isoformat()}T10:00:00", "source": "cowork", "label": "Test chat",
|
||||
"provider": "anthropic", "model": "claude-sonnet-4-6",
|
||||
"in": 100, "out": 50, "cache": 0, "estimated": False,
|
||||
"account": "", "machine": "",
|
||||
}
|
||||
event.update(overrides)
|
||||
path = usage_dir / f"{day.isoformat()}.jsonl"
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event) + "\n")
|
||||
|
||||
|
||||
def test_dashboard_tab_builds_and_populates_cards(usage_dir, ctx):
|
||||
from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab
|
||||
|
||||
_write_event(usage_dir, date.today())
|
||||
tab = DashboardTab(ctx) # refresh() runs once at construction
|
||||
|
||||
assert "100" in tab.token_cards.card_in.value_lbl.text() \
|
||||
or tab.token_cards.card_in.value_lbl.text() # non-crashing, has SOME text
|
||||
assert tab.token_cards.card_total.value_lbl.text() != ""
|
||||
|
||||
|
||||
def test_period_navigation_on_chart_refreshes_the_whole_shell(usage_dir, ctx):
|
||||
from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab
|
||||
|
||||
_write_event(usage_dir, date.today())
|
||||
tab = DashboardTab(ctx)
|
||||
calls = []
|
||||
tab.refresh = lambda *a, orig=tab.refresh: (calls.append(1), orig(*a))[-1]
|
||||
|
||||
tab.chart._chart_prev()
|
||||
|
||||
assert calls == [1]
|
||||
|
||||
|
||||
def test_granularity_change_resets_offset_to_current(usage_dir, ctx):
|
||||
from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab
|
||||
from cowork_local.presentation.dashboard.usage_chart_widget import UsageChartWidget
|
||||
|
||||
tab = DashboardTab(ctx)
|
||||
tab.chart._chart_offset = -3
|
||||
|
||||
idx = tab.chart.gran_combo.findData("month")
|
||||
tab.chart.gran_combo.setCurrentIndex(idx)
|
||||
|
||||
assert tab.chart.chart_offset == 0
|
||||
|
||||
|
||||
def test_currency_change_persists_and_triggers_refresh(usage_dir, ctx):
|
||||
from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab
|
||||
|
||||
tab = DashboardTab(ctx)
|
||||
idx = tab.chart.currency_combo.findData("EUR") \
|
||||
if tab.chart.currency_combo.findData("EUR") >= 0 else 0
|
||||
seen = []
|
||||
tab.chart.currency_changed.connect(lambda: seen.append(1))
|
||||
|
||||
tab.chart.currency_combo.setCurrentIndex(idx)
|
||||
|
||||
if tab.chart.currency_combo.currentData() != "USD":
|
||||
assert seen == [1]
|
||||
assert ctx.config.data["usage"]["currency"] == tab.chart.currency_combo.currentData()
|
||||
|
||||
|
||||
def test_habits_widget_ai_analyze_noop_without_data(usage_dir, ctx):
|
||||
"""No events in range -> emits a status message instead of starting a
|
||||
background worker (matches the original _ai_analyze guard)."""
|
||||
from cowork_local.presentation.dashboard.habits_widget import HabitsWidget
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
|
||||
query = DashboardQueryService(ctx)
|
||||
widget = HabitsWidget(ctx, query)
|
||||
widget.refresh(date(2020, 1, 1), date(2020, 1, 1))
|
||||
messages = []
|
||||
widget.status_message.connect(messages.append)
|
||||
|
||||
widget._ai_analyze()
|
||||
|
||||
assert messages # "no data" status, no worker started
|
||||
assert widget._ai_worker is None
|
||||
|
||||
|
||||
def test_budget_apply_updates_budget_card(usage_dir, ctx):
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.presentation.dashboard.token_usage_card_widget import TokenUsageCardWidget
|
||||
|
||||
query = DashboardQueryService(ctx)
|
||||
widget = TokenUsageCardWidget(ctx, query)
|
||||
widget.budget_card.budget_spin.setValue(50.0)
|
||||
|
||||
widget._apply_budget()
|
||||
|
||||
status = query.budget_status()
|
||||
assert status is not None
|
||||
assert status["amount_usd"] == pytest.approx(50.0)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""EPIC R08-T12: WorkspaceFileTree / DocumentPreviewManager / FolderTab,
|
||||
real Qt offscreen.
|
||||
|
||||
Scope note: the AI-Edit panel's send -> plan -> edit -> apply pipeline
|
||||
(``ai_edit_pipeline.py``) runs on a real ``core.worker.AgentWorker`` QThread
|
||||
and had ZERO existing tests before this task (confirmed by grep — nothing
|
||||
under tests/ exercised ``ui/folder_tab.py``'s AI methods except the routing
|
||||
surface test, fixed alongside this task in
|
||||
``tests/integration/test_routing_surfaces.py``). Driving that full async
|
||||
pipeline end to end is out of scope here; what's covered is everything that
|
||||
doesn't need a live QThread: navigation, preview rendering, and — the
|
||||
concrete R08-T12 deliverable — that saves/creates actually go through
|
||||
``FileWorkspaceService`` (containment enforced, not just "writes a file").
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(qt_app, tmp_path: Path):
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root(tmp_path: Path) -> Path:
|
||||
d = tmp_path / "workspace"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
# ---- WorkspaceFileTree ----------------------------------------------------- #
|
||||
def test_workspace_file_tree_set_root_updates_label_and_model(qt_app, root):
|
||||
from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree
|
||||
|
||||
tree = WorkspaceFileTree(str(root))
|
||||
other = root.parent / "other-root"
|
||||
other.mkdir()
|
||||
|
||||
tree.set_root(str(other))
|
||||
|
||||
assert tree.root == str(other)
|
||||
assert tree.path_lbl.text() == str(other)
|
||||
|
||||
|
||||
def test_workspace_file_tree_ignores_an_invalid_root(qt_app, root):
|
||||
from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree
|
||||
|
||||
tree = WorkspaceFileTree(str(root))
|
||||
tree.set_root(str(root / "does-not-exist"))
|
||||
|
||||
assert tree.root == str(root) # unchanged
|
||||
|
||||
|
||||
def test_workspace_file_tree_click_emits_file_selected(qt_app, root):
|
||||
from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree
|
||||
|
||||
f = root / "a.txt"
|
||||
f.write_text("hello", encoding="utf-8")
|
||||
tree = WorkspaceFileTree(str(root))
|
||||
seen = []
|
||||
tree.file_selected.connect(seen.append)
|
||||
|
||||
index = tree.model.index(str(f))
|
||||
tree._on_tree_clicked(index)
|
||||
|
||||
assert seen and os.path.normpath(seen[0]) == os.path.normpath(str(f))
|
||||
|
||||
|
||||
# ---- DocumentPreviewManager ------------------------------------------------- #
|
||||
def test_open_file_renders_code_into_the_editor(qt_app, root):
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
f = root / "script.py"
|
||||
f.write_text("print('hi')\n", encoding="utf-8")
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
|
||||
preview.open_file(str(f))
|
||||
|
||||
assert preview.stack.currentWidget() is preview.editor
|
||||
assert preview.editor.toPlainText() == "print('hi')\n"
|
||||
assert preview.current_file == str(f)
|
||||
|
||||
|
||||
def test_open_file_on_a_binary_file_shows_the_placeholder(qt_app, root):
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
f = root / "data.bin"
|
||||
f.write_bytes(b"\x00\x01\x02binary")
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
|
||||
preview.open_file(str(f))
|
||||
|
||||
assert preview.stack.currentWidget() is preview._placeholder
|
||||
|
||||
|
||||
def test_save_writes_through_file_workspace_service(qt_app, root):
|
||||
"""The concrete R08-T12/R06-T05 deliverable: FileWorkspaceService (built
|
||||
at R06, unused until this task) is now the actual write path."""
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
f = root / "note.txt"
|
||||
f.write_text("old", encoding="utf-8")
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
preview.open_file(str(f))
|
||||
|
||||
preview.editor.setPlainText("new content")
|
||||
preview.save()
|
||||
|
||||
assert f.read_text(encoding="utf-8") == "new content"
|
||||
|
||||
|
||||
def test_create_new_file_writes_inside_root_via_file_workspace_service(qt_app, root):
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
|
||||
dest = preview.create_new_file("sub/new.md", "# hello")
|
||||
|
||||
assert dest == str(root / "sub" / "new.md")
|
||||
assert (root / "sub" / "new.md").read_text(encoding="utf-8") == "# hello"
|
||||
assert preview.current_file == dest # opened it, kept the AI chat (reset_ai=False)
|
||||
|
||||
|
||||
def test_create_new_file_rejects_a_path_escaping_the_root(qt_app, root):
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
messages = []
|
||||
preview.status_message.connect(messages.append)
|
||||
|
||||
dest = preview.create_new_file("../escaped.txt", "nope")
|
||||
|
||||
assert dest is None
|
||||
assert not (root.parent / "escaped.txt").exists()
|
||||
assert messages # a save_error status was emitted
|
||||
|
||||
|
||||
def test_write_content_persists_and_refreshes_html_preview(qt_app, root):
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
f = root / "page.html"
|
||||
f.write_text("<p>old</p>", encoding="utf-8")
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
preview.open_file(str(f)) # HTML opens in preview mode by default
|
||||
|
||||
preview.write_content("<p>new</p>")
|
||||
|
||||
assert f.read_text(encoding="utf-8") == "<p>new</p>"
|
||||
|
||||
|
||||
# ---- FolderTab shell -------------------------------------------------------- #
|
||||
def test_folder_tab_builds_and_wires_tree_to_preview(ctx, root):
|
||||
from cowork_local.presentation.folder.folder_tab import FolderTab
|
||||
|
||||
tab = FolderTab(ctx)
|
||||
tab.set_root(str(root))
|
||||
f = root / "hello.py"
|
||||
f.write_text("x = 1\n", encoding="utf-8")
|
||||
|
||||
tab.tree.file_selected.emit(str(f)) # simulate a tree click
|
||||
|
||||
assert tab.preview.current_file == str(f)
|
||||
assert tab.preview.editor.toPlainText() == "x = 1\n"
|
||||
|
||||
|
||||
def test_folder_tab_ai_panel_toggle_updates_button_badge(ctx, root):
|
||||
from cowork_local.presentation.folder.folder_tab import FolderTab
|
||||
|
||||
tab = FolderTab(ctx)
|
||||
assert tab.ai_panel.isHidden()
|
||||
|
||||
tab.ai_btn.setChecked(True)
|
||||
tab._toggle_ai_panel()
|
||||
|
||||
assert not tab.ai_panel.isHidden()
|
||||
@@ -0,0 +1,53 @@
|
||||
"""EPIC R07-T03: QtSchedulerClock against a REAL QTimer/event loop.
|
||||
|
||||
``tests/unit/test_task_scheduler_dispatch.py`` covers ``TaskScheduler``'s
|
||||
dispatch logic entirely through ``tests/fakes/fake_clock.py::FakeClock`` (no
|
||||
Qt at all — that's the whole point of the extraction). This file is the
|
||||
complement: it proves the adapter itself actually drives a real ``QTimer``
|
||||
and pumps a real event loop, offscreen, the way ``test_history_dir_race.py``
|
||||
proves ``ui/chat_panel.py``'s fix against real Qt rather than a double.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from PySide6.QtTest import QTest # noqa: E402
|
||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from cowork_local.infrastructure.qt.qt_scheduler_clock import QtSchedulerClock # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def test_start_fires_callback_on_the_real_qt_event_loop(qapp):
|
||||
clock = QtSchedulerClock()
|
||||
ticks = []
|
||||
clock.start(interval_ms=10, callback=lambda: ticks.append(1))
|
||||
try:
|
||||
QTest.qWait(200) # let the real QTimer fire a few times
|
||||
finally:
|
||||
clock.stop()
|
||||
assert len(ticks) >= 1
|
||||
|
||||
|
||||
def test_stop_prevents_further_callbacks(qapp):
|
||||
clock = QtSchedulerClock()
|
||||
ticks = []
|
||||
clock.start(interval_ms=10, callback=lambda: ticks.append(1))
|
||||
QTest.qWait(50)
|
||||
clock.stop()
|
||||
count_after_stop = len(ticks)
|
||||
QTest.qWait(100)
|
||||
assert len(ticks) == count_after_stop # no more callbacks after stop()
|
||||
|
||||
|
||||
def test_pump_processes_pending_events_without_raising(qapp):
|
||||
clock = QtSchedulerClock()
|
||||
clock.pump() # must not raise even with nothing pending
|
||||
@@ -6,7 +6,7 @@ of that algorithm now call it, on real (offscreen) widgets:
|
||||
|
||||
* ``ui/chat_panel.py::_apply_routing`` (Cowork)
|
||||
* ``ui/co4e_tab.py::_apply_co4e_routing`` (Co4E)
|
||||
* ``ui/folder_tab.py::_ai_apply_routing`` (AI-Edit)
|
||||
* ``presentation/folder/ai_edit_model_resolver.py::AiEditModelResolver.apply_routing`` (AI-Edit)
|
||||
|
||||
It also pins the Manual-mode handshake, including the field contract the
|
||||
existing confirm dialog reads off the decision - the one place where the new
|
||||
@@ -189,15 +189,20 @@ def test_co4e_returns_an_empty_model_when_routing_is_off(ctx):
|
||||
# AI-Edit
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_ai_edit_routes_on_its_own_surface_key(ctx):
|
||||
from cowork_local.ui.folder_tab import FolderTab
|
||||
"""R08-T12: the routing call this test pins moved from
|
||||
``ui/folder_tab.py::FolderTab._ai_apply_routing`` to
|
||||
``presentation/folder/ai_edit_model_resolver.py::AiEditModelResolver.
|
||||
apply_routing`` - same RoutingApplicationService call, same surface key,
|
||||
now independently testable without the whole FolderTab widget tree."""
|
||||
from cowork_local.presentation.folder.folder_tab import FolderTab
|
||||
|
||||
router = _install(ctx, "auto")
|
||||
tab = FolderTab(ctx)
|
||||
|
||||
tab._ai_apply_routing("rename this variable")
|
||||
tab.ai_panel.resolver.apply_routing("rename this variable")
|
||||
|
||||
assert router.surfaces == ["ai_edit"]
|
||||
assert (tab._ai_routed_provider, tab._ai_routed_model) == (
|
||||
assert (tab.ai_panel.resolver.routed_provider, tab.ai_panel.resolver.routed_model) == (
|
||||
"anthropic", "claude-sonnet-4-6")
|
||||
|
||||
|
||||
@@ -206,7 +211,7 @@ def test_ai_edit_pins_the_coding_task_type(ctx):
|
||||
classification entirely - the constraint has to survive the move into the
|
||||
shared service or it is silently dropped."""
|
||||
from cowork_local.core.routing.models import TaskType
|
||||
from cowork_local.ui.folder_tab import FolderTab
|
||||
from cowork_local.presentation.folder.folder_tab import FolderTab
|
||||
|
||||
seen: List[Any] = []
|
||||
|
||||
@@ -219,7 +224,7 @@ def test_ai_edit_pins_the_coding_task_type(ctx):
|
||||
_Recorder(), mode_reader=lambda _s: "auto")
|
||||
tab = FolderTab(ctx)
|
||||
|
||||
tab._ai_apply_routing("rename this variable")
|
||||
tab.ai_panel.resolver.apply_routing("rename this variable")
|
||||
|
||||
assert seen == [TaskType.CODING]
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""EPIC R08-T11: ScheduleTaskTab shell + KanbanBoardWidget, real Qt offscreen.
|
||||
|
||||
Drives the real widgets end to end (build -> refresh -> drag-drop rule via
|
||||
TaskApplicationService -> refresh) against a tmp_path task repository, the
|
||||
way ``test_history_dir_race.py`` proves R06-T04 against real Qt rather than
|
||||
a double. ``TaskApplicationService``'s own business rules are already unit
|
||||
tested (R07-T04); this file exists to prove the WIDGET is actually wired to
|
||||
that service, not to re-test the rules themselves.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.infrastructure.persistence.json.task_repository_impl import ( # noqa: E402
|
||||
TaskRepository,
|
||||
)
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(qt_app, tmp_path: Path):
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tasks_dir(tmp_path: Path) -> Path:
|
||||
d = tmp_path / "tasks"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
def test_schedule_task_tab_builds_and_refreshes_with_no_tasks(ctx, tasks_dir):
|
||||
from cowork_local.presentation.scheduling.schedule_task_tab import ScheduleTaskTab
|
||||
|
||||
tab = ScheduleTaskTab(ctx, scheduler=None, tasks_dir=tasks_dir)
|
||||
tab.refresh() # must not raise against an empty repo
|
||||
|
||||
assert tab.kanban.columns.keys() # 7 lanes were built
|
||||
|
||||
|
||||
def test_kanban_renders_a_task_into_its_status_lane(ctx, tasks_dir):
|
||||
from cowork_local.presentation.scheduling.kanban_board_widget import KanbanBoardWidget
|
||||
|
||||
repo = TaskRepository(tasks_dir)
|
||||
task = repo.create("My Task", task_type="cowork")
|
||||
task["status"] = "backlog"
|
||||
repo.save(task)
|
||||
|
||||
board = KanbanBoardWidget(ctx, tasks_dir=tasks_dir)
|
||||
board.refresh()
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
backlog_ids = [board.columns["backlog"].item(i).data(Qt.UserRole)
|
||||
for i in range(board.columns["backlog"].count())]
|
||||
assert task["task_id"] in backlog_ids
|
||||
|
||||
|
||||
def test_dropping_a_card_on_done_disables_its_schedule_through_the_real_widget(ctx, tasks_dir):
|
||||
"""Same rule TaskApplicationService.move_to_status covers at the unit
|
||||
level (R07-T04) — this proves the Kanban widget's drop handler actually
|
||||
calls it, end to end, with a real TaskRepository on disk."""
|
||||
from cowork_local.presentation.scheduling.kanban_board_widget import KanbanBoardWidget
|
||||
|
||||
repo = TaskRepository(tasks_dir)
|
||||
task = repo.create("Recurring", task_type="cowork")
|
||||
task["schedule"]["enabled"] = True
|
||||
task["schedule"]["run_at"] = "2026-08-28 09:00"
|
||||
task["schedule"]["repeat_type"] = "daily"
|
||||
task["status"] = "scheduled"
|
||||
repo.save(task)
|
||||
|
||||
board = KanbanBoardWidget(ctx, tasks_dir=tasks_dir)
|
||||
board.refresh()
|
||||
|
||||
board._on_task_dropped(task["task_id"], "done")
|
||||
|
||||
on_disk = repo.get(task["task_id"])
|
||||
assert on_disk["status"] == "done"
|
||||
assert on_disk["schedule"]["enabled"] is False
|
||||
|
||||
|
||||
def test_kanban_edit_requested_is_wired_to_the_shells_edit_task(ctx, tasks_dir, monkeypatch):
|
||||
"""Proves ScheduleTaskTab actually connects
|
||||
``kanban.edit_requested -> self._edit_task`` (not just that the Kanban
|
||||
widget emits the signal in isolation) by monkeypatching the dialog class
|
||||
``_edit_task`` opens and checking it was constructed for the right task."""
|
||||
import cowork_local.ui.task_editor_dialog as task_editor_dialog_module
|
||||
from cowork_local.presentation.scheduling.schedule_task_tab import ScheduleTaskTab
|
||||
|
||||
repo = TaskRepository(tasks_dir)
|
||||
task = repo.create("Editable")
|
||||
repo.save(task)
|
||||
|
||||
seen_task_ids = []
|
||||
|
||||
class _FakeDialog:
|
||||
def __init__(self, task, all_tasks, parent, ctx):
|
||||
seen_task_ids.append(task["task_id"] if task else None)
|
||||
self.edited_task = None
|
||||
|
||||
def exec(self):
|
||||
return False # Cancel — nothing further should happen
|
||||
|
||||
monkeypatch.setattr(task_editor_dialog_module, "TaskEditorDialog", _FakeDialog)
|
||||
|
||||
tab = ScheduleTaskTab(ctx, scheduler=None, tasks_dir=tasks_dir)
|
||||
tab.kanban.edit_requested.emit(task["task_id"])
|
||||
|
||||
assert seen_task_ids == [task["task_id"]]
|
||||
@@ -0,0 +1,162 @@
|
||||
"""EPIC R08-T14: GraphRenderer / GraphQaWidget / StructureGraphView shell,
|
||||
real Qt offscreen.
|
||||
|
||||
Scope note: a real directory SCAN (``GraphRenderer._scan``) runs on a
|
||||
``core.worker.AgentWorker`` QThread and had no existing test before this
|
||||
task either (grep confirms nothing under tests/ exercised
|
||||
``ui/structure_graph_view.py``). These tests drive ``_render()`` directly
|
||||
with a hand-built ``StructureGraph`` instead of a live scan — enough to
|
||||
prove the renderer <-> Q&A wiring (the actual R08-T14 deliverable) without
|
||||
needing a real codebase to walk.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.core.structure_graph import GEdge, GNode, StructureGraph # noqa: E402
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(qt_app, tmp_path):
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
def _fake_graph(tmp_path):
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text("def hello():\n pass\n", encoding="utf-8")
|
||||
nodes = [
|
||||
GNode(id="n1", label="mod.py", kind="file", detail="a module", path=str(f)),
|
||||
GNode(id="n2", label="hello", kind="function", detail="says hi", path=str(f)),
|
||||
]
|
||||
edges = [GEdge(source="n1", target="n2", type="defines")]
|
||||
return StructureGraph(nodes=nodes, edges=edges)
|
||||
|
||||
|
||||
def test_structure_graph_view_builds(ctx):
|
||||
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
|
||||
|
||||
view = StructureGraphView(ctx)
|
||||
assert view.renderer is not None
|
||||
assert view.qa is not None
|
||||
|
||||
|
||||
def test_render_populates_the_scene_and_emits_graph_rendered(ctx, tmp_path):
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
renderer = GraphRenderer(ctx)
|
||||
graph = _fake_graph(tmp_path)
|
||||
seen = []
|
||||
renderer.graph_rendered.connect(lambda: seen.append(1))
|
||||
|
||||
renderer._render({"graph": graph, "pos": {"n1": (0, 0), "n2": (100, 0)}, "seq": renderer._scan_seq})
|
||||
|
||||
assert renderer.graph is graph
|
||||
assert len(renderer._node_items) == 2
|
||||
assert seen == [1]
|
||||
|
||||
|
||||
def test_render_ignores_a_stale_scan_result(ctx, tmp_path):
|
||||
"""Only the LATEST scan's result is ever rendered (no stale overwrite)."""
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
renderer = GraphRenderer(ctx)
|
||||
first = _fake_graph(tmp_path)
|
||||
renderer._render({"graph": first, "pos": {}, "seq": renderer._scan_seq})
|
||||
renderer._scan_seq += 1 # a second scan started
|
||||
|
||||
stale = StructureGraph(nodes=[], edges=[])
|
||||
renderer._render({"graph": stale, "pos": {}, "seq": renderer._scan_seq - 1})
|
||||
|
||||
assert renderer.graph is first # stale result was dropped
|
||||
|
||||
|
||||
def test_node_selection_updates_the_qa_detail_panel(ctx, tmp_path):
|
||||
from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
renderer = GraphRenderer(ctx)
|
||||
graph = _fake_graph(tmp_path)
|
||||
renderer._render({"graph": graph, "pos": {"n1": (0, 0), "n2": (100, 0)}, "seq": renderer._scan_seq})
|
||||
qa = GraphQaWidget(ctx, renderer)
|
||||
|
||||
node = graph.nodes[1]
|
||||
renderer.node_selected.emit(node)
|
||||
|
||||
assert "hello" in qa.detail.toPlainText()
|
||||
assert "says hi" in qa.detail.toPlainText()
|
||||
|
||||
|
||||
def test_candidate_file_paths_uses_selected_nodes_when_present(ctx, tmp_path):
|
||||
from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
renderer = GraphRenderer(ctx)
|
||||
graph = _fake_graph(tmp_path)
|
||||
renderer._render({"graph": graph, "pos": {"n1": (0, 0), "n2": (100, 0)}, "seq": renderer._scan_seq})
|
||||
qa = GraphQaWidget(ctx, renderer)
|
||||
|
||||
# No selection -> every file node in the graph (both nodes share one
|
||||
# file here, so this also proves the path-dedup in _candidate_file_paths).
|
||||
assert qa._candidate_file_paths() == [graph.nodes[0].path]
|
||||
|
||||
renderer._node_items[0].setSelected(True)
|
||||
assert qa._candidate_file_paths() == [graph.nodes[0].path]
|
||||
|
||||
|
||||
def test_project_change_clears_the_qa_extraction_cache(ctx, tmp_path):
|
||||
from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
renderer = GraphRenderer(ctx)
|
||||
qa = GraphQaWidget(ctx, renderer)
|
||||
qa._extract_cache = {"some/path.py": "cached text"}
|
||||
|
||||
renderer.project_changed.emit()
|
||||
|
||||
assert qa._extract_cache == {}
|
||||
|
||||
|
||||
def test_qa_collapse_resizes_the_shells_splitter(ctx):
|
||||
"""Exact pixel width is Qt's splitter-layout arithmetic, not this code's
|
||||
concern - what matters is that collapsing narrows the QA pane a lot
|
||||
(down near the strip's width) while the strip itself becomes visible and
|
||||
the total splitter width is conserved."""
|
||||
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
|
||||
from cowork_local.ui.widgets import CollapseStrip
|
||||
|
||||
view = StructureGraphView(ctx)
|
||||
before = view._split.sizes()
|
||||
|
||||
view.qa._set_collapsed(True)
|
||||
|
||||
after = view._split.sizes()
|
||||
assert after[1] <= CollapseStrip.WIDTH + 2
|
||||
assert view.qa.maximumWidth() == CollapseStrip.WIDTH + 2
|
||||
assert sum(after) == sum(before) # total width conserved, just redistributed
|
||||
|
||||
|
||||
def test_hide_event_clears_extracts(ctx):
|
||||
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
|
||||
|
||||
view = StructureGraphView(ctx)
|
||||
view.qa._extract_cache = {"x": "y"}
|
||||
|
||||
from PySide6.QtGui import QHideEvent
|
||||
view.hideEvent(QHideEvent())
|
||||
|
||||
assert view.qa._extract_cache == {}
|
||||
@@ -0,0 +1,87 @@
|
||||
"""EPIC R07-T05: AiTaskPlannerService — AI-generate + import, no Qt, no network.
|
||||
|
||||
``core/ai_task_planner.py::plan_tasks`` and ``core/task_import.py::
|
||||
import_tasks`` are exercised through a FakeProvider / real tmp files rather
|
||||
than reimplemented — this service is a seam, not a new planner.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.application.scheduling.ai_task_planner_service import (
|
||||
AiTaskPlannerService,
|
||||
)
|
||||
from tests.fakes import FakeProvider, ScriptedTurn
|
||||
|
||||
_PLAN_REPLY = json.dumps({
|
||||
"tasks": [
|
||||
{"title": "Draft report", "description": "d", "task_type": "cowork",
|
||||
"priority": "medium", "schedule": {"enabled": False}}
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
def test_plan_uses_the_constructor_injected_provider_factory():
|
||||
provider = FakeProvider([ScriptedTurn(text=_PLAN_REPLY)])
|
||||
service = AiTaskPlannerService(provider_factory=lambda: provider)
|
||||
|
||||
tasks = service.plan("Write a weekly report")
|
||||
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["title"] == "Draft report"
|
||||
assert provider.call_count == 1
|
||||
|
||||
|
||||
def test_plan_prefers_an_explicit_provider_over_the_factory():
|
||||
factory_provider = FakeProvider([ScriptedTurn(text=_PLAN_REPLY)], strict=False)
|
||||
explicit_provider = FakeProvider([ScriptedTurn(text=_PLAN_REPLY)])
|
||||
service = AiTaskPlannerService(provider_factory=lambda: factory_provider)
|
||||
|
||||
service.plan("Write a weekly report", provider=explicit_provider)
|
||||
|
||||
assert explicit_provider.call_count == 1
|
||||
assert factory_provider.call_count == 0
|
||||
|
||||
|
||||
def test_plan_without_any_provider_raises_runtime_error():
|
||||
service = AiTaskPlannerService(provider_factory=None)
|
||||
with pytest.raises(RuntimeError):
|
||||
service.plan("Write a weekly report")
|
||||
|
||||
|
||||
def test_plan_stamps_attachments_onto_every_generated_task():
|
||||
two_tasks_reply = json.dumps({"tasks": [
|
||||
{"title": "A", "task_type": "cowork"},
|
||||
{"title": "B", "task_type": "cowork"},
|
||||
]})
|
||||
provider = FakeProvider([ScriptedTurn(text=two_tasks_reply)])
|
||||
service = AiTaskPlannerService(provider_factory=lambda: provider)
|
||||
|
||||
tasks = service.plan("do two things", file_paths=["a.txt"], links=["https://x"])
|
||||
|
||||
assert len(tasks) == 2
|
||||
for t in tasks:
|
||||
assert t["input"]["file_paths"] == ["a.txt"]
|
||||
assert t["input"]["links"] == ["https://x"]
|
||||
|
||||
|
||||
def test_import_file_delegates_to_core_task_import(tmp_path):
|
||||
csv_path = tmp_path / "tasks.csv"
|
||||
csv_path.write_text("title,task_type,priority\nMy Task,cowork,medium\n", encoding="utf-8")
|
||||
service = AiTaskPlannerService()
|
||||
|
||||
tasks = service.import_file(csv_path)
|
||||
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["title"] == "My Task"
|
||||
|
||||
|
||||
def test_import_file_raises_value_error_on_unsupported_extension(tmp_path):
|
||||
bogus = tmp_path / "tasks.txt"
|
||||
bogus.write_text("nope", encoding="utf-8")
|
||||
service = AiTaskPlannerService()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
service.import_file(bogus)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""EPIC R08-T13: DashboardQueryService — no Qt.
|
||||
|
||||
``core/usage_tracker.py::USAGE_DIR`` is a module-level constant (not
|
||||
injectable per-call except through an explicit ``directory=`` kwarg
|
||||
``load_events`` alone accepts) — this is a pre-existing testability gap the
|
||||
original ``ui/dashboard_tab.py`` also had (it had zero tests before this
|
||||
task). Monkeypatching the module attribute is what lets these tests write
|
||||
usage events without touching the real ``~/.cowork_local/usage/``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.config import AppConfig
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def usage_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "usage"
|
||||
monkeypatch.setattr(ut, "USAGE_DIR", d)
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(tmp_path):
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
def _write_event(usage_dir, day: date, **overrides):
|
||||
usage_dir.mkdir(parents=True, exist_ok=True)
|
||||
event = {
|
||||
"ts": f"{day.isoformat()}T10:00:00", "source": "cowork", "label": "Test chat",
|
||||
"provider": "anthropic", "model": "claude-sonnet-4-6",
|
||||
"in": 100, "out": 50, "cache": 0, "estimated": False,
|
||||
"account": "", "machine": "",
|
||||
}
|
||||
event.update(overrides)
|
||||
path = usage_dir / f"{day.isoformat()}.jsonl"
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event) + "\n")
|
||||
|
||||
|
||||
def test_period_range_is_inclusive_end(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
start, end = query.period_range("week", 0)
|
||||
assert start <= end
|
||||
|
||||
|
||||
def test_summary_aggregates_events_in_range(usage_dir, ctx):
|
||||
today = date.today()
|
||||
_write_event(usage_dir, today, **{"in": 100, "out": 50})
|
||||
_write_event(usage_dir, today - timedelta(days=400), **{"in": 999, "out": 999}) # out of range
|
||||
query = DashboardQueryService(ctx)
|
||||
|
||||
summary = query.summary(today, today)
|
||||
|
||||
assert len(summary["events"]) == 1
|
||||
assert summary["stats"]["in"] == 100
|
||||
assert summary["stats"]["out"] == 50
|
||||
assert summary["total_cost"] >= 0
|
||||
|
||||
|
||||
def test_summary_empty_range_has_no_events(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
summary = query.summary(date(2020, 1, 1), date(2020, 1, 1))
|
||||
assert summary["events"] == []
|
||||
assert summary["stats"]["total"] == 0
|
||||
|
||||
|
||||
def test_pricing_returns_a_dict_with_currency(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
pricing = query.pricing()
|
||||
assert "currency" in pricing
|
||||
|
||||
|
||||
def test_chart_series_returns_points_for_the_granularity(usage_dir, ctx):
|
||||
today = date.today()
|
||||
_write_event(usage_dir, today)
|
||||
query = DashboardQueryService(ctx)
|
||||
|
||||
pts = query.chart_series("week", 0, "tokens")
|
||||
|
||||
assert len(pts) == 7 # week view = 7 days
|
||||
assert all(isinstance(p, tuple) and len(p) == 2 for p in pts)
|
||||
|
||||
|
||||
def test_budget_status_none_when_no_budget_set(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
assert query.budget_status() is None
|
||||
|
||||
|
||||
def test_set_budget_then_status_reflects_it(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
query.set_budget(100.0, "USD")
|
||||
status = query.budget_status()
|
||||
assert status is not None
|
||||
assert status["amount_usd"] == pytest.approx(100.0)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""EPIC R08-T12: pure helpers moved out of ui/folder_tab.py into
|
||||
application/workspaces/ (file_preview_helpers.py, ai_edit_output.py) — no Qt,
|
||||
directly unit-testable, unlike when they lived as private module functions
|
||||
inside the Qt widget file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.application.workspaces.ai_edit_output import (
|
||||
parse_ai_output,
|
||||
split_code_block,
|
||||
)
|
||||
from cowork_local.application.workspaces.file_preview_helpers import (
|
||||
is_probably_text,
|
||||
read_text,
|
||||
)
|
||||
|
||||
|
||||
def test_read_text_returns_file_contents(tmp_path):
|
||||
f = tmp_path / "a.txt"
|
||||
f.write_text("hello", encoding="utf-8")
|
||||
assert read_text(str(f)) == "hello"
|
||||
|
||||
|
||||
def test_read_text_on_missing_file_returns_a_note_not_raise(tmp_path):
|
||||
result = read_text(str(tmp_path / "missing.txt"))
|
||||
assert "could not read file" in result
|
||||
|
||||
|
||||
def test_is_probably_text_true_for_utf8(tmp_path):
|
||||
f = tmp_path / "a.txt"
|
||||
f.write_text("hello world", encoding="utf-8")
|
||||
assert is_probably_text(str(f)) is True
|
||||
|
||||
|
||||
def test_is_probably_text_false_for_null_bytes(tmp_path):
|
||||
f = tmp_path / "a.bin"
|
||||
f.write_bytes(b"\x00\x01\x02")
|
||||
assert is_probably_text(str(f)) is False
|
||||
|
||||
|
||||
def test_split_code_block_extracts_fenced_block_and_summary():
|
||||
text = "Here is the change:\n\n```python\nprint('hi')\n```"
|
||||
content, summary = split_code_block(text)
|
||||
assert content == "print('hi')\n"
|
||||
assert summary == "Here is the change:"
|
||||
|
||||
|
||||
def test_split_code_block_no_fence_returns_none_and_full_text():
|
||||
content, summary = split_code_block("just prose, no code")
|
||||
assert content is None
|
||||
assert summary == "just prose, no code"
|
||||
|
||||
|
||||
def test_parse_ai_output_extracts_file_target():
|
||||
text = "FILE: new/thing.py\n```python\nx = 1\n```"
|
||||
target, content, summary, image_gens = parse_ai_output(text)
|
||||
assert target == "new/thing.py"
|
||||
assert content == "x = 1\n"
|
||||
assert image_gens == []
|
||||
|
||||
|
||||
def test_parse_ai_output_extracts_image_gen_directives():
|
||||
text = ("Adding an illustration.\n"
|
||||
"IMAGE_GEN: a red fox in a forest => assets/fox.png\n"
|
||||
"```html\n<img src='assets/fox.png'>\n```")
|
||||
target, content, summary, image_gens = parse_ai_output(text)
|
||||
assert image_gens == [("a red fox in a forest", "assets/fox.png")]
|
||||
assert "IMAGE_GEN" not in summary
|
||||
|
||||
|
||||
def test_parse_ai_output_no_directives_or_code_block():
|
||||
target, content, summary, image_gens = parse_ai_output("just an answer")
|
||||
assert target is None
|
||||
assert content is None
|
||||
assert image_gens == []
|
||||
assert summary == "just an answer"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""EPIC R08-T14: graph_index_service.py — pure helpers moved out of
|
||||
ui/structure_graph_view.py, no Qt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.application.workspaces.graph_index_service import extract_file_contents
|
||||
|
||||
|
||||
def test_extract_file_contents_reads_text_files(tmp_path):
|
||||
f = tmp_path / "a.py"
|
||||
f.write_text("print('hello')\n", encoding="utf-8")
|
||||
|
||||
block, cache = extract_file_contents([str(f)], {}, str(tmp_path))
|
||||
|
||||
assert "print('hello')" in block
|
||||
assert str(f) in cache
|
||||
assert cache[str(f)]
|
||||
|
||||
|
||||
def test_extract_file_contents_reuses_the_cache(tmp_path):
|
||||
f = tmp_path / "a.txt"
|
||||
f.write_text("original", encoding="utf-8")
|
||||
seed_cache = {str(f): "cached content, not re-read"}
|
||||
|
||||
block, cache = extract_file_contents([str(f)], seed_cache, str(tmp_path))
|
||||
|
||||
assert "cached content, not re-read" in block
|
||||
|
||||
|
||||
def test_extract_file_contents_skips_unreadable_paths_without_raising(tmp_path):
|
||||
missing = tmp_path / "does-not-exist.txt"
|
||||
block, cache = extract_file_contents([str(missing)], {}, str(tmp_path))
|
||||
assert block == ""
|
||||
|
||||
|
||||
def test_extract_file_contents_respects_max_total_budget(tmp_path):
|
||||
f1 = tmp_path / "a.txt"
|
||||
f1.write_text("x" * 100, encoding="utf-8")
|
||||
f2 = tmp_path / "b.txt"
|
||||
f2.write_text("y" * 100, encoding="utf-8")
|
||||
|
||||
block, cache = extract_file_contents([str(f1), str(f2)], {}, str(tmp_path), max_total=50)
|
||||
|
||||
assert len(block) < 250 # bounded, not both files in full
|
||||
@@ -0,0 +1,164 @@
|
||||
"""EPIC R07-T02: ScheduleCalculator — pure due-time/cron math.
|
||||
|
||||
This is the one piece of scheduling logic core/tasks.py's own docstring
|
||||
claimed was "Qt-free so it can be unit-tested headlessly" but had NO unit
|
||||
test at all before this task (confirmed by grepping tests/ for
|
||||
"schedule_calculator"/"compute_next_run"/"cron" — nothing matched). These
|
||||
tests exercise domain/tasks/schedule_calculator.py directly, with no Qt, no
|
||||
filesystem, and fake holiday/cron callables so the module stays provably
|
||||
zero-I/O.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.domain.tasks.schedule_calculator import ScheduleCalculator
|
||||
|
||||
|
||||
def _sched(**overrides):
|
||||
base = {
|
||||
"enabled": True,
|
||||
"run_at": "2026-08-24 09:00", # a Monday
|
||||
"repeat_type": "none",
|
||||
"cron_expression": None,
|
||||
"working_days_only": False,
|
||||
"skip_holidays": False,
|
||||
"holiday_country": "VN",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def _task(**sched_overrides):
|
||||
return {"status": "scheduled", "schedule": _sched(**sched_overrides)}
|
||||
|
||||
|
||||
class _FakeCron:
|
||||
"""A cron stub that fires every day at a fixed hour:minute — enough to
|
||||
exercise the cron branch without depending on core/cron.py::Cron."""
|
||||
|
||||
def __init__(self, expression: str):
|
||||
if expression == "bad":
|
||||
raise ValueError("bad cron expression")
|
||||
self.hour, self.minute = 10, 0
|
||||
|
||||
def next_after(self, after: datetime) -> datetime:
|
||||
candidate = after.replace(hour=self.hour, minute=self.minute, second=0, microsecond=0)
|
||||
if candidate <= after:
|
||||
from datetime import timedelta
|
||||
candidate += timedelta(days=1)
|
||||
return candidate
|
||||
|
||||
|
||||
def _is_weekend_holiday(date_, country):
|
||||
# A deterministic fake: only 2026-08-29 (a Saturday) counts as a holiday.
|
||||
return date_.isoformat() == "2026-08-29"
|
||||
|
||||
|
||||
def test_daily_advances_by_one_day():
|
||||
calc = ScheduleCalculator()
|
||||
task = _task(repeat_type="daily")
|
||||
nxt = calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0))
|
||||
assert nxt == datetime(2026, 8, 25, 9, 0)
|
||||
|
||||
|
||||
def test_weekly_advances_by_seven_days():
|
||||
calc = ScheduleCalculator()
|
||||
task = _task(repeat_type="weekly")
|
||||
nxt = calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0))
|
||||
assert nxt == datetime(2026, 8, 31, 9, 0)
|
||||
|
||||
|
||||
def test_monthly_clamps_day_to_shorter_month():
|
||||
calc = ScheduleCalculator()
|
||||
task = _task(run_at="2026-01-31 09:00", repeat_type="monthly")
|
||||
nxt = calc.compute_next_run(task, datetime(2026, 1, 31, 9, 0))
|
||||
assert nxt == datetime(2026, 2, 28, 9, 0) # Feb 2026 has 28 days
|
||||
|
||||
|
||||
def test_one_shot_repeat_type_none_has_no_next_run():
|
||||
calc = ScheduleCalculator()
|
||||
task = _task(repeat_type="none")
|
||||
assert calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0)) is None
|
||||
|
||||
|
||||
def test_cron_without_injected_factory_returns_none():
|
||||
"""No make_cron wired in -> a cron schedule simply never produces a next
|
||||
run, instead of crashing the caller."""
|
||||
calc = ScheduleCalculator()
|
||||
task = _task(repeat_type="cron", cron_expression="0 10 * * *")
|
||||
assert calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0)) is None
|
||||
|
||||
|
||||
def test_cron_uses_injected_factory():
|
||||
calc = ScheduleCalculator(make_cron=_FakeCron)
|
||||
task = _task(repeat_type="cron", cron_expression="0 10 * * *")
|
||||
nxt = calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0))
|
||||
assert nxt == datetime(2026, 8, 24, 10, 0)
|
||||
|
||||
|
||||
def test_malformed_cron_expression_returns_none_not_raise():
|
||||
calc = ScheduleCalculator(make_cron=_FakeCron)
|
||||
task = _task(repeat_type="cron", cron_expression="bad")
|
||||
assert calc.compute_next_run(task, datetime(2026, 8, 24, 9, 0)) is None
|
||||
|
||||
|
||||
def test_working_days_only_skips_weekend():
|
||||
calc = ScheduleCalculator()
|
||||
# 2026-08-28 is a Friday; +1 day (daily) would land on Saturday 08-29.
|
||||
task = _task(run_at="2026-08-28 09:00", repeat_type="daily", working_days_only=True)
|
||||
nxt = calc.compute_next_run(task, datetime(2026, 8, 28, 9, 0))
|
||||
assert nxt.weekday() < 5 # Monday 08-31, not the weekend
|
||||
|
||||
|
||||
def test_skip_holidays_uses_injected_is_holiday():
|
||||
calc = ScheduleCalculator(is_holiday=_is_weekend_holiday)
|
||||
# 2026-08-28 (Fri) + 1 day = 2026-08-29, which the fake marks a holiday.
|
||||
task = _task(run_at="2026-08-28 09:00", repeat_type="daily", skip_holidays=True)
|
||||
nxt = calc.compute_next_run(task, datetime(2026, 8, 28, 9, 0))
|
||||
assert nxt == datetime(2026, 8, 30, 9, 0) # skipped past the holiday
|
||||
|
||||
|
||||
def test_skip_holidays_without_injected_is_holiday_degrades_gracefully():
|
||||
calc = ScheduleCalculator() # no is_holiday wired in
|
||||
task = _task(run_at="2026-08-28 09:00", repeat_type="daily", skip_holidays=True)
|
||||
nxt = calc.compute_next_run(task, datetime(2026, 8, 28, 9, 0))
|
||||
assert nxt == datetime(2026, 8, 29, 9, 0) # no holiday check applied at all
|
||||
|
||||
|
||||
def test_shift_off_excluded_days_moves_weekend_forward():
|
||||
calc = ScheduleCalculator()
|
||||
sched = _sched(working_days_only=True)
|
||||
saturday = datetime(2026, 8, 29, 9, 0)
|
||||
shifted = calc.shift_off_excluded_days(saturday, sched)
|
||||
assert shifted.weekday() < 5
|
||||
assert shifted >= saturday
|
||||
|
||||
|
||||
def test_add_month_end_of_year_rolls_to_january():
|
||||
calc = ScheduleCalculator()
|
||||
assert calc.add_month(datetime(2026, 12, 15, 9, 0)) == datetime(2027, 1, 15, 9, 0)
|
||||
|
||||
|
||||
def test_due_tasks_filters_by_status_enabled_and_run_at():
|
||||
calc = ScheduleCalculator()
|
||||
now = datetime(2026, 8, 27, 12, 0)
|
||||
due_now = _task(run_at="2026-08-27 09:00")
|
||||
due_now["title"] = "due"
|
||||
future = _task(run_at="2026-08-28 09:00")
|
||||
future["title"] = "future"
|
||||
disabled = _task(run_at="2026-08-27 09:00", enabled=False)
|
||||
disabled["title"] = "disabled"
|
||||
not_scheduled = _task(run_at="2026-08-27 09:00")
|
||||
not_scheduled["title"] = "backlog"
|
||||
not_scheduled["status"] = "backlog"
|
||||
|
||||
result = calc.due_tasks([due_now, future, disabled, not_scheduled], now)
|
||||
assert [t["title"] for t in result] == ["due"]
|
||||
|
||||
|
||||
def test_due_tasks_empty_when_no_tasks():
|
||||
calc = ScheduleCalculator()
|
||||
assert calc.due_tasks([], datetime(2026, 8, 27, 12, 0)) == []
|
||||
@@ -0,0 +1,191 @@
|
||||
"""EPIC R07-T04: TaskApplicationService — CRUD + dispatch rules, no Qt.
|
||||
|
||||
Everything here used to be exercised only by driving the real
|
||||
``ui/schedule_task_tab.py`` widget (a QListWidget drag gesture, a QMenu
|
||||
click). These tests drive the same rules directly through the service.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.application.scheduling.task_application_service import (
|
||||
TaskApplicationService,
|
||||
)
|
||||
from cowork_local.infrastructure.persistence.json import TaskRepository
|
||||
|
||||
|
||||
def _service(tmp_path, run_now=None):
|
||||
return TaskApplicationService(TaskRepository(tmp_path), run_now=run_now)
|
||||
|
||||
|
||||
def _new_saved_task(repo: TaskRepository, **overrides):
|
||||
task = repo.create("T", **overrides)
|
||||
repo.save(task)
|
||||
return task
|
||||
|
||||
|
||||
def test_run_now_rejects_manual_task_type(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo, task_type="manual")
|
||||
service = TaskApplicationService(repo, run_now=lambda tid: True)
|
||||
|
||||
result = service.run_now(task["task_id"])
|
||||
|
||||
assert result.ok is False
|
||||
assert result.reason == "manual_task"
|
||||
|
||||
|
||||
def test_run_now_without_scheduler_wired_reports_no_scheduler(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo, task_type="cowork")
|
||||
service = TaskApplicationService(repo, run_now=None)
|
||||
|
||||
result = service.run_now(task["task_id"])
|
||||
|
||||
assert result.ok is False
|
||||
assert result.reason == "no_scheduler"
|
||||
|
||||
|
||||
def test_run_now_delegates_to_injected_scheduler(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo, task_type="cowork")
|
||||
seen = []
|
||||
service = TaskApplicationService(repo, run_now=lambda tid: seen.append(tid) or True)
|
||||
|
||||
result = service.run_now(task["task_id"])
|
||||
|
||||
assert result.ok is True
|
||||
assert seen == [task["task_id"]]
|
||||
|
||||
|
||||
def test_run_now_missing_task_reports_not_found(tmp_path):
|
||||
service = _service(tmp_path, run_now=lambda tid: True)
|
||||
result = service.run_now("does-not-exist")
|
||||
assert result.ok is False
|
||||
assert result.reason == "not_found"
|
||||
|
||||
|
||||
def test_duplicate_saves_a_copy_with_fresh_identity(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo, description="d")
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
dup = service.duplicate(task["task_id"])
|
||||
|
||||
assert dup is not None
|
||||
assert dup["task_id"] != task["task_id"]
|
||||
assert dup["description"] == "d"
|
||||
assert repo.get(dup["task_id"]) is not None # actually persisted, not just returned
|
||||
|
||||
|
||||
def test_toggle_pause_then_resume_goes_to_backlog(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo)
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
paused = service.toggle_pause(task["task_id"])
|
||||
assert paused["status"] == "paused"
|
||||
|
||||
resumed = service.toggle_pause(task["task_id"])
|
||||
assert resumed["status"] == "backlog"
|
||||
|
||||
|
||||
def test_delete_reports_whether_the_task_existed(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo)
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
assert service.delete(task["task_id"]) is True
|
||||
assert repo.get(task["task_id"]) is None
|
||||
assert service.delete(task["task_id"]) is False # already gone
|
||||
|
||||
|
||||
def test_bulk_delete_counts_only_tasks_that_existed(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
a = _new_saved_task(repo)
|
||||
b = _new_saved_task(repo)
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
count = service.bulk_delete([a["task_id"], b["task_id"], "ghost-id"])
|
||||
|
||||
assert count == 2
|
||||
assert repo.list() == []
|
||||
|
||||
|
||||
def test_move_to_status_running_task_is_blocked(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo)
|
||||
task["status"] = "running"
|
||||
repo.save(task)
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
result = service.move_to_status(task["task_id"], "backlog")
|
||||
|
||||
assert result.blocked is True
|
||||
assert repo.get(task["task_id"])["status"] == "running" # untouched
|
||||
|
||||
|
||||
def test_move_to_status_running_lane_dispatches_run_now(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo, task_type="cowork")
|
||||
seen = []
|
||||
service = TaskApplicationService(repo, run_now=lambda tid: seen.append(tid) or True)
|
||||
|
||||
result = service.move_to_status(task["task_id"], "running")
|
||||
|
||||
assert result.ran_now is True
|
||||
assert result.run_now_result.ok is True
|
||||
assert seen == [task["task_id"]]
|
||||
|
||||
|
||||
def test_move_to_status_done_disables_the_schedule(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo)
|
||||
task["schedule"]["enabled"] = True
|
||||
task["schedule"]["run_at"] = "2026-08-28 09:00"
|
||||
repo.save(task)
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
result = service.move_to_status(task["task_id"], "done")
|
||||
|
||||
assert result.task["status"] == "done"
|
||||
assert result.task["schedule"]["enabled"] is False
|
||||
assert repo.get(task["task_id"])["schedule"]["enabled"] is False
|
||||
|
||||
|
||||
def test_move_to_status_scheduled_without_run_at_needs_schedule(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo) # fresh task has schedule.run_at = None
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
result = service.move_to_status(task["task_id"], "scheduled")
|
||||
|
||||
assert result.needs_schedule is True
|
||||
assert repo.get(task["task_id"])["status"] == "scheduled"
|
||||
assert repo.get(task["task_id"])["schedule"]["enabled"] is False
|
||||
|
||||
|
||||
def test_move_to_status_scheduled_with_run_at_enables_it(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo)
|
||||
task["schedule"]["run_at"] = "2026-08-28 09:00"
|
||||
repo.save(task)
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
result = service.move_to_status(task["task_id"], "scheduled")
|
||||
|
||||
assert result.needs_schedule is False
|
||||
assert repo.get(task["task_id"])["schedule"]["enabled"] is True
|
||||
|
||||
|
||||
def test_move_to_status_plain_change(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = _new_saved_task(repo)
|
||||
service = TaskApplicationService(repo)
|
||||
|
||||
result = service.move_to_status(task["task_id"], "backlog")
|
||||
|
||||
assert result.task["status"] == "backlog"
|
||||
|
||||
|
||||
def test_move_to_status_missing_task_returns_none(tmp_path):
|
||||
service = _service(tmp_path)
|
||||
assert service.move_to_status("does-not-exist", "backlog") is None
|
||||
@@ -0,0 +1,70 @@
|
||||
"""EPIC R07-T01: TaskRepository + core/tasks.py::save_task atomic write.
|
||||
|
||||
The motivating bug: ``core/tasks.py::save_task`` used to
|
||||
``path.write_text(json.dumps(...))`` — two syscalls, no atomicity, same class
|
||||
of bug already fixed for projects/conversations at R06-T02. A failure between
|
||||
the write and the replace must never leave a half-written task JSON file on
|
||||
disk; that is the one property the crash-injection test exists to pin.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core.tasks import new_task
|
||||
from cowork_local.infrastructure.persistence.json import TaskRepository
|
||||
|
||||
|
||||
def test_task_repository_crud_round_trip(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = repo.create("My Task")
|
||||
repo.save(task)
|
||||
|
||||
assert [t["task_id"] for t in repo.list()] == [task["task_id"]]
|
||||
assert repo.get(task["task_id"])["title"] == "My Task"
|
||||
|
||||
task["title"] = "Renamed"
|
||||
repo.save(task)
|
||||
assert repo.get(task["task_id"])["title"] == "Renamed"
|
||||
|
||||
repo.delete(task["task_id"])
|
||||
assert repo.get(task["task_id"]) is None
|
||||
assert repo.list() == []
|
||||
|
||||
|
||||
def test_task_repository_duplicate_keeps_config_resets_identity(tmp_path):
|
||||
repo = TaskRepository(tmp_path)
|
||||
task = repo.create("Original", description="d")
|
||||
repo.save(task)
|
||||
|
||||
dup = repo.duplicate(task)
|
||||
repo.save(dup)
|
||||
|
||||
assert dup["task_id"] != task["task_id"]
|
||||
assert dup["description"] == "d"
|
||||
assert {t["task_id"] for t in repo.list()} == {task["task_id"], dup["task_id"]}
|
||||
|
||||
|
||||
def test_save_task_never_corrupts_existing_file_on_crash(tmp_path, monkeypatch):
|
||||
"""Same guarantee as ``test_atomic_write_and_repositories.py``'s crash
|
||||
test, exercised through ``core/tasks.py::save_task`` directly (not just
|
||||
through the repository) since that is the function every existing
|
||||
scheduler/executor call site still uses."""
|
||||
from cowork_local.core.tasks import save_task, task_path
|
||||
|
||||
task = new_task("Stable")
|
||||
save_task(task, tmp_path)
|
||||
|
||||
import cowork_local.infrastructure.persistence.json.atomic_write as mod
|
||||
|
||||
def boom(*_a, **_k):
|
||||
raise OSError("simulated crash between write and replace")
|
||||
|
||||
monkeypatch.setattr(mod.os, "replace", boom)
|
||||
task["title"] = "Corrupted?"
|
||||
with pytest.raises(OSError):
|
||||
save_task(task, tmp_path)
|
||||
|
||||
on_disk = json.loads(task_path(task["task_id"], tmp_path).read_text(encoding="utf-8"))
|
||||
assert on_disk["title"] == "Stable"
|
||||
@@ -0,0 +1,66 @@
|
||||
"""EPIC R07-T03: TaskScheduler <-> clock wiring, entirely through
|
||||
tests/fakes/fake_clock.py::FakeClock — no real QTimer, no Qt event loop.
|
||||
|
||||
Scope note: this only exercises the clock injection seam (start arms+starts
|
||||
the clock with `tick`, stop stops it), not the full dispatch/execution
|
||||
pipeline (`_start` -> `AgentWorker` -> `execute_task`), which needs a real
|
||||
``ctx``/provider and is exactly the kind of Qt-adjacent, thread-heavy path
|
||||
better left to an offscreen integration test if/when R08 touches this file
|
||||
again — recorded here rather than silently left untested.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.core.task_scheduler import TICK_MS, TaskScheduler
|
||||
from tests.fakes import FakeClock
|
||||
|
||||
|
||||
def test_start_arms_and_starts_the_injected_clock(tmp_path):
|
||||
clock = FakeClock()
|
||||
scheduler = TaskScheduler(ctx=None, tasks_dir=tmp_path, clock=clock)
|
||||
ticks = []
|
||||
# Instance-attribute override, set BEFORE start(): TaskScheduler.start()
|
||||
# reads `self.tick`, which Python resolves to this override rather than
|
||||
# the class method, so we can count calls without a real due task/ctx.
|
||||
scheduler.tick = lambda: ticks.append(1)
|
||||
|
||||
scheduler.start()
|
||||
|
||||
assert clock.running is True
|
||||
assert clock.interval_ms == TICK_MS
|
||||
assert ticks == [1] # the catch-up tick() call at startup
|
||||
|
||||
|
||||
def test_clock_fire_drives_another_tick(tmp_path):
|
||||
clock = FakeClock()
|
||||
scheduler = TaskScheduler(ctx=None, tasks_dir=tmp_path, clock=clock)
|
||||
ticks = []
|
||||
scheduler.tick = lambda: ticks.append(1)
|
||||
scheduler.start()
|
||||
|
||||
clock.fire()
|
||||
|
||||
assert ticks == [1, 1]
|
||||
|
||||
|
||||
def test_stop_stops_the_clock(tmp_path):
|
||||
clock = FakeClock()
|
||||
scheduler = TaskScheduler(ctx=None, tasks_dir=tmp_path, clock=clock)
|
||||
scheduler.tick = lambda: None
|
||||
scheduler.start()
|
||||
|
||||
scheduler.stop()
|
||||
|
||||
assert clock.running is False
|
||||
|
||||
|
||||
def test_fire_after_stop_does_not_call_tick(tmp_path):
|
||||
clock = FakeClock()
|
||||
scheduler = TaskScheduler(ctx=None, tasks_dir=tmp_path, clock=clock)
|
||||
ticks = []
|
||||
scheduler.tick = lambda: ticks.append(1)
|
||||
scheduler.start()
|
||||
scheduler.stop()
|
||||
|
||||
clock.fire()
|
||||
|
||||
assert ticks == [1] # only the startup catch-up tick, nothing after stop
|
||||
@@ -1,438 +0,0 @@
|
||||
"""Dashboard tab — token usage & cost overview.
|
||||
|
||||
Top: header (period filter + display-currency picker + refresh), then stat
|
||||
cards (total, input, output, cache tokens, and cost per bucket). Unit prices
|
||||
still come from Monitoring's model pricing table (same ``usage.*`` config keys
|
||||
— both screens always agree); the currency picker itself lives HERE, beside
|
||||
refresh. Bottom: a habits summary — which tasks/sessions burn the most tokens,
|
||||
average per prompt, busiest day/hour. Data comes from the local usage log (one
|
||||
event per model turn, recorded by the providers — real server counts when
|
||||
available, ~4 chars/token estimates otherwise).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QGridLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QScrollArea, QTextBrowser, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..core import usage_tracker as ut
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..state import AppContext
|
||||
from ..theme import current_palette
|
||||
from .icons import icon
|
||||
from .spline_chart import SplineChart
|
||||
from .widgets import BudgetCard as _BudgetCard
|
||||
from .widgets import StatCard as _StatCard
|
||||
from .widgets import fmt_tokens as _fmt_tokens
|
||||
|
||||
|
||||
class DashboardTab(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
_PERIODS = ("today", "week", "month", "all")
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
content = QWidget()
|
||||
scroll.setWidget(content)
|
||||
outer.addWidget(scroll)
|
||||
root = QVBoxLayout(content)
|
||||
|
||||
# ---- header: title + the PERIOD FILTER (applies to the WHOLE dashboard —
|
||||
# cards, chart and habits all follow the selected week/month) + refresh
|
||||
self._chart_offset = 0 # 0 = current period; <0 = a past period
|
||||
head = QHBoxLayout()
|
||||
self._title = QLabel()
|
||||
self._title.setStyleSheet("font-weight:700; font-size:15px;")
|
||||
self.chart_prev_btn = QPushButton()
|
||||
self.chart_prev_btn.setIcon(icon("chevron-left"))
|
||||
self.chart_prev_btn.setFixedWidth(30)
|
||||
self.chart_prev_btn.clicked.connect(self._chart_prev)
|
||||
self._chart_period_lbl = QLabel()
|
||||
self._chart_period_lbl.setObjectName("hint")
|
||||
self._chart_period_lbl.setAlignment(Qt.AlignCenter)
|
||||
self._chart_period_lbl.setMinimumWidth(170)
|
||||
self.chart_next_btn = QPushButton()
|
||||
self.chart_next_btn.setIcon(icon("chevron-right"))
|
||||
self.chart_next_btn.setFixedWidth(30)
|
||||
self.chart_next_btn.clicked.connect(self._chart_next)
|
||||
self.gran_combo = QComboBox()
|
||||
for g in ("week", "month", "year"):
|
||||
self.gran_combo.addItem(tr(f"dashboard.gran_{g}"), g)
|
||||
self.gran_combo.currentIndexChanged.connect(self._on_gran_changed)
|
||||
self.metric_combo = QComboBox()
|
||||
for m in ("cost", "tokens"):
|
||||
self.metric_combo.addItem(tr(f"dashboard.metric_{m}"), m)
|
||||
self.metric_combo.currentIndexChanged.connect(self._refresh_chart)
|
||||
# Display-currency picker — moved here from Monitoring's Token Usage
|
||||
# card, right beside refresh; both screens still share the same
|
||||
# usage.currency config key, so changing it here updates everywhere.
|
||||
self.currency_lbl = QLabel()
|
||||
self.currency_lbl.setObjectName("hint")
|
||||
self.currency_combo = QComboBox()
|
||||
for cur in ut.SUPPORTED_CURRENCIES:
|
||||
self.currency_combo.addItem(cur, cur)
|
||||
idx = self.currency_combo.findData(
|
||||
(self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
|
||||
self.currency_combo.setCurrentIndex(max(0, idx))
|
||||
self.currency_combo.currentIndexChanged.connect(self._on_currency_changed)
|
||||
self.refresh_btn = QPushButton("")
|
||||
self.refresh_btn.setIcon(icon("refresh"))
|
||||
self.refresh_btn.setFixedWidth(34)
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
# Two rows, grouped by what the controls do, instead of nine widgets
|
||||
# strung across one line where the title, a date pager, two chart
|
||||
# selectors, a currency picker and Refresh all read as one undifferentiated
|
||||
# strip. Row 1 is "where am I"; row 2 is "what am I looking at".
|
||||
head.addWidget(self._title, 1)
|
||||
head.addWidget(self.refresh_btn)
|
||||
root.addLayout(head)
|
||||
|
||||
controls = QHBoxLayout()
|
||||
controls.setSpacing(6)
|
||||
controls.addWidget(self.chart_prev_btn) # period pager
|
||||
controls.addWidget(self._chart_period_lbl)
|
||||
controls.addWidget(self.chart_next_btn)
|
||||
controls.addSpacing(12)
|
||||
controls.addWidget(self.gran_combo) # what the chart plots
|
||||
controls.addWidget(self.metric_combo)
|
||||
controls.addStretch(1)
|
||||
controls.addWidget(self.currency_lbl) # how money is displayed
|
||||
controls.addWidget(self.currency_combo)
|
||||
root.addLayout(controls)
|
||||
|
||||
# ---- stat cards ---------------------------------------------------
|
||||
# Cost is the headline this screen exists for, so it gets a card twice
|
||||
# the height of the rest instead of being the fifth of five identical
|
||||
# tiles — with six equal cards nothing said which number mattered.
|
||||
cards_grid = QGridLayout()
|
||||
cards_grid.setSpacing(8)
|
||||
self.card_total = _StatCard()
|
||||
self.card_in = _StatCard()
|
||||
self.card_out = _StatCard()
|
||||
self.card_cache = _StatCard()
|
||||
self.card_cost = _StatCard().as_hero()
|
||||
# Hero on the left, spanning both rows; the four supporting figures fill
|
||||
# a 2×2 block beside it.
|
||||
cards_grid.addWidget(self.card_cost, 0, 0, 2, 1)
|
||||
for i, card in enumerate((self.card_total, self.card_in,
|
||||
self.card_out, self.card_cache)):
|
||||
cards_grid.addWidget(card, i // 2, 1 + i % 2)
|
||||
# Budget: remaining/budget, direct entry, auto-warns red past 85% used.
|
||||
self.budget_card = _BudgetCard()
|
||||
self.budget_card.apply_btn.setIcon(icon("check"))
|
||||
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
|
||||
cards_grid.addWidget(self.budget_card, 0, 3, 2, 1)
|
||||
# The hero and Budget columns get more room than the small tiles.
|
||||
for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)):
|
||||
cards_grid.setColumnStretch(col, stretch)
|
||||
root.addLayout(cards_grid)
|
||||
|
||||
# ---- token/cost within the selected period (spline): WEEK → 7 days
|
||||
# (Mon–Sun) · MONTH → weeks W1…Wn · YEAR → 12 months. Dashed lines
|
||||
# compare the previous week / month. ----
|
||||
chart_head = QHBoxLayout()
|
||||
self._chart_title = QLabel()
|
||||
self._chart_title.setStyleSheet("font-weight:600;")
|
||||
chart_head.addWidget(self._chart_title, 1)
|
||||
root.addLayout(chart_head)
|
||||
self.chart = SplineChart()
|
||||
root.addWidget(self.chart)
|
||||
|
||||
# ---- habits summary -------------------------------------------------
|
||||
self._habits_title = QLabel()
|
||||
self._habits_title.setStyleSheet("font-weight:600;")
|
||||
habits_head = QHBoxLayout()
|
||||
self.ai_analyze_btn = QPushButton()
|
||||
self.ai_analyze_btn.setIcon(icon("sparkle"))
|
||||
self.ai_analyze_btn.clicked.connect(self._ai_analyze)
|
||||
# Apply an AI-suggested cost-saving strategy (enable auto-compress + tune
|
||||
# the compression threshold) — only after the user clicks to approve it.
|
||||
self.apply_strategy_btn = QPushButton()
|
||||
self.apply_strategy_btn.setIcon(icon("bolt"))
|
||||
self.apply_strategy_btn.setVisible(False)
|
||||
self.apply_strategy_btn.clicked.connect(self._apply_saving_strategy)
|
||||
habits_head.addWidget(self._habits_title, 1)
|
||||
habits_head.addWidget(self.apply_strategy_btn)
|
||||
habits_head.addWidget(self.ai_analyze_btn)
|
||||
root.addLayout(habits_head)
|
||||
self.habits = QTextBrowser()
|
||||
self.habits.setOpenExternalLinks(False)
|
||||
self.habits.setMinimumHeight(160)
|
||||
root.addWidget(self.habits, 1)
|
||||
# AI recommendations panel (filled by the ✨ button).
|
||||
self._ai_title = QLabel()
|
||||
self._ai_title.setStyleSheet("font-weight:600;")
|
||||
self._ai_title.setVisible(False)
|
||||
root.addWidget(self._ai_title)
|
||||
self.ai_advice = QTextBrowser()
|
||||
self.ai_advice.setOpenExternalLinks(False)
|
||||
self.ai_advice.setMinimumHeight(140)
|
||||
self.ai_advice.setVisible(False)
|
||||
root.addWidget(self.ai_advice, 1)
|
||||
|
||||
# Auto-refresh every 30s so numbers follow ongoing work.
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(30_000)
|
||||
self._timer.timeout.connect(self.refresh)
|
||||
self._timer.start()
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
self.refresh()
|
||||
|
||||
# ---- helpers -----------------------------------------------------------
|
||||
def _pricing(self) -> Dict:
|
||||
from ..core import model_pricing as mp
|
||||
mp.sync_to_usage(self.ctx.config) # cost/total comes straight from the price table
|
||||
return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||||
|
||||
def _on_currency_changed(self, _idx: int) -> None:
|
||||
cur = self.currency_combo.currentData()
|
||||
if not cur:
|
||||
return
|
||||
self.ctx.config.data.setdefault("usage", {})["currency"] = cur
|
||||
self.ctx.save()
|
||||
self.refresh()
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self._title.setText(tr("dashboard.title"))
|
||||
self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip"))
|
||||
self.currency_lbl.setText(tr("monitoring.overview_currency"))
|
||||
self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
self.ai_analyze_btn.setToolTip(tr("dashboard.ai_analyze_tooltip"))
|
||||
self.apply_strategy_btn.setText(tr("dashboard.strategy_btn"))
|
||||
self.apply_strategy_btn.setToolTip(tr("dashboard.strategy_tooltip"))
|
||||
self._habits_title.setText(tr("dashboard.habits_title"))
|
||||
self._chart_title.setText(tr("dashboard.chart_title"))
|
||||
self.chart_prev_btn.setToolTip(tr("dashboard.chart_prev"))
|
||||
self.chart_next_btn.setToolTip(tr("dashboard.chart_next"))
|
||||
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
|
||||
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
|
||||
self.refresh()
|
||||
|
||||
def _apply_budget(self) -> None:
|
||||
"""Persist the spin box's value as the new budget — starts a fresh
|
||||
remaining-balance window (spend before now is no longer counted)."""
|
||||
ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD")
|
||||
ut.set_budget(self.ctx.config, self.budget_card.budget_spin.value(), ccy)
|
||||
self.ctx.save()
|
||||
self._refresh_budget()
|
||||
|
||||
def _refresh_budget(self) -> None:
|
||||
from ..core import model_pricing as mp
|
||||
pricing = self._pricing()
|
||||
status = ut.budget_status(self.ctx.config)
|
||||
if status is None:
|
||||
self.budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget"))
|
||||
self.budget_card.budget_spin.setValue(0.0)
|
||||
return
|
||||
remaining_disp = mp.convert(status["remaining_usd"], "USD",
|
||||
pricing.get("currency", "USD"), self.ctx.config)
|
||||
amount_disp = mp.convert(status["amount_usd"], "USD",
|
||||
pricing.get("currency", "USD"), self.ctx.config)
|
||||
value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}"
|
||||
f" / {ut.format_cost(status['amount_usd'], pricing, digits=2)}")
|
||||
pct = int(round(status["pct_used"] * 100))
|
||||
sub = tr("usage.budget_over_warning") if status["over_85"] else tr("usage.budget_used_pct", pct=pct)
|
||||
self.budget_card.set(tr("usage.budget_title"), value, sub, warn=status["over_85"])
|
||||
# keep the entry field showing the CURRENT budget (in display currency) —
|
||||
# only when it doesn't already have unsaved focus/edits from the user.
|
||||
if not self.budget_card.budget_spin.hasFocus():
|
||||
self.budget_card.budget_spin.setValue(round(amount_disp, 2))
|
||||
|
||||
def _period_range(self):
|
||||
"""The SELECTED period as an inclusive (start, end) date range — drives
|
||||
the whole dashboard (cards, chart, habits)."""
|
||||
gran = self.gran_combo.currentData() or "week"
|
||||
start, end = ut.period_bounds(gran, self._chart_offset)
|
||||
return start, end - timedelta(days=1) # load_events end is inclusive
|
||||
|
||||
def _on_gran_changed(self, *_a) -> None:
|
||||
self._chart_offset = 0 # period size changed → back to current
|
||||
self.refresh() # the filter drives the WHOLE dashboard
|
||||
|
||||
def _chart_prev(self) -> None:
|
||||
self._chart_offset -= 1 # page one period into the past
|
||||
self.refresh()
|
||||
|
||||
def _chart_next(self) -> None:
|
||||
self._chart_offset = min(0, self._chart_offset + 1) # never past the present
|
||||
self.refresh()
|
||||
|
||||
@staticmethod
|
||||
def _delta_txt(cur: float, prev: float) -> str:
|
||||
"""▲/▼ percent change of ``cur`` vs ``prev`` (empty if no baseline)."""
|
||||
if not prev:
|
||||
return ""
|
||||
pct = (cur - prev) / prev * 100
|
||||
arrow = "▲" if pct > 0.5 else ("▼" if pct < -0.5 else "•")
|
||||
return f"{arrow}{abs(pct):.0f}%"
|
||||
|
||||
def _refresh_chart(self, *_a) -> None:
|
||||
"""Break the SELECTED period into its parts: WEEK → 7 days (Mon–Sun) ·
|
||||
MONTH → weeks W1…Wn · YEAR → 12 months. Dashed lines mark the previous
|
||||
week's / month's average per point with the % change of the totals."""
|
||||
if not hasattr(self, "chart"):
|
||||
return
|
||||
gran = self.gran_combo.currentData() or "week"
|
||||
metric = self.metric_combo.currentData() or "cost"
|
||||
events = ut.load_events() # all events; breakdown slices by period
|
||||
pricing = self._pricing()
|
||||
parts = ut.period_breakdown(events, gran, pricing, offset=self._chart_offset)
|
||||
mi = 0 if metric == "tokens" else 1 # (label, tokens, cost) → +1 for the value
|
||||
pts = [(row[0], float(row[mi + 1])) for row in parts]
|
||||
# Compact cost format (2 decimals, K/M above 1,000/1,000,000) — the
|
||||
# chart's y-axis label box is narrow; format_cost's full precision (up
|
||||
# to 4 decimals for USD) overflowed it, clipping/obscuring the amount.
|
||||
fmt = _fmt_tokens if metric == "tokens" else (lambda v: ut.format_cost_compact(v, pricing))
|
||||
|
||||
# One dashed comparison line that FOLLOWS the filter: the selected period
|
||||
# vs the previous SAME-granularity one — "Last week" in week view,
|
||||
# "Last month" in month view, "Last year" in year view. Drawn at the
|
||||
# previous period's average per point so it sits on-scale; the label shows
|
||||
# the % change of the period totals.
|
||||
cur = ut.period_totals(events, gran, pricing, self._chart_offset)
|
||||
prev = ut.period_totals(events, gran, pricing, self._chart_offset - 1)
|
||||
ref_key = {"week": "dashboard.ref_last_week",
|
||||
"month": "dashboard.ref_last_month",
|
||||
"year": "dashboard.ref_last_year"}.get(gran, "dashboard.ref_last_week")
|
||||
n_points = max(1, len(parts))
|
||||
refs = []
|
||||
if prev[mi] > 0:
|
||||
# Muted on purpose: the comparison line is a reference, not the
|
||||
# series — it must not compete with the accent-coloured spline.
|
||||
refs.append((prev[mi] / n_points,
|
||||
f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}",
|
||||
current_palette().text_muted))
|
||||
self.chart.set_reference_lines(refs)
|
||||
self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}"))
|
||||
self._chart_period_lbl.setText(ut.period_range_label(gran, self._chart_offset))
|
||||
self.chart_next_btn.setEnabled(self._chart_offset < 0)
|
||||
|
||||
# ---- main refresh --------------------------------------------------------
|
||||
def refresh(self) -> None:
|
||||
start, end = self._period_range()
|
||||
events = ut.load_events(start, end)
|
||||
|
||||
s = ut.summarize(events)
|
||||
pricing = self._pricing()
|
||||
costs = ut.cost_usd_events(events, pricing) # honors the per-model price table
|
||||
total_cost = sum(costs.values())
|
||||
|
||||
est_note = (tr("dashboard.estimated_note", pct=int(s["estimated_share"] * 100))
|
||||
if s["estimated_share"] > 0 else "")
|
||||
self.card_total.set(tr("dashboard.card_total"), _fmt_tokens(s["total"]),
|
||||
tr("dashboard.card_turns", n=s["turns"]))
|
||||
self.card_in.set(tr("dashboard.card_in"), _fmt_tokens(s["in"]),
|
||||
ut.format_cost(costs["in"], pricing))
|
||||
self.card_out.set(tr("dashboard.card_out"), _fmt_tokens(s["out"]),
|
||||
ut.format_cost(costs["out"], pricing))
|
||||
self.card_cache.set(tr("dashboard.card_cache"), _fmt_tokens(s["cache"]),
|
||||
ut.format_cost(costs["cache"], pricing))
|
||||
self.card_cost.set(tr("dashboard.card_cost"),
|
||||
ut.format_cost(total_cost, pricing, digits=2), est_note)
|
||||
|
||||
# ---- habits -----------------------------------------------------------
|
||||
lines: List[str] = []
|
||||
if not events:
|
||||
lines.append(f"<i>{tr('dashboard.no_data')}</i>")
|
||||
else:
|
||||
lines.append(f"<b>{tr('dashboard.h_top')}</b>")
|
||||
lines.append("<ol>")
|
||||
for label, tok in s["top_labels"]:
|
||||
pct = int(tok * 100 / s["total"]) if s["total"] else 0
|
||||
lines.append(f"<li>{label[:60]} — {_fmt_tokens(tok)} tokens ({pct}%)</li>")
|
||||
lines.append("</ol>")
|
||||
src_parts = ", ".join(
|
||||
f"{tr(f'app.tab.{k}') if k in ('cowork', 'code') else k}: {_fmt_tokens(v)}"
|
||||
for k, v in s["by_source"])
|
||||
lines.append(f"<b>{tr('dashboard.h_by_source')}</b>: {src_parts}<br>")
|
||||
lines.append(f"<b>{tr('dashboard.h_avg')}</b>: "
|
||||
f"{_fmt_tokens(s['avg_per_turn'])} tokens<br>")
|
||||
if s["busiest_day"]:
|
||||
lines.append(f"<b>{tr('dashboard.h_busiest_day')}</b>: {s['busiest_day']}<br>")
|
||||
if s["busiest_hour"] is not None:
|
||||
lines.append(f"<b>{tr('dashboard.h_busiest_hour')}</b>: "
|
||||
f"{s['busiest_hour']:02d}:00–{s['busiest_hour']:02d}:59<br>")
|
||||
if s["estimated_share"] > 0:
|
||||
lines.append(f"<i>{tr('dashboard.estimated_note', pct=int(s['estimated_share'] * 100))}</i>")
|
||||
self.habits.setHtml("".join(lines))
|
||||
self._refresh_chart()
|
||||
self._refresh_budget()
|
||||
|
||||
def _apply_saving_strategy(self) -> None:
|
||||
"""Apply an AI-suggested cost-saving strategy AFTER the user approves:
|
||||
turn on auto-compress and compress earlier (lower threshold) + compress
|
||||
content before sending it to the agent — cutting tokens on every turn."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
if QMessageBox.question(self, tr("dashboard.strategy_title"),
|
||||
tr("dashboard.strategy_confirm")) != QMessageBox.Yes:
|
||||
return
|
||||
cx = self.ctx.config.data.setdefault("context", {})
|
||||
cx["auto_compact"] = True
|
||||
cx["compact_threshold"] = 0.6 # compress at 60% of the window (was ~80%)
|
||||
cx["compress_before_send"] = True # digest context before each turn
|
||||
self.ctx.save()
|
||||
self.status_message.emit(tr("dashboard.strategy_applied"))
|
||||
|
||||
# ---- AI habits analysis ----------------------------------------------------
|
||||
def _ai_analyze(self) -> None:
|
||||
"""✨ Send the aggregated numbers (never raw prompt text) to the active
|
||||
provider and show habit feedback + token-saving recommendations."""
|
||||
if getattr(self, "_ai_worker", None) is not None:
|
||||
return
|
||||
start, end = self._period_range()
|
||||
events = ut.load_events(start, end)
|
||||
if not events:
|
||||
self.status_message.emit(tr("dashboard.no_data"))
|
||||
return
|
||||
summary = ut.summarize(events)
|
||||
self.ai_analyze_btn.setEnabled(False)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyzing"))
|
||||
ctx = self.ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from ..i18n import get_language
|
||||
|
||||
prompt = ut.build_ai_analysis_prompt(summary, get_language())
|
||||
provider = ctx.build_active_provider()
|
||||
reply = provider.chat([{"role": "user", "content": prompt}],
|
||||
cancel=worker.stop_event)
|
||||
return {"text": (reply.get("content") or "").strip()}
|
||||
|
||||
def done(result: dict) -> None:
|
||||
self._ai_worker = None
|
||||
self.ai_analyze_btn.setEnabled(True)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
text = result.get("text") or ""
|
||||
if text:
|
||||
self._ai_title.setText(tr("dashboard.ai_advice_title"))
|
||||
self._ai_title.setVisible(True)
|
||||
self.ai_advice.setMarkdown(text)
|
||||
self.ai_advice.setVisible(True)
|
||||
self.apply_strategy_btn.setVisible(True) # offer to apply the saving strategy
|
||||
|
||||
def failed(err: str) -> None:
|
||||
self._ai_worker = None
|
||||
self.ai_analyze_btn.setEnabled(True)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
self.status_message.emit(str(err))
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._ai_worker = w
|
||||
w.start()
|
||||
-1586
File diff suppressed because it is too large
Load Diff
@@ -1,794 +0,0 @@
|
||||
"""Schedule Task tab — Kanban board for scheduled/automated tasks.
|
||||
|
||||
Columns: Backlog / Scheduled / Running / Waiting Input / Done / Failed /
|
||||
Paused. Cards drag between columns (dropping = changing status), double-click
|
||||
edits, right-click offers Run now / Edit / Duplicate / Pause / Delete / View
|
||||
logs / Create-next-from-output. Header has search, a type filter, Add Task
|
||||
and AI Create Task (preview first — nothing is created until confirmed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout,
|
||||
QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox,
|
||||
QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget,
|
||||
QTabBar, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..core import tasks as taskrepo
|
||||
from ..core.projects import list_projects
|
||||
from ..core.tasks import STATUSES, chain_error, duplicate_task, new_task
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..state import AppContext
|
||||
from ..theme import current_palette
|
||||
from .calendar_view import CalendarView
|
||||
from .icons import icon
|
||||
from .osutil import open_path
|
||||
|
||||
_VIEWS = ("kanban", "calendar")
|
||||
|
||||
# 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):
|
||||
"""One status lane. Accepts drops from sibling columns; a drop means
|
||||
'move this task to my status'."""
|
||||
|
||||
task_dropped = Signal(str, str) # task_id, new_status
|
||||
|
||||
def __init__(self, status: str):
|
||||
super().__init__()
|
||||
self.status = status
|
||||
self.setDragDropMode(QAbstractItemView.DragDrop)
|
||||
self.setDefaultDropAction(Qt.MoveAction)
|
||||
# Shift/Ctrl-click several cards in the SAME column, then right-click
|
||||
# → "Delete N selected" to bulk-remove tasks instead of one at a time.
|
||||
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||
self.setWordWrap(True)
|
||||
# Cards wrap, so there is never anything to reach by scrolling sideways
|
||||
# — but QListWidget's own column hint runs 1-6px past the viewport, and
|
||||
# a lane sprouted a horizontal scrollbar at 36 of 38 window widths I
|
||||
# measured. Which lanes grew one changed with the width, which is why it
|
||||
# looked like it depended on the screen.
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setResizeMode(QListWidget.Adjust) # re-wrap on every resize
|
||||
# No pixel floor here. A fixed one is always wrong on some screen:
|
||||
# 190 lost the seventh lane, 150 still wanted 1242px where a 1280
|
||||
# window leaves 1091 — so the 1280 monitor scrolled sideways and the
|
||||
# 1920 one did not, same app, same build. The board divides whatever
|
||||
# width it has by seven instead; see _fit_lanes().
|
||||
|
||||
def dropEvent(self, event): # noqa: N802
|
||||
source = event.source()
|
||||
if isinstance(source, _KanbanColumn) and source is not self:
|
||||
item = source.currentItem()
|
||||
tid = item.data(Qt.UserRole) if item else None
|
||||
if tid:
|
||||
event.acceptProposedAction()
|
||||
self.task_dropped.emit(tid, self.status)
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
|
||||
class ScheduleTaskTab(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext, scheduler=None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self.scheduler = scheduler # TaskScheduler (may be None in tests)
|
||||
self._ai_worker: Optional[AgentWorker] = None
|
||||
self._tasks_dir: Optional[Path] = None # None → default repo 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")
|
||||
# A one-line summary of every lane's count. Left to size itself it
|
||||
# reported a sizeHint wide enough to set the MINIMUM width of the whole
|
||||
# screen — 1285px at 150% scaling, which then became the window's
|
||||
# minimum and stopped the app fitting a 1280px laptop. It is a summary,
|
||||
# and the same numbers are on each lane header, so it gives way first.
|
||||
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()
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
board = QWidget()
|
||||
scroll.setWidget(board)
|
||||
cols = QHBoxLayout(board)
|
||||
# Gutters wide enough to read as a break between lanes without eating
|
||||
# too much of the seven-way split — they still share the board equally
|
||||
# (see _fit_lanes below), so a wider gutter narrows every lane by the
|
||||
# same share automatically; nothing else to compute here.
|
||||
cols.setSpacing(2)
|
||||
self.columns: Dict[str, _KanbanColumn] = {}
|
||||
self.column_headers: Dict[str, QLabel] = {}
|
||||
for status in STATUSES:
|
||||
box = QVBoxLayout()
|
||||
# The per-lane holder's own margins were the style's default
|
||||
# (~9px a side) on top of the inter-column gap — with seven lanes
|
||||
# that outweighs the gap itself. Zero it out and let the lane's
|
||||
# header/list fill the width _fit_lanes() hands them.
|
||||
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
|
||||
self._board_scroll = scroll
|
||||
self._board_gap = cols.spacing()
|
||||
scroll.viewport().installEventFilter(self)
|
||||
self._view_stack.addWidget(scroll)
|
||||
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).
|
||||
from PySide6.QtCore import QTimer
|
||||
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}"))
|
||||
for status, col in self.columns.items():
|
||||
col.setToolTip(tr(f"schedtask.col_tip.{status}"))
|
||||
self.refresh()
|
||||
|
||||
# ---- Kanban / Calendar view switch --------------------------------
|
||||
def _on_view_changed(self) -> None:
|
||||
self._view_stack.setCurrentIndex(self.view_tabs.currentIndex())
|
||||
|
||||
def _add_task_on_date(self, date_str: str) -> None:
|
||||
"""Create a task pre-filled with the clicked calendar date (default
|
||||
09:00) — same editor Add Task opens, nothing is saved until confirmed."""
|
||||
from .task_editor_dialog import TaskEditorDialog
|
||||
|
||||
t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"})
|
||||
dlg = TaskEditorDialog(t, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
self.status_message.emit(tr("schedtask.msg_created"))
|
||||
|
||||
# ---- lane widths ------------------------------------------------------
|
||||
#
|
||||
# The seven lanes share the board equally — that is the layout's stretch
|
||||
# doing the work, so the split is a proportion of whatever width there is,
|
||||
# on any monitor. The only pixel question left is how narrow a lane may get
|
||||
# before scrolling sideways beats squeezing, and that is a question about
|
||||
# TEXT: roughly eight characters of a task title plus its padding. Reading
|
||||
# it off the font keeps it right at 125%/150% scaling and at a user's own
|
||||
# font size, where a constant would not be.
|
||||
_LANE_FLOOR_CH = 8
|
||||
|
||||
def eventFilter(self, obj, event): # noqa: N802
|
||||
from PySide6.QtCore import QEvent
|
||||
|
||||
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)
|
||||
|
||||
# ---- board 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"))
|
||||
# Card shows ONLY the task's own title (plus the [AI] marker and chain
|
||||
# note) — no "[Cowork]"/"[Code]" task-type tag cluttering it.
|
||||
return (f"{ai}{t.get('title', '')}{chain}\n"
|
||||
f"{when_line} {prio}\n{last_line}")
|
||||
|
||||
def refresh(self) -> None:
|
||||
all_tasks = taskrepo.list_tasks(self._tasks_dir)
|
||||
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 — the one column here
|
||||
# with a side effect should not look like the other six.
|
||||
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)
|
||||
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
|
||||
self.calendar.set_tasks(all_tasks)
|
||||
|
||||
# ---- actions --------------------------------------------------------
|
||||
def _save_and_refresh(self, task: dict) -> None:
|
||||
taskrepo.save_task(task, self._tasks_dir)
|
||||
self.refresh()
|
||||
|
||||
def _add_task(self) -> None:
|
||||
from .task_editor_dialog import TaskEditorDialog
|
||||
|
||||
dlg = TaskEditorDialog(None, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
self.status_message.emit(tr("schedtask.msg_created"))
|
||||
|
||||
def _edit_task(self, task_id: str) -> None:
|
||||
from .task_editor_dialog import TaskEditorDialog
|
||||
|
||||
task = taskrepo.load_task(task_id, self._tasks_dir)
|
||||
if not task:
|
||||
return
|
||||
dlg = TaskEditorDialog(task, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
|
||||
def _on_double_click(self, item: QListWidgetItem) -> None:
|
||||
tid = item.data(Qt.UserRole)
|
||||
if tid:
|
||||
self._edit_task(tid)
|
||||
|
||||
def _on_task_dropped(self, task_id: str, new_status: str) -> None:
|
||||
"""Dropping a card into a lane ACTS on the task, not just relabels it:
|
||||
→ Running actually runs it now; → Done marks it completed; → Scheduled
|
||||
puts it on the calendar (opening the editor if no time is set yet)."""
|
||||
task = taskrepo.load_task(task_id, self._tasks_dir)
|
||||
if not task:
|
||||
return
|
||||
if task.get("status") == "running":
|
||||
self.refresh() # can't drag a running task
|
||||
return
|
||||
if new_status == "running":
|
||||
# Dropping into Running = "run it now" (counts as manual approval).
|
||||
self.refresh()
|
||||
self._run_now(task)
|
||||
return
|
||||
if new_status == "done":
|
||||
task["status"] = "done"
|
||||
task["schedule"]["enabled"] = False # done by hand → don't re-fire
|
||||
self._save_and_refresh(task)
|
||||
return
|
||||
task["status"] = new_status
|
||||
if new_status == "scheduled" and not task["schedule"].get("enabled"):
|
||||
if task["schedule"].get("run_at"):
|
||||
task["schedule"]["enabled"] = True
|
||||
else:
|
||||
# No time set yet — a silently-disabled "Scheduled" card would
|
||||
# never run and look broken. Open the editor so the user sets
|
||||
# the schedule right away.
|
||||
self._save_and_refresh(task)
|
||||
self.status_message.emit(tr("schedtask.msg_set_schedule"))
|
||||
self._edit_task(task_id)
|
||||
return
|
||||
self._save_and_refresh(task)
|
||||
|
||||
@staticmethod
|
||||
def _is_multi_selection(item, selected) -> bool:
|
||||
"""True when the right-clicked card is part of an existing multi-item
|
||||
selection — pure boolean, kept separate from _context_menu so it's
|
||||
testable without ever invoking Qt's (modal, event-loop-blocking) menu."""
|
||||
return len(selected) > 1 and item in selected
|
||||
|
||||
def _context_menu(self, col: _KanbanColumn, pos) -> None:
|
||||
item = col.itemAt(pos)
|
||||
if item is None or not item.data(Qt.UserRole):
|
||||
return
|
||||
selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)]
|
||||
if self._is_multi_selection(item, selected):
|
||||
self._bulk_delete_menu(col, pos, selected)
|
||||
return
|
||||
tid = item.data(Qt.UserRole)
|
||||
task = taskrepo.load_task(tid, self._tasks_dir)
|
||||
if not task:
|
||||
return
|
||||
menu = QMenu(col)
|
||||
run_act = menu.addAction(tr("schedtask.menu_run"))
|
||||
edit_act = menu.addAction(tr("schedtask.menu_edit"))
|
||||
dup_act = menu.addAction(tr("schedtask.menu_duplicate"))
|
||||
paused = task.get("status") == "paused"
|
||||
pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause"))
|
||||
logs_act = menu.addAction(tr("schedtask.menu_logs"))
|
||||
hist_act = menu.addAction(tr("schedtask.menu_history"))
|
||||
next_act = menu.addAction(tr("schedtask.menu_create_next"))
|
||||
menu.addSeparator()
|
||||
del_act = menu.addAction(tr("schedtask.menu_delete"))
|
||||
chosen = menu.exec(col.viewport().mapToGlobal(pos))
|
||||
if chosen == run_act:
|
||||
self._run_now(task)
|
||||
elif chosen == edit_act:
|
||||
self._edit_task(tid)
|
||||
elif chosen == dup_act:
|
||||
self._save_and_refresh(duplicate_task(task))
|
||||
elif chosen == pause_act:
|
||||
task["status"] = "backlog" if paused else "paused"
|
||||
self._save_and_refresh(task)
|
||||
elif chosen == logs_act:
|
||||
self._view_logs(task)
|
||||
elif chosen == hist_act:
|
||||
_RunHistoryDialog(task, self).exec()
|
||||
elif chosen == next_act:
|
||||
self._create_next_from_output(task)
|
||||
elif chosen == del_act:
|
||||
if QMessageBox.question(self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_confirm", title=task.get("title", ""))
|
||||
) == QMessageBox.Yes:
|
||||
taskrepo.delete_task(tid, self._tasks_dir)
|
||||
self.refresh()
|
||||
|
||||
def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None:
|
||||
"""Right-click on a multi-selection within one column (Shift/Ctrl-click
|
||||
several cards first): one action deletes every selected task. The
|
||||
popup itself is a thin wrapper — see _confirm_and_delete_selected for
|
||||
the actual (independently testable) confirm+delete logic."""
|
||||
menu = QMenu(col)
|
||||
del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected)))
|
||||
chosen = menu.exec(col.viewport().mapToGlobal(pos))
|
||||
if chosen == del_act:
|
||||
self._confirm_and_delete_selected(selected)
|
||||
|
||||
def _confirm_and_delete_selected(self, selected) -> bool:
|
||||
"""Confirm, then delete every task in ``selected``. Split out of
|
||||
_bulk_delete_menu so tests can drive it directly without having to
|
||||
fake a real (modal, event-loop-blocking) QMenu popup."""
|
||||
if QMessageBox.question(
|
||||
self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
|
||||
return False
|
||||
for item in selected:
|
||||
tid = item.data(Qt.UserRole)
|
||||
if tid:
|
||||
taskrepo.delete_task(tid, self._tasks_dir)
|
||||
self.refresh()
|
||||
return True
|
||||
|
||||
def _run_now(self, task: dict) -> None:
|
||||
if task.get("task_type") == "manual":
|
||||
self.status_message.emit(tr("schedtask.msg_manual_norun"))
|
||||
return
|
||||
if self.scheduler is None:
|
||||
self.status_message.emit(tr("schedtask.msg_no_scheduler"))
|
||||
return
|
||||
if self.scheduler.run_now(task["task_id"]):
|
||||
self.status_message.emit(tr("schedtask.msg_running", title=task.get("title", "")))
|
||||
self.refresh()
|
||||
|
||||
def _view_logs(self, task: dict) -> None:
|
||||
run_id = task.get("logs", {}).get("last_run_id")
|
||||
if not run_id:
|
||||
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
|
||||
return
|
||||
folder = taskrepo.ARTIFACTS_DIR / task["task_id"] / run_id
|
||||
if folder.exists():
|
||||
open_path(str(folder))
|
||||
else:
|
||||
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
|
||||
|
||||
def _create_next_from_output(self, task: dict) -> None:
|
||||
"""Scaffold a follow-up task pre-wired to consume this task's output."""
|
||||
nxt = new_task(tr("schedtask.next_of", title=task.get("title", "")))
|
||||
nxt["task_type"] = "cowork"
|
||||
nxt["input"]["mode"] = "previous_task_output"
|
||||
nxt["input"]["previous_task_id"] = task["task_id"]
|
||||
nxt["dependency"]["previous_task_id"] = task["task_id"]
|
||||
err = chain_error(taskrepo.list_tasks(self._tasks_dir) + [nxt],
|
||||
task["task_id"], nxt["task_id"])
|
||||
if err:
|
||||
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
|
||||
return
|
||||
taskrepo.save_task(nxt, self._tasks_dir)
|
||||
task["dependency"]["next_task_id"] = nxt["task_id"]
|
||||
task["dependency"]["pass_output_to_next"] = True
|
||||
if task["dependency"].get("run_next_mode", "none") == "none":
|
||||
task["dependency"]["run_next_mode"] = "run_after_success"
|
||||
taskrepo.save_task(task, self._tasks_dir)
|
||||
self.refresh()
|
||||
self._edit_task(nxt["task_id"])
|
||||
|
||||
# ---- AI create ----------------------------------------------------------
|
||||
def _ai_create(self) -> None:
|
||||
dlg = _AiCreateDialog(self.ctx, self)
|
||||
if dlg.exec() and dlg.created_tasks:
|
||||
for t in dlg.created_tasks:
|
||||
taskrepo.save_task(t, self._tasks_dir)
|
||||
self.refresh()
|
||||
self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks)))
|
||||
|
||||
|
||||
class _RunHistoryDialog(QDialog):
|
||||
"""Run history of one task as a table (newest first): time, status, error;
|
||||
double-click a row to open that run's artifact folder."""
|
||||
|
||||
def __init__(self, task: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self._task = task
|
||||
self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}")
|
||||
self.resize(620, 380)
|
||||
root = QVBoxLayout(self)
|
||||
hint = QLabel(tr("schedtask.hist_hint"))
|
||||
hint.setObjectName("hint")
|
||||
root.addWidget(hint)
|
||||
|
||||
runs = list(reversed(task.get("runs", []) or []))
|
||||
self.table = QTableWidget(len(runs), 4)
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"),
|
||||
tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"),
|
||||
])
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
for row, run in enumerate(runs):
|
||||
ok = run.get("status") == "success"
|
||||
cells = (
|
||||
run.get("finished_at", ""),
|
||||
str(run.get("status", "")),
|
||||
run.get("run_id", ""),
|
||||
(run.get("error") or "")[:200],
|
||||
)
|
||||
for col, text in enumerate(cells):
|
||||
item = QTableWidgetItem(str(text))
|
||||
if col == 0:
|
||||
item.setData(Qt.UserRole, run.get("run_id", ""))
|
||||
self.table.setItem(row, col, item)
|
||||
self.table.resizeColumnsToContents()
|
||||
self.table.horizontalHeader().setStretchLastSection(True)
|
||||
self.table.itemDoubleClicked.connect(self._open_artifact)
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
buttons.rejected.connect(self.reject)
|
||||
buttons.accepted.connect(self.accept)
|
||||
root.addWidget(buttons)
|
||||
|
||||
def _open_artifact(self, item: QTableWidgetItem) -> None:
|
||||
first = self.table.item(item.row(), 0)
|
||||
run_id = first.data(Qt.UserRole) if first else ""
|
||||
if not run_id:
|
||||
return
|
||||
folder = taskrepo.ARTIFACTS_DIR / self._task["task_id"] / run_id
|
||||
if folder.exists():
|
||||
open_path(str(folder))
|
||||
|
||||
|
||||
class _DropZone(QLabel):
|
||||
"""Drag-an-.xlsx-here area for the Import tab."""
|
||||
|
||||
file_dropped = Signal(str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setAlignment(Qt.AlignCenter)
|
||||
self.setMinimumHeight(70)
|
||||
_p = current_palette()
|
||||
self.setStyleSheet(
|
||||
f"QLabel {{ border: 1px dashed {_p.border_strong};"
|
||||
f" border-radius: {_p.radius_lg}px;"
|
||||
f" color: {_p.text_muted}; padding: 10px; }}")
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
def dragEnterEvent(self, event): # noqa: N802
|
||||
urls = event.mimeData().urls()
|
||||
if urls and urls[0].toLocalFile().lower().endswith(
|
||||
(".xlsx", ".xlsm", ".xls", ".csv", ".json")):
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event): # noqa: N802
|
||||
urls = event.mimeData().urls()
|
||||
if urls:
|
||||
self.file_dropped.emit(urls[0].toLocalFile())
|
||||
|
||||
|
||||
class _AiCreateDialog(QDialog):
|
||||
"""Create tasks two ways, one tab each (both preview first — nothing is
|
||||
saved until the user confirms): ✨ AI gen from a natural-language
|
||||
description, or 📥 Import from a filled Excel template (pick or drag)."""
|
||||
|
||||
def __init__(self, ctx: AppContext, parent=None):
|
||||
super().__init__(parent)
|
||||
from PySide6.QtWidgets import QTabWidget
|
||||
|
||||
self.ctx = ctx
|
||||
self.created_tasks: List[dict] = []
|
||||
self._planned: List[dict] = []
|
||||
self._worker: Optional[AgentWorker] = None
|
||||
self.setWindowTitle(tr("schedtask.ai_btn"))
|
||||
self.resize(600, 520)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
ws_row = QHBoxLayout()
|
||||
ws_row.addWidget(QLabel(tr("schedtask.f_workspace")))
|
||||
self.workspace_combo = QComboBox()
|
||||
self.workspace_combo.addItem(tr("schedtask.no_workspace"), "")
|
||||
for p in list_projects():
|
||||
self.workspace_combo.addItem(p.name, p.project_id)
|
||||
self.workspace_combo.setToolTip(tr("schedtask.hint_workspace"))
|
||||
ws_row.addWidget(self.workspace_combo, 1)
|
||||
root.addLayout(ws_row)
|
||||
self.tabs = QTabWidget()
|
||||
root.addWidget(self.tabs, 1)
|
||||
|
||||
# ---- tab 1: AI gen ------------------------------------------------
|
||||
ai_page = QWidget()
|
||||
al = QVBoxLayout(ai_page)
|
||||
al.addWidget(QLabel(tr("schedtask.ai_desc_label")))
|
||||
self.desc_edit = QPlainTextEdit()
|
||||
self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph"))
|
||||
self.desc_edit.setMaximumHeight(110)
|
||||
al.addWidget(self.desc_edit)
|
||||
# Attachments (files + links) — merged into every task this generates,
|
||||
# AND into the planning prompt so the AI knows they exist.
|
||||
attach_row = QHBoxLayout()
|
||||
self.ai_files_edit = QLineEdit()
|
||||
self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder"))
|
||||
ai_pick_btn = QPushButton(tr("schedtask.pick_files"))
|
||||
ai_pick_btn.setIcon(icon("folder"))
|
||||
ai_pick_btn.clicked.connect(self._ai_pick_files)
|
||||
attach_row.addWidget(self.ai_files_edit, 1)
|
||||
attach_row.addWidget(ai_pick_btn)
|
||||
al.addWidget(QLabel(tr("schedtask.f_files")))
|
||||
al.addLayout(attach_row)
|
||||
self.ai_links_edit = QLineEdit()
|
||||
self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder"))
|
||||
al.addWidget(QLabel(tr("schedtask.f_links")))
|
||||
al.addWidget(self.ai_links_edit)
|
||||
self.gen_btn = QPushButton(tr("schedtask.ai_generate"))
|
||||
self.gen_btn.setIcon(icon("sparkle"))
|
||||
self.gen_btn.setObjectName("primary")
|
||||
self.gen_btn.clicked.connect(self._generate)
|
||||
al.addWidget(self.gen_btn)
|
||||
al.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
||||
self.preview = QPlainTextEdit()
|
||||
self.preview.setReadOnly(True)
|
||||
al.addWidget(self.preview, 1)
|
||||
self.tabs.addTab(ai_page, tr("schedtask.tab_ai"))
|
||||
|
||||
# ---- tab 2: Import from Excel --------------------------------------
|
||||
imp_page = QWidget()
|
||||
il = QVBoxLayout(imp_page)
|
||||
tpl_btn = QPushButton(tr("schedtask.export_template_btn"))
|
||||
tpl_btn.setIcon(icon("upload"))
|
||||
tpl_btn.clicked.connect(self._export_template)
|
||||
il.addWidget(tpl_btn)
|
||||
pick_row = QHBoxLayout()
|
||||
pick_btn = QPushButton(tr("schedtask.import_pick_btn"))
|
||||
pick_btn.setIcon(icon("folder"))
|
||||
pick_btn.clicked.connect(self._pick_import_file)
|
||||
pick_row.addWidget(pick_btn)
|
||||
pick_row.addStretch(1)
|
||||
il.addLayout(pick_row)
|
||||
self.drop_zone = _DropZone()
|
||||
self.drop_zone.setText(tr("schedtask.drop_hint"))
|
||||
self.drop_zone.file_dropped.connect(self._load_import_file)
|
||||
il.addWidget(self.drop_zone)
|
||||
il.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
||||
self.import_preview = QPlainTextEdit()
|
||||
self.import_preview.setReadOnly(True)
|
||||
il.addWidget(self.import_preview, 1)
|
||||
self.tabs.addTab(imp_page, tr("schedtask.tab_import"))
|
||||
|
||||
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm"))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||
self.buttons.accepted.connect(self._confirm)
|
||||
self.buttons.rejected.connect(self.reject)
|
||||
root.addWidget(self.buttons)
|
||||
|
||||
# ---- Import tab ------------------------------------------------------
|
||||
def _export_template(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from ..core.task_excel import export_template
|
||||
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, tr("schedtask.export_template_btn"),
|
||||
"cowork_tasks_template.xlsx", "Excel (*.xlsx)")
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
export_template(path)
|
||||
open_path(str(Path(path).parent))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc))
|
||||
|
||||
def _pick_import_file(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from ..core.task_import import IMPORT_FILTER
|
||||
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, tr("schedtask.import_pick_btn"), "", IMPORT_FILTER)
|
||||
if path:
|
||||
self._load_import_file(path)
|
||||
|
||||
def _load_import_file(self, path: str) -> None:
|
||||
from ..core.task_import import import_tasks
|
||||
|
||||
try:
|
||||
self._planned = import_tasks(path)
|
||||
except ValueError as exc:
|
||||
self.import_preview.setPlainText(str(exc))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||
return
|
||||
by_id = {t["task_id"]: t["title"] for t in self._planned}
|
||||
lines = []
|
||||
for i, t in enumerate(self._planned, 1):
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
||||
deps = t.get("dependency", {}).get("depends_on") or []
|
||||
dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else ""
|
||||
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
||||
f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}")
|
||||
self.import_preview.setPlainText("\n\n".join(lines))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned))
|
||||
|
||||
def _ai_pick_files(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files"))
|
||||
if files:
|
||||
existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()]
|
||||
self.ai_files_edit.setText("; ".join(existing + files))
|
||||
|
||||
def _attached_files(self) -> List[str]:
|
||||
return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()]
|
||||
|
||||
def _attached_links(self) -> List[str]:
|
||||
return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()]
|
||||
|
||||
def _generate(self) -> None:
|
||||
description = self.desc_edit.toPlainText().strip()
|
||||
if not description or self._worker is not None:
|
||||
return
|
||||
files, links = self._attached_files(), self._attached_links()
|
||||
self.gen_btn.setEnabled(False)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generating"))
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from ..core.ai_task_planner import plan_tasks
|
||||
|
||||
provider = self.ctx.build_active_provider()
|
||||
full_desc = description
|
||||
if files or links:
|
||||
attach_note = "; ".join(files + links)
|
||||
full_desc += f"\n\n(Attached references available: {attach_note})"
|
||||
planned = plan_tasks(provider, full_desc, cancel=worker.is_cancelled)
|
||||
# Attachments apply to every generated task so they're available
|
||||
# at RUN time too, not just visible to the planner.
|
||||
for t in planned:
|
||||
t["input"]["file_paths"] = list(files)
|
||||
t["input"]["links"] = list(links)
|
||||
return {"tasks": planned}
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(self._on_planned)
|
||||
w.failed.connect(self._on_failed)
|
||||
self._worker = w
|
||||
w.start()
|
||||
|
||||
def _on_planned(self, result: dict) -> None:
|
||||
self._worker = None
|
||||
self.gen_btn.setEnabled(True)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
||||
self._planned = result.get("tasks") or []
|
||||
lines = []
|
||||
for i, t in enumerate(self._planned, 1):
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
||||
dep = t.get("dependency", {})
|
||||
chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else ""
|
||||
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
||||
f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n"
|
||||
f" {t.get('description', '')[:150]}")
|
||||
self.preview.setPlainText("\n\n".join(lines))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned))
|
||||
|
||||
def _on_failed(self, err: str) -> None:
|
||||
self._worker = None
|
||||
self.gen_btn.setEnabled(True)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
||||
self.preview.setPlainText(str(err))
|
||||
|
||||
def _confirm(self) -> None:
|
||||
project_id = self.workspace_combo.currentData() or ""
|
||||
for t in self._planned:
|
||||
t["project_id"] = project_id
|
||||
self.created_tasks = self._planned
|
||||
self.accept()
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -220,7 +220,7 @@ class WorkspaceTab(QWidget):
|
||||
# Folder — a two-pane file explorer (tree + view/edit) placed right below
|
||||
# Co4E. Always available (not project-gated); its root follows the
|
||||
# selected project's workspace folder when one is chosen.
|
||||
from .folder_tab import FolderTab
|
||||
from ..presentation.folder.folder_tab import FolderTab
|
||||
|
||||
self._folder = FolderTab(self.ctx, cowork=self._cowork)
|
||||
self._folder.status_message.connect(self.status_message)
|
||||
|
||||
Reference in New Issue
Block a user