feat(R08): split ScheduleTaskTab, FolderTab, DashboardTab, StructureGraphView

Team Hoa, EPIC R08 (UI/Application Separation) - Team Hoa scope only
(R08-T11 -> T14; R08-T01->T10 belong to Team Duy/Team Nam).

- R08-T11: ui/schedule_task_tab.py (795 lines) -> presentation/scheduling/
  {kanban_board_widget,calendar_view_widget,ai_task_creator_dialog,
  ai_task_import_dialog,run_history_dialog}.py + schedule_task_tab.py
  shell. Kanban CRUD/drag-drop now goes through
  application/scheduling/task_application_service.py (R07-T04) instead of
  ~30 lines of inline if/elif per drag target.
- R08-T12: ui/folder_tab.py (1587 lines, the largest of the four) ->
  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 + folder_tab.py shell.
  Closes the R06-T05 loop: FileWorkspaceService existed since R06 with
  zero production call sites (confirmed by grep); every plain-text write
  (save/create/write_content) now goes through it, gaining path
  containment and a Python-syntax warning the original code never had.
  Pure helpers (_read_text, _is_probably_text, _pptx_available,
  _split_code_block, _parse_ai_output) moved to
  application/workspaces/{file_preview_helpers,ai_edit_output}.py.
- R08-T13: ui/dashboard_tab.py (437 lines) -> presentation/dashboard/
  {token_usage_card_widget,usage_chart_widget,habits_widget}.py +
  dashboard_tab.py shell, backed by a new
  application/monitoring/dashboard_query_service.py (pricing/period/
  summary queries the three widgets used to each recompute separately).
  Directory-ownership note left in the checklist for Team Nam.
- R08-T14: ui/structure_graph_view.py (1035 lines) ->
  presentation/graph/{graph_scene_items,graph_renderer,
  graph_messages_view,graph_qa_widget}.py + structure_graph_view.py
  shell. Extraction helpers (_pdf_to_markdown, _extract_file_contents)
  moved to application/workspaces/graph_index_service.py (pure Python).
  Renderer and Q&A panel talk only through signals
  (node_selected/graph_rendered/raw_json_ready/project_changed) - neither
  imports the other.
- presentation/shared/web_engine_support.py: HAS_WEB_ENGINE, previously
  duplicated (folder_tab imported it FROM structure_graph_view.py) - now
  one shared flag instead of one screen importing another screen's module.

All four old ui/*.py files deleted; app.py and ui/workspace_tab.py updated
to the new import paths (each god-file only had 1-2 real construction
sites, so import sites were updated directly rather than kept as a
strangler-fig shim - unlike core/tools.py at R05, which had dozens).

pytest: 377 pass (+94 vs the R07 baseline of 328; same 4 pre-existing
failures as the R05/R06 baseline, unrelated to this work).
scripts/check_imports.py: PASS. python -c "import cowork_local.app": OK.
Every new file < 400 lines (largest: graph_renderer.py, 391).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 20:55:32 +09:00
co-authored by Claude Sonnet 5
parent 69ab8e125b
commit 0e51356a7d
51 changed files with 5746 additions and 3877 deletions
+3 -3
View File
@@ -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"
+18
View File
@@ -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"]
+14 -2
View File
@@ -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",
]
+40
View File
@@ -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"]
+36 -8
View File
@@ -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_R07_R08.md` — 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ệ)
@@ -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`*
---
+7
View File
@@ -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.
"""
+3
View File
@@ -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."""
+91
View File
@@ -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"]
+171
View File
@@ -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"]
+4
View File
@@ -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"]
+372
View File
@@ -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"]
+190
View File
@@ -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"]
+120
View File
@@ -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"]
+3
View File
@@ -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."""
+99
View File
@@ -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"]
+374
View File
@@ -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"]
+391
View File
@@ -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"]
+142
View File
@@ -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"]
+3
View File
@@ -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"]
+14
View File
@@ -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"]
+38
View File
@@ -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"]
+142
View File
@@ -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)
+193
View File
@@ -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()
+11 -6
View File
@@ -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]
+125
View File
@@ -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 == {}
+103
View File
@@ -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"
+44
View File
@@ -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
-438
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-794
View File
@@ -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
View File
@@ -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)