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:
@@ -23,13 +23,13 @@ from .state import AppContext
|
||||
from .ui.widgets import tidy_popup
|
||||
from .theme import current_palette, set_active_theme, stylesheet
|
||||
from .core.task_scheduler import TaskScheduler
|
||||
from .presentation.dashboard.dashboard_tab import DashboardTab
|
||||
from .presentation.graph.structure_graph_view import StructureGraphView
|
||||
from .presentation.scheduling.schedule_task_tab import ScheduleTaskTab
|
||||
from .ui.cowork_tab import CoworkTab
|
||||
from .ui.dashboard_tab import DashboardTab
|
||||
from .ui.monitoring_tab import MonitoringTab
|
||||
from .ui.schedule_task_tab import ScheduleTaskTab
|
||||
from .ui.settings_dialog import SettingsDialog
|
||||
from .ui.sidebar import HistorySidebar
|
||||
from .ui.structure_graph_view import StructureGraphView
|
||||
from .ui.workspace_tab import WorkspaceTab
|
||||
|
||||
ASSETS = Path(__file__).resolve().parent / "assets"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Read-only query services for monitoring/dashboard screens (EPIC R08).
|
||||
|
||||
⚠️ Ownership note (R08-T13): per ``docs/refactor/Feature_Architecture_
|
||||
Proposal.md``'s file-split diagram, ``dashboard_query_service.py`` lives
|
||||
under ``application/monitoring/`` alongside the Dashboard split — but the
|
||||
SAME document's "Ranh giới phân hệ" table assigns ``application/monitoring/``
|
||||
to Team Nam (R08-T07→T10, Monitoring's own 8-tab split). This directory did
|
||||
not exist yet when Team Hoa reached R08-T13, so creating it here does not
|
||||
collide with any file Team Nam has written — same situation R06-T02 flagged
|
||||
for ``infrastructure/persistence/json/atomic_write.py`` vs. Team Nam's
|
||||
planned ``atomic_json_file.py``. Team Nam should confirm when they start
|
||||
R08-T07→T10 whether ``DashboardQueryService`` belongs here permanently or
|
||||
should move once Monitoring's own query service exists.
|
||||
"""
|
||||
|
||||
from .dashboard_query_service import DashboardQueryService
|
||||
|
||||
__all__ = ["DashboardQueryService"]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""DashboardQueryService - read-only usage/cost queries for the Dashboard
|
||||
screen (R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``, lines
|
||||
196-199/256-261/284-322 of the original 437-line file: ``_pricing``,
|
||||
``_period_range``'s date-math, and the ``period_totals``/``period_breakdown``
|
||||
calls ``_refresh_chart`` made directly).
|
||||
|
||||
``ui/dashboard_tab.py`` called ``core/usage_tracker.py``/``core/model_
|
||||
pricing.py`` directly from FIVE different methods spread across what is now
|
||||
three widgets (``token_usage_card_widget.py``, ``usage_chart_widget.py``,
|
||||
``habits_widget.py``) — each recomputing the same merged pricing dict. This
|
||||
service is the one place that merge happens now; the three widgets share it
|
||||
instead of each calling ``core.usage_tracker``/``core.model_pricing`` on
|
||||
their own.
|
||||
|
||||
Pure Python: no Qt. Wraps ``core/usage_tracker.py`` (a plain-Python module
|
||||
already) rather than reimplementing any of its date/cost math.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
|
||||
class DashboardQueryService:
|
||||
"""Usage/cost queries scoped to one ``AppContext``.
|
||||
|
||||
Args:
|
||||
ctx: ``AppContext`` — read for ``ctx.config`` (pricing table,
|
||||
currency, budget) and nothing else; this class does no I/O of
|
||||
its own beyond what ``core.usage_tracker`` already does.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: Any) -> None:
|
||||
self.ctx = ctx
|
||||
|
||||
def pricing(self) -> Dict[str, Any]:
|
||||
"""The merged price table (defaults + user overrides), synced from
|
||||
Monitoring's model-pricing table first so cost figures always agree
|
||||
between the two screens."""
|
||||
from cowork_local.core import model_pricing as mp
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
mp.sync_to_usage(self.ctx.config)
|
||||
return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||||
|
||||
def period_range(self, granularity: str, offset: int) -> Tuple[date, date]:
|
||||
"""The SELECTED period as an inclusive ``(start, end)`` date range —
|
||||
drives every widget on the screen (cards, chart, habits)."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
start, end = ut.period_bounds(granularity, offset)
|
||||
return start, end - timedelta(days=1) # load_events end is inclusive
|
||||
|
||||
def summary(self, start: date, end: date) -> Dict[str, Any]:
|
||||
"""Everything the stat cards + habits panel need for one period:
|
||||
the raw events, ``usage_tracker.summarize``'s aggregate stats, the
|
||||
per-bucket costs, and their total — computed once so both widgets
|
||||
read the same numbers instead of loading events twice."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
events = ut.load_events(start, end)
|
||||
pricing = self.pricing()
|
||||
stats = ut.summarize(events)
|
||||
costs = ut.cost_usd_events(events, pricing)
|
||||
return {
|
||||
"events": events,
|
||||
"pricing": pricing,
|
||||
"stats": stats,
|
||||
"costs": costs,
|
||||
"total_cost": sum(costs.values()),
|
||||
}
|
||||
|
||||
def chart_series(self, granularity: str, offset: int, metric: str
|
||||
) -> List[Tuple[str, float]]:
|
||||
"""``(label, value)`` points for the spline chart — WEEK -> 7 days,
|
||||
MONTH -> weeks, YEAR -> 12 months, in whichever ``metric``
|
||||
("tokens" | "cost") was selected."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
events = ut.load_events() # all events; breakdown slices by period
|
||||
pricing = self.pricing()
|
||||
parts = ut.period_breakdown(events, granularity, pricing, offset=offset)
|
||||
mi = 0 if metric == "tokens" else 1 # (label, tokens, cost) -> +1 for the value
|
||||
return [(row[0], float(row[mi + 1])) for row in parts]
|
||||
|
||||
def period_totals(self, granularity: str, offset: int) -> Tuple[float, float]:
|
||||
"""``(tokens, cost)`` totals for one period — used to compute the
|
||||
vs-previous-period delta the chart's reference line shows."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
events = ut.load_events()
|
||||
return ut.period_totals(events, granularity, self.pricing(), offset)
|
||||
|
||||
def period_range_label(self, granularity: str, offset: int) -> str:
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
return ut.period_range_label(granularity, offset)
|
||||
|
||||
def budget_status(self):
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
return ut.budget_status(self.ctx.config)
|
||||
|
||||
def set_budget(self, amount: float, currency: str) -> None:
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
ut.set_budget(self.ctx.config, amount, currency)
|
||||
|
||||
|
||||
__all__ = ["DashboardQueryService"]
|
||||
@@ -1,5 +1,17 @@
|
||||
"""Workspace file operations for non-agent-loop callers (EPIC R06)."""
|
||||
"""Workspace file operations for non-agent-loop callers (EPIC R06, R08)."""
|
||||
|
||||
from .ai_edit_output import parse_ai_output, split_code_block
|
||||
from .file_preview_helpers import is_probably_text, pptx_available, read_text
|
||||
from .file_workspace_service import FileWorkspaceService
|
||||
from .graph_index_service import extract_file_contents, pdf_to_markdown
|
||||
|
||||
__all__ = ["FileWorkspaceService"]
|
||||
__all__ = [
|
||||
"FileWorkspaceService",
|
||||
"read_text",
|
||||
"is_probably_text",
|
||||
"pptx_available",
|
||||
"split_code_block",
|
||||
"parse_ai_output",
|
||||
"pdf_to_markdown",
|
||||
"extract_file_contents",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Parse an AI file-edit reply into its parts (R08-T12, moved out of
|
||||
``ui/folder_tab.py`` — that file's module-level ``_split_code_block``/
|
||||
``_parse_ai_output``, lines 1536-1562 of the original 1587-line file). Pure
|
||||
string parsing, no Qt — used by ``presentation/folder/ai_file_editor_dialog.py``
|
||||
to turn a model's raw reply into a proposed edit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
def split_code_block(text: str) -> Tuple[Optional[str], str]:
|
||||
"""Split an AI reply into ``(file_content, summary)``. ``file_content``
|
||||
is the first fenced code block (the edited file); ``summary`` is any
|
||||
prose before it. Returns ``(None, text)`` when there's no code block."""
|
||||
m = re.search(r"```[^\n]*\n(.*?)```", text or "", re.DOTALL)
|
||||
if not m:
|
||||
return None, (text or "")
|
||||
return m.group(1), (text[:m.start()].strip())
|
||||
|
||||
|
||||
def parse_ai_output(text: str) -> Tuple[Optional[str], Optional[str], str, List[Tuple[str, str]]]:
|
||||
"""Parse an AI edit reply into ``(target, content, summary, image_gens)``.
|
||||
``FILE: <path>`` names a NEW file to create; ``IMAGE_GEN: <prompt> =>
|
||||
<path>`` lines request generated illustration images (relative paths)."""
|
||||
content, summary = split_code_block(text)
|
||||
target = None
|
||||
m = re.search(r"(?mi)^\s*FILE:\s*(.+?)\s*$", text or "")
|
||||
if m:
|
||||
target = m.group(1).strip().strip("`\"'")
|
||||
image_gens = []
|
||||
for gm in re.finditer(r"(?mi)^\s*IMAGE_GEN:\s*(.+?)\s*=>\s*(\S+)\s*$", text or ""):
|
||||
image_gens.append((gm.group(1).strip(), gm.group(2).strip().strip("`\"'")))
|
||||
# Strip the directive lines out of the shown summary.
|
||||
summary = re.sub(r"(?mi)^\s*(FILE|IMAGE_GEN):\s*.+?$", "", summary).strip()
|
||||
return target, content, summary, image_gens
|
||||
|
||||
|
||||
__all__ = ["split_code_block", "parse_ai_output"]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Pure helpers for previewing a file (R08-T12, moved out of
|
||||
``ui/folder_tab.py`` — that file's module-level functions
|
||||
``_read_text``/``_is_probably_text``/``_pptx_available``, lines 1519-1533 and
|
||||
1565-1587 of the original 1587-line file). No Qt, no widget state — the
|
||||
"is this file text? is pptx editing available?" questions the preview
|
||||
manager asks before it decides how to render something.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_PPTX_READY = None # cached: pptx-editing library available (after auto-install)
|
||||
|
||||
|
||||
def pptx_available() -> bool:
|
||||
"""True when python-pptx is importable. If it's MISSING, auto-download &
|
||||
install it (via deps.ensure_module) so pptx editing 'just works' — cached
|
||||
so the (one-time) install is attempted only once."""
|
||||
global _PPTX_READY
|
||||
if _PPTX_READY is None:
|
||||
try:
|
||||
from cowork_local.core.deps import ensure_module
|
||||
|
||||
_PPTX_READY = ensure_module("pptx", "python-pptx") is not None
|
||||
except Exception: # noqa: BLE001
|
||||
_PPTX_READY = False
|
||||
return _PPTX_READY
|
||||
|
||||
|
||||
def read_text(path: str) -> str:
|
||||
try:
|
||||
return Path(path).read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
return f"[could not read file: {exc}]"
|
||||
|
||||
|
||||
def is_probably_text(path: str) -> bool:
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
chunk = f.read(4096)
|
||||
except OSError:
|
||||
return False
|
||||
if b"\x00" in chunk:
|
||||
return False
|
||||
try:
|
||||
chunk.decode("utf-8")
|
||||
return True
|
||||
except UnicodeDecodeError:
|
||||
# Latin-ish text still edits fine via errors="replace"; only reject on
|
||||
# a hard binary signal (NUL above), so most source files pass.
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["pptx_available", "read_text", "is_probably_text"]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Temporary file-content extraction for Graph-RAG Q&A (R08-T14, moved out
|
||||
of ``ui/structure_graph_view.py`` — that file's module-level
|
||||
``_pdf_to_markdown``/``_extract_file_contents``, lines 964-1034 of the
|
||||
original 1035-line file). Runs inside the ask worker's job function so the
|
||||
answer is synthesized from real file content, not just the graph structure.
|
||||
|
||||
Pure Python: no Qt. Best-effort throughout (never raises) — a failed
|
||||
extraction degrades to "no content for this file", not a broken Q&A turn.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
def pdf_to_markdown(pdf_path: str, out_dir: str) -> Optional[str]:
|
||||
"""Convert a PDF to Markdown with opendataloader-pdf when available
|
||||
(richer structure than a plain text dump). Best-effort — returns None
|
||||
if the package isn't installed or the call fails, so the caller falls
|
||||
back to ``core/doc_extract.py``."""
|
||||
try:
|
||||
import opendataloader_pdf # optional; auto-installed elsewhere if present
|
||||
except Exception: # noqa: BLE001
|
||||
try:
|
||||
from cowork_local.core.deps import ensure_module
|
||||
if ensure_module("opendataloader_pdf", "opendataloader-pdf") is None:
|
||||
return None
|
||||
import opendataloader_pdf # noqa: F811
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
out = Path(out_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
for call in (
|
||||
lambda: opendataloader_pdf.convert(input_path=[str(pdf_path)], output_dir=str(out),
|
||||
generate_markdown=True),
|
||||
lambda: opendataloader_pdf.convert(input_path=str(pdf_path), output_dir=str(out)),
|
||||
lambda: opendataloader_pdf.convert(str(pdf_path), str(out)),
|
||||
):
|
||||
try:
|
||||
call()
|
||||
break
|
||||
except TypeError:
|
||||
continue
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
mds = list(out.rglob(Path(pdf_path).stem + "*.md")) or list(out.rglob("*.md"))
|
||||
for md in mds:
|
||||
try:
|
||||
return md.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def extract_file_contents(paths: List[str], cache: Dict[str, str], tmp_dir: str,
|
||||
max_files: int = 15, max_total: int = 120_000
|
||||
) -> Tuple[str, Dict[str, str]]:
|
||||
"""Read the ACTUAL content of ``paths`` (PDF -> markdown via
|
||||
opendataloader when available, else ``doc_extract`` for office/pdf/
|
||||
text). Returns ``(block, cache)`` — ``block`` is the concatenated
|
||||
content for the prompt (bounded), ``cache`` maps path -> text for
|
||||
reuse. Never raises."""
|
||||
from cowork_local.core import doc_extract
|
||||
|
||||
cache = dict(cache or {})
|
||||
parts, total = [], 0
|
||||
for p in paths[:max_files]:
|
||||
if total >= max_total:
|
||||
break
|
||||
text = cache.get(p)
|
||||
if text is None:
|
||||
try:
|
||||
if Path(p).suffix.lower() == ".pdf":
|
||||
text = pdf_to_markdown(p, tmp_dir)
|
||||
if not text:
|
||||
text, _n = doc_extract.extract_text(p)
|
||||
else:
|
||||
text, _n = doc_extract.extract_text(p)
|
||||
except Exception: # noqa: BLE001
|
||||
text = ""
|
||||
cache[p] = text or ""
|
||||
text = cache.get(p) or ""
|
||||
if not text:
|
||||
continue
|
||||
chunk = text[: max(0, max_total - total)]
|
||||
total += len(chunk)
|
||||
parts.append(f'--- {Path(p).name} ({p}) ---\n{chunk}')
|
||||
return ("\n\n".join(parts), cache)
|
||||
|
||||
|
||||
__all__ = ["pdf_to_markdown", "extract_file_contents"]
|
||||
@@ -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`*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Presentation layer: Qt widgets, one screen/concern per file, assembled
|
||||
into thin shell containers (EPIC R08).
|
||||
|
||||
Nothing under here is imported by ``domain/`` or ``application/``
|
||||
(``scripts/check_imports.py`` rule I3) — data flows the other way, through
|
||||
application services these widgets call.
|
||||
"""
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Dashboard screen, split into single-responsibility widgets (R08-T13):
|
||||
``token_usage_card_widget``, ``usage_chart_widget``, ``habits_widget``,
|
||||
assembled by the ``dashboard_tab`` shell."""
|
||||
@@ -0,0 +1,91 @@
|
||||
"""DashboardTab shell (R08-T13) — assembles
|
||||
``token_usage_card_widget.py::TokenUsageCardWidget``,
|
||||
``usage_chart_widget.py::UsageChartWidget`` and
|
||||
``habits_widget.py::HabitsWidget`` behind the scroll area / header / 30s
|
||||
auto-refresh timer that used to be inline in
|
||||
``ui/dashboard_tab.py::DashboardTab.__init__`` (lines 40-193 of the original
|
||||
437-line file).
|
||||
|
||||
The one ``DashboardQueryService`` (R08-T13) instance is built here and
|
||||
shared by all three children so pricing/currency stay consistent across the
|
||||
whole screen.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QTimer, Signal
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QScrollArea, QVBoxLayout, QWidget
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.dashboard.habits_widget import HabitsWidget
|
||||
from cowork_local.presentation.dashboard.token_usage_card_widget import TokenUsageCardWidget
|
||||
from cowork_local.presentation.dashboard.usage_chart_widget import UsageChartWidget
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class DashboardTab(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._query = DashboardQueryService(ctx)
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
content = QWidget()
|
||||
scroll.setWidget(content)
|
||||
outer.addWidget(scroll)
|
||||
root = QVBoxLayout(content)
|
||||
|
||||
head = QHBoxLayout()
|
||||
self._title = QLabel()
|
||||
self._title.setStyleSheet("font-weight:700; font-size:15px;")
|
||||
self.refresh_btn = QPushButton("")
|
||||
self.refresh_btn.setIcon(icon("refresh"))
|
||||
self.refresh_btn.setFixedWidth(34)
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
head.addWidget(self._title, 1)
|
||||
head.addWidget(self.refresh_btn)
|
||||
root.addLayout(head)
|
||||
|
||||
self.token_cards = TokenUsageCardWidget(ctx, self._query)
|
||||
root.addWidget(self.token_cards)
|
||||
|
||||
self.chart = UsageChartWidget(ctx, self._query)
|
||||
self.chart.period_changed.connect(self.refresh)
|
||||
self.chart.currency_changed.connect(self.refresh)
|
||||
root.addWidget(self.chart)
|
||||
|
||||
self.habits = HabitsWidget(ctx, self._query)
|
||||
self.habits.status_message.connect(self.status_message.emit)
|
||||
root.addWidget(self.habits, 1)
|
||||
|
||||
# Auto-refresh every 30s so numbers follow ongoing work.
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(30_000)
|
||||
self._timer.timeout.connect(self.refresh)
|
||||
self._timer.start()
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
self.refresh()
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self._title.setText(tr("dashboard.title"))
|
||||
self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip"))
|
||||
self.token_cards.retranslate()
|
||||
self.chart.retranslate()
|
||||
self.habits.retranslate()
|
||||
self.refresh()
|
||||
|
||||
def refresh(self, *_a) -> None:
|
||||
start, end = self.chart.period_range()
|
||||
self.token_cards.refresh(start, end)
|
||||
self.chart.refresh()
|
||||
self.habits.refresh(start, end)
|
||||
|
||||
|
||||
__all__ = ["DashboardTab"]
|
||||
@@ -0,0 +1,171 @@
|
||||
"""HabitsWidget — the usage-habits summary + AI recommendations panel of the
|
||||
Dashboard (R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``,
|
||||
lines 154-184/348-372/376-438 of the original 437-line file: the habits/AI
|
||||
layout, ``refresh()``'s habits-HTML section, ``_apply_saving_strategy``,
|
||||
``_ai_analyze``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QTextBrowser, QVBoxLayout, QWidget
|
||||
from PySide6.QtCore import Signal
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.widgets import fmt_tokens
|
||||
|
||||
|
||||
class HabitsWidget(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx, query: DashboardQueryService, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._query = query
|
||||
self._ai_worker: Optional[AgentWorker] = None
|
||||
self._period_range = (None, None) # set on each refresh(); _ai_analyze reuses it
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
self._habits_title = QLabel()
|
||||
self._habits_title.setStyleSheet("font-weight:600;")
|
||||
habits_head = QHBoxLayout()
|
||||
self.ai_analyze_btn = QPushButton()
|
||||
self.ai_analyze_btn.setIcon(icon("sparkle"))
|
||||
self.ai_analyze_btn.clicked.connect(self._ai_analyze)
|
||||
# Apply an AI-suggested cost-saving strategy — only after the user
|
||||
# clicks to approve it.
|
||||
self.apply_strategy_btn = QPushButton()
|
||||
self.apply_strategy_btn.setIcon(icon("bolt"))
|
||||
self.apply_strategy_btn.setVisible(False)
|
||||
self.apply_strategy_btn.clicked.connect(self._apply_saving_strategy)
|
||||
habits_head.addWidget(self._habits_title, 1)
|
||||
habits_head.addWidget(self.apply_strategy_btn)
|
||||
habits_head.addWidget(self.ai_analyze_btn)
|
||||
root.addLayout(habits_head)
|
||||
self.habits = QTextBrowser()
|
||||
self.habits.setOpenExternalLinks(False)
|
||||
self.habits.setMinimumHeight(160)
|
||||
root.addWidget(self.habits, 1)
|
||||
self._ai_title = QLabel()
|
||||
self._ai_title.setStyleSheet("font-weight:600;")
|
||||
self._ai_title.setVisible(False)
|
||||
root.addWidget(self._ai_title)
|
||||
self.ai_advice = QTextBrowser()
|
||||
self.ai_advice.setOpenExternalLinks(False)
|
||||
self.ai_advice.setMinimumHeight(140)
|
||||
self.ai_advice.setVisible(False)
|
||||
root.addWidget(self.ai_advice, 1)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
self.ai_analyze_btn.setToolTip(tr("dashboard.ai_analyze_tooltip"))
|
||||
self.apply_strategy_btn.setText(tr("dashboard.strategy_btn"))
|
||||
self.apply_strategy_btn.setToolTip(tr("dashboard.strategy_tooltip"))
|
||||
self._habits_title.setText(tr("dashboard.habits_title"))
|
||||
|
||||
def refresh(self, start: date, end: date) -> None:
|
||||
self._period_range = (start, end)
|
||||
summary = self._query.summary(start, end)
|
||||
s, events = summary["stats"], summary["events"]
|
||||
|
||||
lines: List[str] = []
|
||||
if not events:
|
||||
lines.append(f"<i>{tr('dashboard.no_data')}</i>")
|
||||
else:
|
||||
lines.append(f"<b>{tr('dashboard.h_top')}</b>")
|
||||
lines.append("<ol>")
|
||||
for label, tok in s["top_labels"]:
|
||||
pct = int(tok * 100 / s["total"]) if s["total"] else 0
|
||||
lines.append(f"<li>{label[:60]} — {fmt_tokens(tok)} tokens ({pct}%)</li>")
|
||||
lines.append("</ol>")
|
||||
src_parts = ", ".join(
|
||||
f"{tr(f'app.tab.{k}') if k in ('cowork', 'code') else k}: {fmt_tokens(v)}"
|
||||
for k, v in s["by_source"])
|
||||
lines.append(f"<b>{tr('dashboard.h_by_source')}</b>: {src_parts}<br>")
|
||||
lines.append(f"<b>{tr('dashboard.h_avg')}</b>: "
|
||||
f"{fmt_tokens(s['avg_per_turn'])} tokens<br>")
|
||||
if s["busiest_day"]:
|
||||
lines.append(f"<b>{tr('dashboard.h_busiest_day')}</b>: {s['busiest_day']}<br>")
|
||||
if s["busiest_hour"] is not None:
|
||||
lines.append(f"<b>{tr('dashboard.h_busiest_hour')}</b>: "
|
||||
f"{s['busiest_hour']:02d}:00–{s['busiest_hour']:02d}:59<br>")
|
||||
if s["estimated_share"] > 0:
|
||||
lines.append(f"<i>{tr('dashboard.estimated_note', pct=int(s['estimated_share'] * 100))}</i>")
|
||||
self.habits.setHtml("".join(lines))
|
||||
|
||||
def _apply_saving_strategy(self) -> None:
|
||||
"""Apply an AI-suggested cost-saving strategy AFTER the user
|
||||
approves: turn on auto-compress and compress earlier (lower
|
||||
threshold) + compress content before sending it to the agent."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
if QMessageBox.question(self, tr("dashboard.strategy_title"),
|
||||
tr("dashboard.strategy_confirm")) != QMessageBox.Yes:
|
||||
return
|
||||
cx = self.ctx.config.data.setdefault("context", {})
|
||||
cx["auto_compact"] = True
|
||||
cx["compact_threshold"] = 0.6 # compress at 60% of the window (was ~80%)
|
||||
cx["compress_before_send"] = True # digest context before each turn
|
||||
self.ctx.save()
|
||||
self.status_message.emit(tr("dashboard.strategy_applied"))
|
||||
|
||||
def _ai_analyze(self) -> None:
|
||||
"""✨ Send the aggregated numbers (never raw prompt text) to the
|
||||
active provider and show habit feedback + token-saving
|
||||
recommendations."""
|
||||
if self._ai_worker is not None:
|
||||
return
|
||||
start, end = self._period_range
|
||||
if start is None:
|
||||
return
|
||||
summary = self._query.summary(start, end)
|
||||
if not summary["events"]:
|
||||
self.status_message.emit(tr("dashboard.no_data"))
|
||||
return
|
||||
stats = summary["stats"]
|
||||
self.ai_analyze_btn.setEnabled(False)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyzing"))
|
||||
ctx = self.ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
from cowork_local.i18n import get_language
|
||||
|
||||
prompt = ut.build_ai_analysis_prompt(stats, get_language())
|
||||
provider = ctx.build_active_provider()
|
||||
reply = provider.chat([{"role": "user", "content": prompt}],
|
||||
cancel=worker.stop_event)
|
||||
return {"text": (reply.get("content") or "").strip()}
|
||||
|
||||
def done(result: dict) -> None:
|
||||
self._ai_worker = None
|
||||
self.ai_analyze_btn.setEnabled(True)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
text = result.get("text") or ""
|
||||
if text:
|
||||
self._ai_title.setText(tr("dashboard.ai_advice_title"))
|
||||
self._ai_title.setVisible(True)
|
||||
self.ai_advice.setMarkdown(text)
|
||||
self.ai_advice.setVisible(True)
|
||||
self.apply_strategy_btn.setVisible(True)
|
||||
|
||||
def failed(err: str) -> None:
|
||||
self._ai_worker = None
|
||||
self.ai_analyze_btn.setEnabled(True)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
self.status_message.emit(str(err))
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._ai_worker = w
|
||||
w.start()
|
||||
|
||||
|
||||
__all__ = ["HabitsWidget"]
|
||||
@@ -0,0 +1,108 @@
|
||||
"""TokenUsageCardWidget — the stat-card grid + budget card of the Dashboard
|
||||
(R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``, lines
|
||||
116-141/226-254/337-346 of the original 437-line file: the card grid layout,
|
||||
``_apply_budget``, ``_refresh_budget``, and ``refresh()``'s card-filling
|
||||
section).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from PySide6.QtWidgets import QGridLayout, QWidget
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.widgets import BudgetCard, StatCard, fmt_tokens
|
||||
|
||||
|
||||
class TokenUsageCardWidget(QWidget):
|
||||
"""Cost is the headline this screen exists for, so it gets a card twice
|
||||
the height of the rest instead of being the fifth of five identical
|
||||
tiles — with six equal cards nothing said which number mattered."""
|
||||
|
||||
def __init__(self, ctx, query: DashboardQueryService, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._query = query
|
||||
|
||||
cards_grid = QGridLayout(self)
|
||||
cards_grid.setSpacing(8)
|
||||
self.card_total = StatCard()
|
||||
self.card_in = StatCard()
|
||||
self.card_out = StatCard()
|
||||
self.card_cache = StatCard()
|
||||
self.card_cost = StatCard().as_hero()
|
||||
# Hero on the left, spanning both rows; the four supporting figures
|
||||
# fill a 2x2 block beside it.
|
||||
cards_grid.addWidget(self.card_cost, 0, 0, 2, 1)
|
||||
for i, card in enumerate((self.card_total, self.card_in,
|
||||
self.card_out, self.card_cache)):
|
||||
cards_grid.addWidget(card, i // 2, 1 + i % 2)
|
||||
# Budget: remaining/budget, direct entry, auto-warns red past 85% used.
|
||||
self.budget_card = BudgetCard()
|
||||
self.budget_card.apply_btn.setIcon(icon("check"))
|
||||
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
|
||||
cards_grid.addWidget(self.budget_card, 0, 3, 2, 1)
|
||||
for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)):
|
||||
cards_grid.setColumnStretch(col, stretch)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
|
||||
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
|
||||
|
||||
def refresh(self, start: date, end: date) -> None:
|
||||
summary = self._query.summary(start, end)
|
||||
s, pricing, costs = summary["stats"], summary["pricing"], summary["costs"]
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
est_note = (tr("dashboard.estimated_note", pct=int(s["estimated_share"] * 100))
|
||||
if s["estimated_share"] > 0 else "")
|
||||
self.card_total.set(tr("dashboard.card_total"), fmt_tokens(s["total"]),
|
||||
tr("dashboard.card_turns", n=s["turns"]))
|
||||
self.card_in.set(tr("dashboard.card_in"), fmt_tokens(s["in"]),
|
||||
ut.format_cost(costs["in"], pricing))
|
||||
self.card_out.set(tr("dashboard.card_out"), fmt_tokens(s["out"]),
|
||||
ut.format_cost(costs["out"], pricing))
|
||||
self.card_cache.set(tr("dashboard.card_cache"), fmt_tokens(s["cache"]),
|
||||
ut.format_cost(costs["cache"], pricing))
|
||||
self.card_cost.set(tr("dashboard.card_cost"),
|
||||
ut.format_cost(summary["total_cost"], pricing, digits=2), est_note)
|
||||
self._refresh_budget()
|
||||
|
||||
def _apply_budget(self) -> None:
|
||||
"""Persist the spin box's value as the new budget — starts a fresh
|
||||
remaining-balance window (spend before now is no longer counted)."""
|
||||
ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD")
|
||||
self._query.set_budget(self.budget_card.budget_spin.value(), ccy)
|
||||
self.ctx.save()
|
||||
self._refresh_budget()
|
||||
|
||||
def _refresh_budget(self) -> None:
|
||||
from cowork_local.core import model_pricing as mp
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
pricing = self._query.pricing()
|
||||
status = self._query.budget_status()
|
||||
if status is None:
|
||||
self.budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget"))
|
||||
self.budget_card.budget_spin.setValue(0.0)
|
||||
return
|
||||
remaining_disp = mp.convert(status["remaining_usd"], "USD",
|
||||
pricing.get("currency", "USD"), self.ctx.config)
|
||||
amount_disp = mp.convert(status["amount_usd"], "USD",
|
||||
pricing.get("currency", "USD"), self.ctx.config)
|
||||
value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}"
|
||||
f" / {ut.format_cost(status['amount_usd'], pricing, digits=2)}")
|
||||
pct = int(round(status["pct_used"] * 100))
|
||||
sub = tr("usage.budget_over_warning") if status["over_85"] else tr("usage.budget_used_pct", pct=pct)
|
||||
self.budget_card.set(tr("usage.budget_title"), value, sub, warn=status["over_85"])
|
||||
# keep the entry field showing the CURRENT budget (in display currency)
|
||||
# — only when it doesn't already have unsaved focus/edits from the user.
|
||||
if not self.budget_card.budget_spin.hasFocus():
|
||||
self.budget_card.budget_spin.setValue(round(amount_disp, 2))
|
||||
|
||||
|
||||
__all__ = ["TokenUsageCardWidget"]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""UsageChartWidget — the period pager + granularity/metric/currency
|
||||
controls + spline chart of the Dashboard (R08-T13, extracted from
|
||||
``ui/dashboard_tab.py::DashboardTab``, lines 53-114/143-152/201-207/
|
||||
263-323 of the original 437-line file).
|
||||
|
||||
Owns the period SELECTOR (granularity + prev/next offset) that the whole
|
||||
screen follows — ``token_usage_card_widget.py`` and ``habits_widget.py``
|
||||
read :meth:`period_range`/:meth:`granularity` rather than keeping their own
|
||||
copy, and the shell re-refreshes them on :attr:`period_changed`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.spline_chart import SplineChart
|
||||
from cowork_local.ui.widgets import fmt_tokens
|
||||
|
||||
|
||||
class UsageChartWidget(QWidget):
|
||||
period_changed = Signal() # granularity or offset changed — re-run every widget
|
||||
currency_changed = Signal() # display currency changed — same, cost text depends on it
|
||||
|
||||
def __init__(self, ctx, query: DashboardQueryService, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._query = query
|
||||
self._chart_offset = 0 # 0 = current period; <0 = a past period
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
controls = QHBoxLayout()
|
||||
controls.setSpacing(6)
|
||||
self.chart_prev_btn = QPushButton()
|
||||
self.chart_prev_btn.setIcon(icon("chevron-left"))
|
||||
self.chart_prev_btn.setFixedWidth(30)
|
||||
self.chart_prev_btn.clicked.connect(self._chart_prev)
|
||||
controls.addWidget(self.chart_prev_btn)
|
||||
self._chart_period_lbl = QLabel()
|
||||
self._chart_period_lbl.setObjectName("hint")
|
||||
self._chart_period_lbl.setAlignment(Qt.AlignCenter)
|
||||
self._chart_period_lbl.setMinimumWidth(170)
|
||||
controls.addWidget(self._chart_period_lbl)
|
||||
self.chart_next_btn = QPushButton()
|
||||
self.chart_next_btn.setIcon(icon("chevron-right"))
|
||||
self.chart_next_btn.setFixedWidth(30)
|
||||
self.chart_next_btn.clicked.connect(self._chart_next)
|
||||
controls.addWidget(self.chart_next_btn)
|
||||
controls.addSpacing(12)
|
||||
self.gran_combo = QComboBox()
|
||||
for g in ("week", "month", "year"):
|
||||
self.gran_combo.addItem(tr(f"dashboard.gran_{g}"), g)
|
||||
self.gran_combo.currentIndexChanged.connect(self._on_gran_changed)
|
||||
controls.addWidget(self.gran_combo)
|
||||
self.metric_combo = QComboBox()
|
||||
for m in ("cost", "tokens"):
|
||||
self.metric_combo.addItem(tr(f"dashboard.metric_{m}"), m)
|
||||
self.metric_combo.currentIndexChanged.connect(self.refresh)
|
||||
controls.addWidget(self.metric_combo)
|
||||
controls.addStretch(1)
|
||||
# Display-currency picker — both Dashboard and Monitoring read/write
|
||||
# the same usage.currency config key, so changing it here updates
|
||||
# cost text everywhere.
|
||||
self.currency_lbl = QLabel()
|
||||
self.currency_lbl.setObjectName("hint")
|
||||
controls.addWidget(self.currency_lbl)
|
||||
self.currency_combo = QComboBox()
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
for cur in ut.SUPPORTED_CURRENCIES:
|
||||
self.currency_combo.addItem(cur, cur)
|
||||
idx = self.currency_combo.findData(
|
||||
(self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
|
||||
self.currency_combo.setCurrentIndex(max(0, idx))
|
||||
self.currency_combo.currentIndexChanged.connect(self._on_currency_changed)
|
||||
controls.addWidget(self.currency_combo)
|
||||
root.addLayout(controls)
|
||||
|
||||
chart_head = QHBoxLayout()
|
||||
self._chart_title = QLabel()
|
||||
self._chart_title.setStyleSheet("font-weight:600;")
|
||||
chart_head.addWidget(self._chart_title, 1)
|
||||
root.addLayout(chart_head)
|
||||
self.chart = SplineChart()
|
||||
root.addWidget(self.chart)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.currency_lbl.setText(tr("monitoring.overview_currency"))
|
||||
self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
|
||||
self._chart_title.setText(tr("dashboard.chart_title"))
|
||||
self.chart_prev_btn.setToolTip(tr("dashboard.chart_prev"))
|
||||
self.chart_next_btn.setToolTip(tr("dashboard.chart_next"))
|
||||
|
||||
# ---- public: the period selector every other widget follows ------------- #
|
||||
def granularity(self) -> str:
|
||||
return self.gran_combo.currentData() or "week"
|
||||
|
||||
@property
|
||||
def chart_offset(self) -> int:
|
||||
return self._chart_offset
|
||||
|
||||
def period_range(self) -> Tuple:
|
||||
return self._query.period_range(self.granularity(), self._chart_offset)
|
||||
|
||||
# ---- navigation ------------------------------------------------------------ #
|
||||
def _on_gran_changed(self, *_a) -> None:
|
||||
self._chart_offset = 0 # period size changed → back to current
|
||||
self.period_changed.emit()
|
||||
|
||||
def _chart_prev(self) -> None:
|
||||
self._chart_offset -= 1
|
||||
self.period_changed.emit()
|
||||
|
||||
def _chart_next(self) -> None:
|
||||
self._chart_offset = min(0, self._chart_offset + 1) # never past the present
|
||||
self.period_changed.emit()
|
||||
|
||||
def _on_currency_changed(self, _idx: int) -> None:
|
||||
cur = self.currency_combo.currentData()
|
||||
if not cur:
|
||||
return
|
||||
self.ctx.config.data.setdefault("usage", {})["currency"] = cur
|
||||
self.ctx.save()
|
||||
self.currency_changed.emit()
|
||||
|
||||
@staticmethod
|
||||
def _delta_txt(cur: float, prev: float) -> str:
|
||||
"""▲/▼ percent change of ``cur`` vs ``prev`` (empty if no baseline)."""
|
||||
if not prev:
|
||||
return ""
|
||||
pct = (cur - prev) / prev * 100
|
||||
arrow = "▲" if pct > 0.5 else ("▼" if pct < -0.5 else "•")
|
||||
return f"{arrow}{abs(pct):.0f}%"
|
||||
|
||||
# ---- rendering --------------------------------------------------------------- #
|
||||
def refresh(self, *_a) -> None:
|
||||
"""Break the SELECTED period into its parts: WEEK -> 7 days (Mon-Sun)
|
||||
- MONTH -> weeks W1..Wn - YEAR -> 12 months. A dashed line marks the
|
||||
previous same-granularity period's average per point with the %
|
||||
change of the totals."""
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
|
||||
gran = self.granularity()
|
||||
metric = self.metric_combo.currentData() or "cost"
|
||||
pts = self._query.chart_series(gran, self._chart_offset, metric)
|
||||
pricing = self._query.pricing()
|
||||
mi = 0 if metric == "tokens" else 1
|
||||
# Compact cost format (2 decimals, K/M above 1,000/1,000,000) — the
|
||||
# chart's y-axis label box is narrow; format_cost's full precision
|
||||
# overflowed it, clipping/obscuring the amount.
|
||||
fmt = fmt_tokens if metric == "tokens" else (lambda v: ut.format_cost_compact(v, pricing))
|
||||
|
||||
cur = self._query.period_totals(gran, self._chart_offset)
|
||||
prev = self._query.period_totals(gran, self._chart_offset - 1)
|
||||
ref_key = {"week": "dashboard.ref_last_week",
|
||||
"month": "dashboard.ref_last_month",
|
||||
"year": "dashboard.ref_last_year"}.get(gran, "dashboard.ref_last_week")
|
||||
n_points = max(1, len(pts))
|
||||
refs = []
|
||||
if prev[mi] > 0:
|
||||
# Muted on purpose: the comparison line is a reference, not the
|
||||
# series — it must not compete with the accent-coloured spline.
|
||||
refs.append((prev[mi] / n_points,
|
||||
f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}",
|
||||
current_palette().text_muted))
|
||||
self.chart.set_reference_lines(refs)
|
||||
self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}"))
|
||||
self._chart_period_lbl.setText(self._query.period_range_label(gran, self._chart_offset))
|
||||
self.chart_next_btn.setEnabled(self._chart_offset < 0)
|
||||
|
||||
|
||||
__all__ = ["UsageChartWidget"]
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Folder Explorer screen, split into single-responsibility widgets
|
||||
(R08-T12): ``workspace_file_tree``, ``document_preview_manager``,
|
||||
``ai_edit_model_resolver``, ``ai_file_editor_dialog``, assembled by the
|
||||
``folder_tab`` shell."""
|
||||
@@ -0,0 +1,248 @@
|
||||
"""AiEditModelResolver — model picker + Auto Model Routing + image-model
|
||||
discovery for the AI-Edit panel (R08-T12, extracted from
|
||||
``ui/folder_tab.py::FolderTab``, lines 802-1036/911-961 of the original
|
||||
1587-line file: ``refresh_ai_models``, ``_scan_all_image_models``,
|
||||
``_ai_provider``, ``_ai_apply_routing``, ``_confirm_routing_switch``,
|
||||
``_ai_image_model``, ``_maybe_suggest_image_model``,
|
||||
``_suggest_cross_provider_image``).
|
||||
|
||||
A plain (non-Qt-widget) helper composed BY
|
||||
``ai_file_editor_dialog.py::AiFileEditorDialog`` — this is genuinely a
|
||||
distinct concern (which provider/model answers THIS run) from the panel's
|
||||
send/plan/edit orchestration, and splitting it out is also what keeps
|
||||
``ai_file_editor_dialog.py`` under the 400-line cap.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
|
||||
class AiEditModelResolver:
|
||||
"""Owns the AI-edit model combo's contents and every "which provider/
|
||||
model should THIS run use" decision — independent of the Cowork/Settings
|
||||
agent, exactly like the original panel's own picker was.
|
||||
|
||||
Args:
|
||||
ctx: ``AppContext``.
|
||||
model_combo: the ``QComboBox`` populated by :meth:`refresh`.
|
||||
on_status: ``(text) -> None`` — posts a status line into the AI
|
||||
chat (production passes ``ai_chat.add_status``).
|
||||
confirm_switch: ``(self, decision, timeout) -> bool`` — the Qt
|
||||
confirm dialog for Manual routing mode (kept as a callback so
|
||||
this class never imports a dialog itself).
|
||||
"""
|
||||
|
||||
def __init__(self, ctx, model_combo, on_status, confirm_switch) -> None:
|
||||
self.ctx = ctx
|
||||
self._combo = model_combo
|
||||
self._on_status = on_status
|
||||
self._confirm_switch = confirm_switch
|
||||
self._models: List[str] = []
|
||||
self._models_provider = ""
|
||||
self._all_image_models: List[Tuple[str, str]] = [] # [(provider_key, model)]
|
||||
self._img_scan_worker = None
|
||||
self._pending_img_suggest = False
|
||||
self._routed_provider: Optional[str] = None
|
||||
self._routed_model: Optional[str] = None
|
||||
|
||||
@property
|
||||
def models(self) -> List[str]:
|
||||
return self._models
|
||||
|
||||
@property
|
||||
def models_provider(self) -> str:
|
||||
return self._models_provider
|
||||
|
||||
@property
|
||||
def routed_provider(self) -> Optional[str]:
|
||||
"""The provider :meth:`apply_routing` switched to for the current
|
||||
run, or ``None`` when it didn't switch (routing off/declined)."""
|
||||
return self._routed_provider
|
||||
|
||||
@property
|
||||
def routed_model(self) -> Optional[str]:
|
||||
return self._routed_model
|
||||
|
||||
def should_refresh(self) -> bool:
|
||||
"""True on first open, or when the active provider changed since
|
||||
the model list was last loaded — a stale list would resolve a pick
|
||||
to the wrong/default model at the new endpoint."""
|
||||
return self._combo.count() <= 1 or self._models_provider != self.ctx.config.active_provider
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Fetch the active provider's model list (background) into the
|
||||
picker. Also proactively scans ALL providers for image-capable
|
||||
models so a suggestion is ready the moment one is needed."""
|
||||
name = self.ctx.config.active_provider
|
||||
setting_model = self.ctx.config.provider_conf(name).get("model", "")
|
||||
|
||||
def job(worker):
|
||||
prov = self.ctx.build_provider_for(name)
|
||||
try:
|
||||
models = list(getattr(prov, "list_models", lambda: [])() or [])
|
||||
except Exception: # noqa: BLE001
|
||||
models = []
|
||||
return {"models": models}
|
||||
|
||||
def done(res):
|
||||
fetched = list(res.get("models", []))
|
||||
# Always offer the Settings-configured model as an explicit
|
||||
# choice, even when the provider can't list models.
|
||||
self._models = list(dict.fromkeys(
|
||||
([setting_model] if setting_model else []) + [m for m in fetched if m]))
|
||||
self._models_provider = name
|
||||
cur = self._combo.currentData()
|
||||
self._combo.blockSignals(True)
|
||||
self._combo.clear()
|
||||
self._combo.addItem(tr("folder.ai_model_auto"), None)
|
||||
for m in self._models:
|
||||
self._combo.addItem(m, m)
|
||||
idx = self._combo.findData(cur)
|
||||
self._combo.setCurrentIndex(idx if idx >= 0 else 0)
|
||||
self._combo.blockSignals(False)
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
self._models_worker = w
|
||||
w.start()
|
||||
self._scan_all_image_models()
|
||||
|
||||
def _scan_all_image_models(self, then_suggest: bool = False) -> None:
|
||||
if self._img_scan_worker is not None:
|
||||
if then_suggest:
|
||||
self._pending_img_suggest = True
|
||||
return
|
||||
providers = dict(self.ctx.config.data.get("providers", {}))
|
||||
candidates = [k for k, c in providers.items()
|
||||
if (c.get("base_url") or c.get("api_key"))]
|
||||
|
||||
def job(worker):
|
||||
from cowork_local.core import image_gen
|
||||
found = []
|
||||
for key in candidates:
|
||||
try:
|
||||
prov = self.ctx.build_provider_for(key)
|
||||
models = list(getattr(prov, "list_models", lambda: [])() or [])
|
||||
except Exception: # noqa: BLE001 - a broken provider must not block the scan
|
||||
models = []
|
||||
for m in models:
|
||||
if image_gen.looks_like_image_model(m):
|
||||
found.append((key, m))
|
||||
return {"found": found}
|
||||
|
||||
def done(res):
|
||||
self._img_scan_worker = None
|
||||
self._all_image_models = list(res.get("found", []))
|
||||
if self._pending_img_suggest:
|
||||
self._pending_img_suggest = False
|
||||
self._suggest_cross_provider_image()
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(self._on_image_scan_failed)
|
||||
self._img_scan_worker = w
|
||||
if then_suggest:
|
||||
self._pending_img_suggest = True
|
||||
w.start()
|
||||
|
||||
def _on_image_scan_failed(self, _err) -> None:
|
||||
self._img_scan_worker = None
|
||||
|
||||
def provider(self) -> Any:
|
||||
"""Build a provider using the model chosen in the picker ('(auto)'
|
||||
-> the active provider's default), or an Auto/Manual routing
|
||||
override set by :meth:`apply_routing` for the current run."""
|
||||
if self._routed_provider or self._routed_model:
|
||||
provider = self._routed_provider or self.ctx.config.active_provider
|
||||
return self.ctx.build_provider_for(provider, self._routed_model or None)
|
||||
model = self._combo.currentData()
|
||||
return self.ctx.build_provider_for(self.ctx.config.active_provider, model or None)
|
||||
|
||||
def apply_routing(self, instruction: str) -> None:
|
||||
"""Auto Model Routing for the AI-Edit surface (always a CODING
|
||||
task). Sets the routing override :meth:`provider` honours."""
|
||||
from cowork_local.core.routing.models import TaskType
|
||||
|
||||
self._routed_provider = None
|
||||
self._routed_model = None
|
||||
cur_provider = self.ctx.config.active_provider
|
||||
picked = self._combo.currentData()
|
||||
cur_model = picked or self.ctx.config.provider_conf(cur_provider).get("model", "")
|
||||
decision = self.ctx.routing_application().route_turn(
|
||||
"ai_edit", instruction, cur_provider, cur_model,
|
||||
task_type=TaskType.CODING, confirm=self._confirm_switch,
|
||||
)
|
||||
if not decision.switched:
|
||||
return
|
||||
self._routed_provider, self._routed_model = decision.target()
|
||||
self._on_status(tr(
|
||||
"routing.switched_notice",
|
||||
model=decision.model, task=decision.task_type,
|
||||
gain=f"{decision.score_gain:.2f}"))
|
||||
|
||||
_IMAGE_WORDS = ("image", "picture", "photo", "illustration", "icon", "logo", "diagram",
|
||||
"ảnh", "hình", "minh họa", "biểu tượng", "画像", "イラスト")
|
||||
|
||||
def maybe_suggest_image_model(self, instruction: str) -> None:
|
||||
"""If the request looks image-related, suggest a suitable image
|
||||
model BEFORE running — active provider first, then ALL providers."""
|
||||
from cowork_local.core import image_gen
|
||||
low = (instruction or "").lower()
|
||||
if not any(w in low for w in self._IMAGE_WORDS):
|
||||
return
|
||||
picked = self._combo.currentData()
|
||||
if picked and image_gen.looks_like_image_model(picked):
|
||||
return
|
||||
local = image_gen.suggest_image_model(self._models)
|
||||
if local:
|
||||
self._on_status(tr("folder.ai_image_suggest", model=local))
|
||||
return
|
||||
if self._all_image_models:
|
||||
self._suggest_cross_provider_image()
|
||||
elif self._img_scan_worker is not None:
|
||||
self._pending_img_suggest = True
|
||||
else:
|
||||
self._scan_all_image_models(then_suggest=True)
|
||||
|
||||
def _suggest_cross_provider_image(self) -> None:
|
||||
from cowork_local.config import PROVIDER_LABELS
|
||||
if not self._all_image_models:
|
||||
picked = self._combo.currentData()
|
||||
if picked:
|
||||
self._on_status(tr("folder.ai_image_use_selected", model=picked))
|
||||
else:
|
||||
self._on_status(tr("folder.ai_image_none"))
|
||||
return
|
||||
seen, lines = set(), []
|
||||
for key, model in self._all_image_models:
|
||||
tag = (key, model)
|
||||
if tag in seen:
|
||||
continue
|
||||
seen.add(tag)
|
||||
lines.append(f"• {model} ({PROVIDER_LABELS.get(key, key)})")
|
||||
if len(lines) >= 5:
|
||||
break
|
||||
self._on_status(tr("folder.ai_image_suggest_all") + "\n" + "\n".join(lines))
|
||||
|
||||
def image_model(self) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""Resolve ``(model, base_url, api_key)`` for image generation,
|
||||
searching ALL providers — see the module docstring for priority
|
||||
order (picked model if image-capable -> active provider's image
|
||||
model -> any other provider's -> fall back to the picked model)."""
|
||||
from cowork_local.core import image_gen
|
||||
picked = self._combo.currentData()
|
||||
if picked and image_gen.looks_like_image_model(picked):
|
||||
return picked, None, None
|
||||
local = image_gen.suggest_image_model(self._models)
|
||||
if local:
|
||||
return local, None, None
|
||||
for key, model in self._all_image_models:
|
||||
conf = self.ctx.config.provider_conf(key)
|
||||
return model, (conf.get("base_url") or None), (conf.get("api_key") or None)
|
||||
return (picked or None), None, None
|
||||
|
||||
|
||||
__all__ = ["AiEditModelResolver"]
|
||||
@@ -0,0 +1,372 @@
|
||||
"""AiEditPipeline — the plan-then-edit-then-apply state machine behind the
|
||||
AI-Edit panel (R08-T12, extracted from ``ui/folder_tab.py::FolderTab``,
|
||||
lines 1068-1097/1119-1146/1147-1467 of the original 1587-line file:
|
||||
``_ai_start`` through ``_ai_failed``, minus the queue/busy-badge bookkeeping
|
||||
which stays on ``ai_file_editor_dialog.py::AiFileEditorDialog`` — see that
|
||||
module's docstring for the split rationale).
|
||||
|
||||
A plain (non-Qt-widget) helper composed BY ``AiFileEditorDialog`` — same
|
||||
composition-to-respect-the-400-line-cap pattern as
|
||||
``office_document_renderer.py``. Talks to the file only through
|
||||
``document_preview_manager.py``'s public API (``ensure_editable_for_ai``,
|
||||
``write_content``, ``create_new_file``) — it never touches disk itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from cowork_local.application.workspaces.ai_edit_output import parse_ai_output
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.theme import current_palette
|
||||
|
||||
|
||||
class AiEditPipeline:
|
||||
"""Runs one instruction through PLAN -> EDIT -> (review) -> APPLY/DISCARD.
|
||||
|
||||
Args:
|
||||
owner: the ``AiFileEditorDialog`` — supplies ``ai_chat``, ``preview``
|
||||
(``DocumentPreviewManager``), ``resolver``
|
||||
(``AiEditModelResolver``), ``ctx``, ``cowork_context()``, and is
|
||||
told about status changes via ``on_busy_changed``/``on_flag_done``
|
||||
so the panel's queue/badge bookkeeping stays in one place.
|
||||
"""
|
||||
|
||||
def __init__(self, owner) -> None:
|
||||
self._owner = owner
|
||||
self.worker: Optional[AgentWorker] = None
|
||||
self.pending: Optional[dict] = None # proposed content awaiting confirmation
|
||||
self._ctx: dict = {}
|
||||
self._prompt_usage: dict = {"in": 0, "out": 0, "cache": 0, "cost": 0.0}
|
||||
self._running_file = ""
|
||||
|
||||
def start(self, instruction: str) -> None:
|
||||
"""Begin processing one instruction. Assumes the pipeline is idle
|
||||
(the panel's queue calls this when the previous run finishes)."""
|
||||
o = self._owner
|
||||
preview = o.preview
|
||||
editable = preview.stack.currentWidget() is preview.editor
|
||||
if not editable:
|
||||
editable = preview.ensure_editable_for_ai()
|
||||
o.resolver.maybe_suggest_image_model(instruction)
|
||||
o.resolver.apply_routing(instruction) # may switch to the best coding model
|
||||
has_file = editable and bool(preview.current_file)
|
||||
self._running_file = Path(preview.current_file).name if has_file else tr("folder.ai_new_file")
|
||||
o.set_busy(True)
|
||||
o.status_message.emit(tr("folder.ai_running", name=self._running_file))
|
||||
# Two phases so the PLAN is shown INLINE *before* the edit runs.
|
||||
self._ctx = {
|
||||
"filename": Path(preview.current_file).name if has_file else "",
|
||||
"content": preview.editor.toPlainText() if has_file else "",
|
||||
"convo": o.cowork_context(),
|
||||
"instruction": instruction,
|
||||
"provider": o.resolver.provider(),
|
||||
"plan": "",
|
||||
"edit_kind": preview.edit_kind,
|
||||
}
|
||||
self._prompt_usage = {"in": 0, "out": 0, "cache": 0, "cost": 0.0}
|
||||
self._run_plan()
|
||||
|
||||
# ---- usage accounting (like Cowork's per-message footer) --------------- #
|
||||
def _add_usage(self, usage) -> None:
|
||||
if not isinstance(usage, dict):
|
||||
return
|
||||
tot = self._prompt_usage
|
||||
tot["in"] += int(usage.get("in", 0) or 0)
|
||||
tot["out"] += int(usage.get("out", 0) or 0)
|
||||
tot["cache"] += int(usage.get("cache", 0) or 0)
|
||||
tot["cost"] += float(usage.get("cost_usd", 0.0) or 0.0)
|
||||
|
||||
def _show_usage(self, bubble) -> None:
|
||||
tot = self._prompt_usage
|
||||
if bubble is None or not (tot["in"] or tot["out"]):
|
||||
return
|
||||
from cowork_local.core import model_pricing as mp, usage_tracker as ut
|
||||
pricing = {**ut.DEFAULT_PRICING, **(self._owner.ctx.config.data.get("usage") or {})}
|
||||
line = (f"↓{mp.format_tokens(int(tot['in']))} ↑{mp.format_tokens(int(tot['out']))} "
|
||||
f"▤{mp.format_tokens(int(tot['in'] + tot['out'] + tot['cache']))} "
|
||||
f"{ut.format_cost(tot['cost'], pricing)}")
|
||||
try:
|
||||
bubble.add_usage(line)
|
||||
except Exception: # noqa: BLE001 - a usage footer must never break the edit
|
||||
pass
|
||||
|
||||
# ---- phase 1: plan ------------------------------------------------------- #
|
||||
def _run_plan(self) -> None:
|
||||
o = self._owner
|
||||
c = self._ctx
|
||||
plan_bubble = o.ai_chat.add_plan(tr("folder.ai_planning"))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
|
||||
def job(worker):
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
from cowork_local.core.co4e_runner import _usage_delta
|
||||
provider = c["provider"]
|
||||
messages = [{"role": "system", "content":
|
||||
"You are an AI file editor. Give a SHORT numbered plan (2-4 steps) for "
|
||||
"the requested change. Plan ONLY — do NOT output any code."}]
|
||||
if c["convo"]:
|
||||
messages.append({"role": "system",
|
||||
"content": "Context from the user's Cowork conversation:\n" + c["convo"]})
|
||||
messages.append({"role": "user", "content":
|
||||
f"File: {c['filename']}\n\nCurrent content:\n```\n{c['content']}\n```\n\n"
|
||||
f"Request: {c['instruction']}"})
|
||||
ut.set_context("folder", c.get("filename") or "AI edit")
|
||||
ut.begin_accumulation(); base = ut.accumulated()
|
||||
try:
|
||||
r = provider.chat(messages, tools=None, cancel=worker.is_cancelled)
|
||||
txt = r.get("content", "") if isinstance(r, dict) else str(r)
|
||||
usage = _usage_delta(base, o.ctx.config)
|
||||
finally:
|
||||
ut.end_accumulation()
|
||||
return {"plan": provider.strip_think(txt) or "", "usage": usage}
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.finished_ok.connect(lambda res, b=plan_bubble: self._plan_done(res, b))
|
||||
worker.failed.connect(lambda err, b=plan_bubble: self._failed(err, b))
|
||||
self.worker = worker
|
||||
worker.start()
|
||||
|
||||
def _plan_done(self, result, plan_bubble) -> None:
|
||||
self._add_usage((result or {}).get("usage"))
|
||||
plan = ((result or {}).get("plan") or "").strip()
|
||||
self._ctx["plan"] = plan
|
||||
plan_bubble.set_plain(plan or tr("folder.ai_empty"))
|
||||
self._owner.ai_chat.scroll_to_bottom()
|
||||
self._run_edit()
|
||||
|
||||
# ---- phase 2: execute (edit the file) ------------------------------------ #
|
||||
def _run_edit(self) -> None:
|
||||
o = self._owner
|
||||
c = self._ctx
|
||||
bubble = o.ai_chat.add_assistant(tr("folder.ai_edit"))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
|
||||
pptx_note = ("\nThis is a PPTX shown as marker blocks '### Slide N / Box M', where N is the "
|
||||
"1-based SLIDE NUMBER and M the box on that slide. When the user refers to a "
|
||||
"slide by number (e.g. 'edit slide 3'), change ONLY the blocks under '### Slide "
|
||||
"3' and leave every other slide's block exactly as-is. Each block has fields "
|
||||
"type/pos/size/font/text (and image for pictures). To change TEXT COLOUR or "
|
||||
"FONT edit the `font:` line — e.g. `font: name=Arial size=28 bold=1 "
|
||||
"color=FF0000`. Keep all block markers and structure.") if c["edit_kind"] == "pptx" else ""
|
||||
|
||||
_pptx_words = ("pptx", "powerpoint", "slide", "presentation", "deck",
|
||||
"スライド", "プレゼン", "trình chiếu", "trinh chieu", "bài thuyết trình")
|
||||
wants_new_pptx = (c["edit_kind"] != "pptx"
|
||||
and any(w in c["instruction"].lower() for w in _pptx_words))
|
||||
new_pptx_note = ("\nTo CREATE a PowerPoint, name it `FILE: <name>.pptx` and output the slides "
|
||||
"as marker blocks — one block per shape:\n"
|
||||
"### Slide 1 / Box 1\ntype: text\npos: 0.5, 0.4\nsize: 9.0, 1.2\n"
|
||||
"font: name=Calibri size=32 bold=1 color=1F3864\ntext:\nTitle here\n\n"
|
||||
"### Slide 1 / Box 2\ntype: text\npos: 0.5, 1.8\nsize: 9.0, 4.5\n"
|
||||
"text:\nBullet one\nBullet two\n\n"
|
||||
"Increment the Slide number for each new slide; pos/size are in inches; "
|
||||
"font color is RRGGBB hex.") if wants_new_pptx else ""
|
||||
|
||||
imggen_note = ""
|
||||
try:
|
||||
from cowork_local.core import image_gen
|
||||
if image_gen.is_configured(o.ctx.config):
|
||||
imggen_note = ("\nYou can also GENERATE an illustration image: add a line "
|
||||
"`IMAGE_GEN: <describe the image> => <relative/path.png>`. Use a "
|
||||
"generated image e.g. as a new picture, or (for pptx) set a picture "
|
||||
"box's `image:` field to that same path to insert it.")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def job(worker):
|
||||
provider = c["provider"]
|
||||
open_note = (f"the currently-open file '{c['filename']}'" if c["filename"]
|
||||
else "no file is open")
|
||||
messages = [{"role": "system", "content":
|
||||
"You are an AI file editor inside an app. Following the plan, output the "
|
||||
"COMPLETE file content in ONE fenced code block (```), and nothing after "
|
||||
"it. Preserve everything you were not asked to change.\n"
|
||||
"If the request is to CREATE A NEW file (or a different file than the one "
|
||||
"open), put a line `FILE: <relative/path/name.ext>` (relative to the "
|
||||
"current folder) immediately before the code block. Omit FILE to edit the "
|
||||
f"open file. Right now, {open_note}." + pptx_note + new_pptx_note + imggen_note}]
|
||||
if c["convo"]:
|
||||
messages.append({"role": "system",
|
||||
"content": "Context from the user's Cowork conversation:\n" + c["convo"]})
|
||||
if c["plan"]:
|
||||
messages.append({"role": "system", "content": "Plan to follow:\n" + c["plan"]})
|
||||
cur = (f"File: {c['filename']}\n\nCurrent content:\n```\n{c['content']}\n```\n\n"
|
||||
if c["filename"] else "No file is currently open.\n\n")
|
||||
messages.append({"role": "user", "content": cur + f"Request: {c['instruction']}"})
|
||||
|
||||
def on_text(piece: str) -> None:
|
||||
worker.emit_event({"type": "text", "delta": piece})
|
||||
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
from cowork_local.core.co4e_runner import _usage_delta
|
||||
ut.set_context("folder", c.get("filename") or "AI edit")
|
||||
ut.begin_accumulation(); base = ut.accumulated()
|
||||
try:
|
||||
r = provider.chat(messages, tools=None, on_text=on_text, cancel=worker.is_cancelled)
|
||||
txt = r.get("content", "") if isinstance(r, dict) else str(r)
|
||||
usage = _usage_delta(base, o.ctx.config)
|
||||
finally:
|
||||
ut.end_accumulation()
|
||||
return {"text": provider.strip_think(txt) or "", "usage": usage}
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.event.connect(lambda ev, b=bubble: self._stream(ev, b))
|
||||
worker.finished_ok.connect(lambda res, b=bubble: self._done(res, b))
|
||||
worker.failed.connect(lambda err, b=bubble: self._failed(err, b))
|
||||
self.worker = worker
|
||||
worker.start()
|
||||
|
||||
def _stream(self, ev, bubble) -> None:
|
||||
if isinstance(ev, dict) and ev.get("type") == "text":
|
||||
bubble.append_delta(ev.get("delta", ""))
|
||||
self._owner.ai_chat.scroll_to_bottom()
|
||||
|
||||
def _done(self, result, bubble) -> None:
|
||||
o = self._owner
|
||||
self.worker = None
|
||||
o.set_busy(False)
|
||||
self._add_usage((result or {}).get("usage"))
|
||||
self._show_usage(bubble)
|
||||
text = ((result or {}).get("text") or "").strip()
|
||||
target, new_content, summary, image_gens = parse_ai_output(text)
|
||||
if new_content is None and not image_gens:
|
||||
bubble.set_markdown(text or tr("folder.ai_empty"))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
o.flag_done()
|
||||
return
|
||||
create = bool(target) and (not o.preview.current_file
|
||||
or Path(target).name != Path(o.preview.current_file).name)
|
||||
self.pending = {"content": new_content, "target": target if create else None,
|
||||
"image_gens": image_gens}
|
||||
hint = tr("folder.ai_review_hint")
|
||||
bubble.set_markdown((summary + "\n\n" if summary else "") + "_" + hint + "_")
|
||||
if new_content is not None:
|
||||
old = "" if create else o.preview.editor.toPlainText()
|
||||
diff = "".join(difflib.unified_diff(
|
||||
old.splitlines(keepends=True), new_content.splitlines(keepends=True),
|
||||
fromfile=("(new file)" if create else "current"),
|
||||
tofile=(target if create else "proposed"))) or "(no textual difference)"
|
||||
title = tr("folder.ai_proposed_new", name=target) if create else tr("folder.ai_proposed")
|
||||
o.ai_chat.add_diff(title, diff)
|
||||
if image_gens:
|
||||
listing = "\n".join(f"• {p} ({prompt[:60]})" for prompt, p in image_gens)
|
||||
o.ai_chat.add_status(tr("folder.ai_image_plan") + "\n" + listing)
|
||||
o.show_confirm_row(True)
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
name = target if create else self._running_file
|
||||
o.status_message.emit(tr("folder.ai_proposed_status", name=name))
|
||||
o.set_review_status("● " + hint, current_palette().warning)
|
||||
|
||||
# ---- apply / discard ------------------------------------------------------ #
|
||||
def apply(self) -> None:
|
||||
"""Confirmed by the user. If the edit GENERATES images, ask the
|
||||
image gate then generate them (off-thread) before finalising."""
|
||||
if not self.pending:
|
||||
return
|
||||
p = self.pending
|
||||
self.pending = None
|
||||
self._owner.show_confirm_row(False)
|
||||
if p.get("image_gens"):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
if QMessageBox.question(self._owner, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm_gen")) != QMessageBox.Yes:
|
||||
self._owner.status_message.emit(tr("folder.ai_image_declined"))
|
||||
return
|
||||
self._generate_then_finalize(p)
|
||||
return
|
||||
self._finalize_apply(p)
|
||||
|
||||
def _generate_then_finalize(self, p: dict) -> None:
|
||||
o = self._owner
|
||||
imgs = p.get("image_gens") or []
|
||||
root = os.path.normpath(o.preview.root)
|
||||
img_model, img_base, img_key = o.resolver.image_model()
|
||||
o.set_busy(True)
|
||||
o.status_message.emit(tr("folder.ai_generating"))
|
||||
|
||||
def job(worker):
|
||||
from cowork_local.core import image_gen
|
||||
results = []
|
||||
for prompt, rel in imgs:
|
||||
dest = rel if os.path.isabs(rel) else os.path.join(root, rel)
|
||||
dest = os.path.normpath(dest)
|
||||
if os.path.commonpath([dest, root]) != root:
|
||||
results.append((rel, False, "path escapes the folder"))
|
||||
continue
|
||||
try:
|
||||
os.makedirs(os.path.dirname(dest) or root, exist_ok=True)
|
||||
except OSError as exc:
|
||||
results.append((rel, False, str(exc)))
|
||||
continue
|
||||
ok, msg = image_gen.generate_image(o.ctx.config, prompt, dest,
|
||||
model=img_model, base_url=img_base, api_key=img_key)
|
||||
results.append((dest, ok, msg))
|
||||
return {"results": results}
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.finished_ok.connect(lambda res, pp=p: self._images_done(res, pp))
|
||||
worker.failed.connect(lambda err, pp=p: self._images_done({"results": [], "err": err}, pp))
|
||||
self.worker = worker
|
||||
worker.start()
|
||||
|
||||
def _images_done(self, res: dict, p: dict) -> None:
|
||||
o = self._owner
|
||||
self.worker = None
|
||||
o.set_busy(False)
|
||||
created = []
|
||||
for dest, ok, msg in res.get("results", []):
|
||||
if ok:
|
||||
created.append(dest)
|
||||
o.ai_chat.add_success("✓ " + tr("folder.ai_image_created", name=Path(dest).name))
|
||||
else:
|
||||
o.ai_chat.add_error(tr("folder.ai_image_failed", err=msg))
|
||||
self._finalize_apply(p, images_done=True)
|
||||
if p.get("content") is None and not p.get("target") and created:
|
||||
o.preview.open_file(created[0], reset_ai=False)
|
||||
|
||||
def _finalize_apply(self, p: dict, images_done: bool = False) -> None:
|
||||
o = self._owner
|
||||
content = p.get("content")
|
||||
target = p.get("target")
|
||||
if content is None:
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
o.flag_done()
|
||||
o.status_message.emit(tr("folder.ai_done", name=self._running_file))
|
||||
return
|
||||
if target:
|
||||
dest = o.preview.create_new_file(target, content)
|
||||
if dest is None:
|
||||
return
|
||||
o.ai_chat.add_success("✓ " + tr("folder.ai_created", name=Path(dest).name))
|
||||
o.status_message.emit(tr("folder.ai_created", name=Path(dest).name))
|
||||
else:
|
||||
o.preview.editor.setPlainText(content) # live update in the editor/preview
|
||||
o.preview.write_content(content, skip_image_confirm=images_done)
|
||||
o.ai_chat.add_success("✓ " + tr("folder.ai_applied"))
|
||||
o.status_message.emit(tr("folder.ai_done", name=self._running_file))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
o.flag_done()
|
||||
|
||||
def discard(self) -> None:
|
||||
o = self._owner
|
||||
self.pending = None
|
||||
o.show_confirm_row(False)
|
||||
o.ai_chat.add_status(tr("folder.ai_discarded"))
|
||||
o.ai_chat.scroll_to_bottom()
|
||||
o.set_review_status("", None)
|
||||
o.maybe_dequeue() # discarding resolves the gate → run the next queued edit
|
||||
|
||||
def _failed(self, err, bubble) -> None:
|
||||
o = self._owner
|
||||
self.worker = None
|
||||
bubble.set_markdown(tr("folder.ai_error", err=err))
|
||||
o.set_busy(False)
|
||||
o.status_message.emit(tr("folder.ai_error", err=err))
|
||||
o.flag_done()
|
||||
|
||||
|
||||
__all__ = ["AiEditPipeline"]
|
||||
@@ -0,0 +1,237 @@
|
||||
"""AiFileEditorDialog — the collapsible AI-edit panel of the Folder Explorer
|
||||
(R08-T12, extracted from ``ui/folder_tab.py::FolderTab``, lines 701-800/
|
||||
1039-1066/1099-1118/1468-1517 of the original 1587-line file:
|
||||
``_build_ai_panel``, panel open/reset, the instruction queue, and the busy/
|
||||
done status line).
|
||||
|
||||
Despite the name (matching ``docs/refactor/Feature_Architecture_Proposal.md``'s
|
||||
R08-T12 file list), this is an inline collapsible ``QWidget`` panel, not a
|
||||
modal ``QDialog`` — exactly like the original ``_ai_panel`` was.
|
||||
|
||||
Composes two helpers to stay under the 400-line cap:
|
||||
``ai_edit_model_resolver.py::AiEditModelResolver`` (which provider/model
|
||||
answers a run) and ``ai_edit_pipeline.py::AiEditPipeline`` (the actual
|
||||
plan-then-edit-then-apply state machine). This class owns the widget itself,
|
||||
the instruction queue, and the busy/done status line/badge — the parts that
|
||||
needed to stay together because the queue decides when the pipeline's next
|
||||
``start()`` call happens.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget,
|
||||
)
|
||||
from PySide6.QtCore import Signal
|
||||
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.folder.ai_edit_model_resolver import AiEditModelResolver
|
||||
from cowork_local.presentation.folder.ai_edit_pipeline import AiEditPipeline
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.chat_view import ChatView
|
||||
|
||||
|
||||
class AiFileEditorDialog(QWidget):
|
||||
"""Collapsible panel: a Cowork-style inline chat timeline, this panel's
|
||||
OWN model picker + routing toggle, an instruction box, and an Apply/
|
||||
Discard confirmation bar for the proposed edit.
|
||||
|
||||
Args:
|
||||
ctx: ``AppContext``.
|
||||
preview: ``document_preview_manager.py::DocumentPreviewManager`` —
|
||||
every read/write of the actual file content goes through it.
|
||||
cowork: the shared Cowork tab (optional) — its recent messages are
|
||||
included as background context for the edit.
|
||||
"""
|
||||
|
||||
status_message = Signal(str)
|
||||
badge_changed = Signal(str) # "" | " ⏳" | " ✓" — the shell mirrors this onto its toggle button
|
||||
|
||||
def __init__(self, ctx, preview, cowork=None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self.preview = preview
|
||||
self._cowork = cowork
|
||||
self._ai_queue: List[str] = []
|
||||
self.pipeline = AiEditPipeline(self)
|
||||
self.resolver: Optional[AiEditModelResolver] = None # built after ai_model_combo exists
|
||||
|
||||
preview.ai_reset_requested.connect(self.reset_conversation)
|
||||
preview.status_message.connect(self.status_message.emit)
|
||||
|
||||
v = QVBoxLayout(self)
|
||||
v.setContentsMargins(6, 0, 0, 0)
|
||||
v.setSpacing(4)
|
||||
title_row = QHBoxLayout()
|
||||
self._ai_title = QLabel(tr("folder.ai_edit"))
|
||||
self._ai_title.setStyleSheet("font-weight:600;")
|
||||
title_row.addWidget(self._ai_title)
|
||||
title_row.addStretch(1)
|
||||
self._ai_status = QLabel("")
|
||||
self._ai_status.setObjectName("hint")
|
||||
title_row.addWidget(self._ai_status)
|
||||
v.addLayout(title_row)
|
||||
self.ai_chat = ChatView()
|
||||
v.addWidget(self.ai_chat, 1)
|
||||
|
||||
model_row = QHBoxLayout()
|
||||
self._ai_model_lbl = QLabel(tr("folder.ai_model_label"))
|
||||
self._ai_model_lbl.setObjectName("hint")
|
||||
model_row.addWidget(self._ai_model_lbl)
|
||||
self.ai_model_combo = QComboBox()
|
||||
self.ai_model_combo.addItem(tr("folder.ai_model_auto"), None)
|
||||
model_row.addWidget(self.ai_model_combo, 1)
|
||||
from cowork_local.ui.routing_toggle import RoutingToggle
|
||||
self.ai_routing_toggle = RoutingToggle(self.ctx, "ai_edit")
|
||||
model_row.addWidget(self.ai_routing_toggle)
|
||||
v.addLayout(model_row)
|
||||
self.resolver = AiEditModelResolver(
|
||||
ctx, self.ai_model_combo, self.ai_chat.add_status, self._confirm_routing_switch)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self.ai_input = QLineEdit()
|
||||
self.ai_input.setPlaceholderText(tr("folder.ai_placeholder"))
|
||||
self.ai_input.returnPressed.connect(self._ai_send)
|
||||
row.addWidget(self.ai_input, 1)
|
||||
self.ai_send_btn = QPushButton(tr("folder.ai_send"))
|
||||
self.ai_send_btn.setObjectName("primary")
|
||||
self.ai_send_btn.clicked.connect(self._ai_send)
|
||||
row.addWidget(self.ai_send_btn)
|
||||
v.addLayout(row)
|
||||
|
||||
self._ai_confirm_row = QWidget()
|
||||
cf = QHBoxLayout(self._ai_confirm_row)
|
||||
cf.setContentsMargins(0, 0, 0, 0)
|
||||
cf.addStretch(1)
|
||||
self._ai_discard_btn = QPushButton(tr("folder.ai_discard"))
|
||||
self._ai_discard_btn.clicked.connect(self.pipeline.discard)
|
||||
cf.addWidget(self._ai_discard_btn)
|
||||
self._ai_apply_btn = QPushButton(tr("folder.ai_apply"))
|
||||
self._ai_apply_btn.setObjectName("primary")
|
||||
self._ai_apply_btn.clicked.connect(self.pipeline.apply)
|
||||
cf.addWidget(self._ai_apply_btn)
|
||||
self._ai_confirm_row.setVisible(False)
|
||||
v.addWidget(self._ai_confirm_row)
|
||||
|
||||
on_language_changed(self.retranslate)
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self._ai_title.setText(tr("folder.ai_edit"))
|
||||
self.ai_input.setPlaceholderText(tr("folder.ai_placeholder"))
|
||||
self.ai_send_btn.setText(tr("folder.ai_send"))
|
||||
self._ai_model_lbl.setText(tr("folder.ai_model_label"))
|
||||
if self.ai_model_combo.count() >= 1 and self.ai_model_combo.itemData(0) is None:
|
||||
self.ai_model_combo.setItemText(0, tr("folder.ai_model_auto"))
|
||||
self._ai_apply_btn.setText(tr("folder.ai_apply"))
|
||||
self._ai_discard_btn.setText(tr("folder.ai_discard"))
|
||||
|
||||
# ---- called by the shell (header button, splitter owner) --------------- #
|
||||
def on_opened(self) -> None:
|
||||
"""The shell's AI toggle button was just checked ON."""
|
||||
self.ai_input.setFocus()
|
||||
if self.resolver.should_refresh():
|
||||
self.resolver.refresh()
|
||||
if self.pipeline.worker is None:
|
||||
self.badge_changed.emit("")
|
||||
self._ai_status.setText("")
|
||||
|
||||
def reset_conversation(self) -> None:
|
||||
"""Clear the AI-edit chat so each file starts a clean conversation. A
|
||||
run in progress (editing the previous file) is left untouched — the
|
||||
reset applies the next time a file is opened while idle."""
|
||||
if self.pipeline.worker is not None:
|
||||
return
|
||||
self.ai_chat.clear()
|
||||
self.badge_changed.emit("")
|
||||
self.pipeline.pending = None
|
||||
self._ai_confirm_row.setVisible(False)
|
||||
self._ai_status.setText("")
|
||||
|
||||
def cowork_context(self) -> str:
|
||||
"""The whole Cowork conversation (recent turns) as background context."""
|
||||
cw = self._cowork
|
||||
msgs = getattr(cw, "messages", None) if cw is not None else None
|
||||
if not msgs:
|
||||
return ""
|
||||
lines = [f"{m['role']}: {str(m['content'])[:1000]}"
|
||||
for m in msgs if m.get("role") in ("user", "assistant") and m.get("content")]
|
||||
return "\n".join(lines[-12:])
|
||||
|
||||
# ---- send / queue -------------------------------------------------------- #
|
||||
def _ai_send(self) -> None:
|
||||
if not self.preview.root:
|
||||
self.ai_chat.add_error(tr("folder.ai_no_file"))
|
||||
return
|
||||
instruction = self.ai_input.text().strip()
|
||||
if not instruction:
|
||||
return
|
||||
self.ai_input.clear()
|
||||
self.ai_chat.add_user(instruction)
|
||||
# QUEUE: while a run is active OR a proposal is awaiting Apply/Discard,
|
||||
# hold the new instruction and run it once the pipeline goes idle.
|
||||
if self.pipeline.worker is not None or self.pipeline.pending is not None:
|
||||
self._ai_queue.append(instruction)
|
||||
self.ai_chat.add_status(tr("folder.ai_queued", n=len(self._ai_queue)))
|
||||
self._update_queue_status()
|
||||
return
|
||||
self.pipeline.start(instruction)
|
||||
|
||||
def _update_queue_status(self) -> None:
|
||||
n = len(self._ai_queue)
|
||||
if n:
|
||||
self._ai_status.setText("⏳ " + tr("folder.ai_status_running")
|
||||
+ " · " + tr("folder.ai_queue_count", n=n))
|
||||
self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
|
||||
|
||||
def maybe_dequeue(self) -> None:
|
||||
"""When the pipeline is fully idle, start the next queued instruction."""
|
||||
if self.pipeline.worker is not None or self.pipeline.pending is not None:
|
||||
return
|
||||
if not self._ai_queue:
|
||||
return
|
||||
nxt = self._ai_queue.pop(0)
|
||||
self._update_queue_status()
|
||||
self.pipeline.start(nxt)
|
||||
|
||||
# ---- pipeline callbacks (see ai_edit_pipeline.py) ------------------------- #
|
||||
def set_busy(self, busy: bool) -> None:
|
||||
self.ai_input.setEnabled(not busy)
|
||||
self.ai_send_btn.setEnabled(not busy)
|
||||
if busy:
|
||||
self._ai_status.setText("⏳ " + tr("folder.ai_status_running"))
|
||||
self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
|
||||
self.badge_changed.emit(" ⏳") # visible even when collapsed
|
||||
else:
|
||||
self._ai_status.setText("")
|
||||
self.badge_changed.emit("")
|
||||
|
||||
def flag_done(self) -> None:
|
||||
"""After a background run, show a 'done' badge so the user notices
|
||||
the result when they return to the tab; cleared on reopen. If more
|
||||
instructions are queued, start the next one instead."""
|
||||
if self.pipeline.worker is None and self.pipeline.pending is None and self._ai_queue:
|
||||
self.maybe_dequeue()
|
||||
return
|
||||
self._ai_status.setText("✓ " + tr("folder.ai_status_done"))
|
||||
self._ai_status.setStyleSheet(f"color:{current_palette().success};")
|
||||
self.badge_changed.emit(" ✓")
|
||||
|
||||
def show_confirm_row(self, visible: bool) -> None:
|
||||
self._ai_confirm_row.setVisible(visible)
|
||||
|
||||
def set_review_status(self, text: str, color) -> None:
|
||||
self._ai_status.setText(text)
|
||||
if color:
|
||||
self._ai_status.setStyleSheet(f"color:{color};")
|
||||
|
||||
def _confirm_routing_switch(self, decision) -> bool:
|
||||
"""Manual mode: ask before moving this AI-Edit run to another model."""
|
||||
from cowork_local.ui.routing_toggle import confirm_switch
|
||||
|
||||
timeout = float(self.ctx.config.routing.get("confirm_timeout_sec", 60) or 60)
|
||||
return bool(confirm_switch(self, decision, timeout))
|
||||
|
||||
|
||||
__all__ = ["AiFileEditorDialog"]
|
||||
@@ -0,0 +1,190 @@
|
||||
"""CodeEditor — the VS-Code-style code/text editor widget (R08-T12, split
|
||||
out of ``document_preview_manager.py`` to keep that file under the 400-line
|
||||
cap; originally ``ui/folder_tab.py``, lines 61-236 of the original
|
||||
1587-line file: the Pygments token-colour helper, ``PygmentsHighlighter``,
|
||||
``_LineNumbers``, ``CodeEditor``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QRect, QSize, Qt, QTimer
|
||||
from PySide6.QtGui import QColor, QFont, QPainter, QSyntaxHighlighter, QTextCharFormat
|
||||
from PySide6.QtWidgets import QPlainTextEdit, QWidget
|
||||
|
||||
from cowork_local.theme import current_palette
|
||||
|
||||
_MAX_HIGHLIGHT_CHARS = 300_000 # skip colouring huge files (keeps typing snappy)
|
||||
|
||||
|
||||
# ── VS-Code-Dark+-ish token palette ────────────────────────────────────────
|
||||
def _fmt(color: str, *, italic: bool = False, bold: bool = False) -> QTextCharFormat:
|
||||
f = QTextCharFormat()
|
||||
f.setForeground(QColor(color))
|
||||
if italic:
|
||||
f.setFontItalic(True)
|
||||
if bold:
|
||||
f.setFontWeight(QFont.Bold)
|
||||
return f
|
||||
|
||||
|
||||
class PygmentsHighlighter(QSyntaxHighlighter):
|
||||
"""Colour the whole document with Pygments and apply per-block. Re-lexes the
|
||||
full text (debounced) so multi-line strings/comments colour correctly."""
|
||||
|
||||
def __init__(self, document):
|
||||
super().__init__(document)
|
||||
from pygments.lexers.special import TextLexer
|
||||
self._lexer = TextLexer(stripnl=False)
|
||||
self._ranges: list[tuple[int, int, QTextCharFormat]] = []
|
||||
self._rules = self._build_rules()
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.setInterval(250)
|
||||
self._timer.timeout.connect(self._retokenize)
|
||||
document.contentsChanged.connect(self._timer.start)
|
||||
|
||||
@staticmethod
|
||||
def _build_rules():
|
||||
from pygments.token import (
|
||||
Comment, Error, Keyword, Name, Number, Operator, Punctuation, String,
|
||||
)
|
||||
p = current_palette()
|
||||
return [
|
||||
(Comment, _fmt(p.code_comment, italic=True)),
|
||||
(Keyword.Type, _fmt(p.code_type)),
|
||||
(Keyword, _fmt(p.code_keyword)),
|
||||
(Name.Function, _fmt(p.code_func)),
|
||||
(Name.Class, _fmt(p.code_type)),
|
||||
(Name.Decorator, _fmt(p.code_func)),
|
||||
(Name.Builtin, _fmt(p.code_type)),
|
||||
(Name.Tag, _fmt(p.code_keyword)),
|
||||
(Name.Attribute, _fmt(p.code_attr)),
|
||||
(String.Doc, _fmt(p.code_comment, italic=True)),
|
||||
(String, _fmt(p.code_string)),
|
||||
(Number, _fmt(p.code_number)),
|
||||
(Operator, _fmt(p.code_fg)),
|
||||
(Punctuation, _fmt(p.code_fg)),
|
||||
(Error, _fmt(p.code_error)),
|
||||
]
|
||||
|
||||
def set_filename(self, filename: str, text: str = "") -> None:
|
||||
from pygments.lexers import get_lexer_for_filename, guess_lexer
|
||||
from pygments.lexers.special import TextLexer
|
||||
from pygments.util import ClassNotFound
|
||||
try:
|
||||
self._lexer = get_lexer_for_filename(filename, stripnl=False)
|
||||
except ClassNotFound:
|
||||
try:
|
||||
self._lexer = guess_lexer(text) if text.strip() else TextLexer()
|
||||
except ClassNotFound:
|
||||
self._lexer = TextLexer(stripnl=False)
|
||||
self._retokenize()
|
||||
|
||||
def _fmt_for(self, tok):
|
||||
for ttype, fmt in self._rules:
|
||||
if tok in ttype:
|
||||
return fmt
|
||||
return None
|
||||
|
||||
def _retokenize(self) -> None:
|
||||
from pygments import lex
|
||||
text = self.document().toPlainText()
|
||||
self._ranges = []
|
||||
if len(text) <= _MAX_HIGHLIGHT_CHARS:
|
||||
pos = 0
|
||||
for tok, val in lex(text, self._lexer):
|
||||
fmt = self._fmt_for(tok)
|
||||
if fmt is not None and val:
|
||||
self._ranges.append((pos, pos + len(val), fmt))
|
||||
pos += len(val)
|
||||
self.rehighlight()
|
||||
|
||||
def highlightBlock(self, text: str) -> None: # noqa: N802 - Qt override
|
||||
if not self._ranges:
|
||||
return
|
||||
bstart = self.currentBlock().position()
|
||||
bend = bstart + len(text)
|
||||
for start, end, fmt in self._ranges:
|
||||
if end <= bstart or start >= bend:
|
||||
continue
|
||||
s = max(start, bstart) - bstart
|
||||
e = min(end, bend) - bstart
|
||||
if e > s:
|
||||
self.setFormat(s, e - s, fmt)
|
||||
|
||||
|
||||
class _LineNumbers(QWidget):
|
||||
def __init__(self, editor):
|
||||
super().__init__(editor)
|
||||
self._editor = editor
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(self._editor.line_number_width(), 0)
|
||||
|
||||
def paintEvent(self, event): # noqa: N802
|
||||
self._editor.paint_line_numbers(event)
|
||||
|
||||
|
||||
class CodeEditor(QPlainTextEdit):
|
||||
"""A dark, monospaced editor with a line-number gutter + Pygments colouring —
|
||||
the Sublime/VS-Code look for viewing & editing source files."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setObjectName("codeEditor")
|
||||
self.setLineWrapMode(QPlainTextEdit.NoWrap)
|
||||
self.setTabStopDistance(4 * self.fontMetrics().horizontalAdvance(" "))
|
||||
font = QFont("Consolas")
|
||||
font.setStyleHint(QFont.Monospace)
|
||||
font.setPointSize(10)
|
||||
self.setFont(font)
|
||||
self._gutter = _LineNumbers(self)
|
||||
self.blockCountChanged.connect(lambda _=0: self._update_gutter_width())
|
||||
self.updateRequest.connect(self._on_update_request)
|
||||
self._highlighter = PygmentsHighlighter(self.document())
|
||||
self._update_gutter_width()
|
||||
|
||||
def line_number_width(self) -> int:
|
||||
digits = max(2, len(str(max(1, self.blockCount()))))
|
||||
return 12 + self.fontMetrics().horizontalAdvance("9") * digits
|
||||
|
||||
def _update_gutter_width(self) -> None:
|
||||
self.setViewportMargins(self.line_number_width(), 0, 0, 0)
|
||||
|
||||
def _on_update_request(self, rect, dy: int) -> None:
|
||||
if dy:
|
||||
self._gutter.scroll(0, dy)
|
||||
else:
|
||||
self._gutter.update(0, rect.y(), self._gutter.width(), rect.height())
|
||||
if rect.contains(self.viewport().rect()):
|
||||
self._update_gutter_width()
|
||||
|
||||
def resizeEvent(self, event): # noqa: N802
|
||||
super().resizeEvent(event)
|
||||
cr = self.contentsRect()
|
||||
self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.line_number_width(), cr.height()))
|
||||
|
||||
def paint_line_numbers(self, event) -> None:
|
||||
p = current_palette()
|
||||
painter = QPainter(self._gutter)
|
||||
painter.fillRect(event.rect(), QColor(p.code_gutter_bg))
|
||||
block = self.firstVisibleBlock()
|
||||
num = block.blockNumber()
|
||||
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
|
||||
bottom = top + self.blockBoundingRect(block).height()
|
||||
painter.setPen(QColor(p.code_gutter_fg))
|
||||
while block.isValid() and top <= event.rect().bottom():
|
||||
if block.isVisible() and bottom >= event.rect().top():
|
||||
painter.drawText(0, int(top), self._gutter.width() - 6,
|
||||
self.fontMetrics().height(), Qt.AlignRight,
|
||||
str(num + 1))
|
||||
block = block.next()
|
||||
top = bottom
|
||||
bottom = top + self.blockBoundingRect(block).height()
|
||||
num += 1
|
||||
|
||||
def load_file(self, path: str, text: str) -> None:
|
||||
self.setPlainText(text)
|
||||
self._highlighter.set_filename(path, text)
|
||||
|
||||
|
||||
__all__ = ["CodeEditor", "PygmentsHighlighter"]
|
||||
@@ -0,0 +1,304 @@
|
||||
"""DocumentPreviewManager — the view/edit pane of the Folder Explorer
|
||||
(R08-T12, extracted from ``ui/folder_tab.py::FolderTab``, lines 295-373/
|
||||
410-449/649-699 of the original 1587-line file: the preview
|
||||
``QStackedWidget`` + open/save/create/external dispatch. HTML/PPTX/Excel/
|
||||
PDF/office rendering lives in ``office_document_renderer.py``; the code
|
||||
editor widget lives in ``code_editor.py`` — both split out to keep this file
|
||||
under the 400-line cap.
|
||||
|
||||
**Closes the R06-T05 loop**: ``application/workspaces/file_workspace_service.
|
||||
py::FileWorkspaceService`` existed since R06 but had zero production call
|
||||
sites (confirmed by grep before this task — ``ui/folder_tab.py`` wrote files
|
||||
with raw ``Path.write_text`` instead). Every plain-text write this class does
|
||||
(``save``, ``create_new_file``, ``write_content``) now goes through it —
|
||||
same path-containment check, same auto ``mkdir``, and (new, from
|
||||
``infrastructure/filesystem/file_tools.py::write_file``) a Python-syntax
|
||||
warning on a bad ``.py`` write, which the original code never had. A ``.pptx``
|
||||
save still goes through ``core/pptx_edit.py`` directly — that's a binary
|
||||
package build, not a text write, and ``FileWorkspaceService`` has no opinion
|
||||
on it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QLabel, QPushButton, QScrollArea, QStackedWidget,
|
||||
QTextBrowser, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.application.workspaces import FileWorkspaceService
|
||||
from cowork_local.application.workspaces.file_preview_helpers import (
|
||||
is_probably_text, pptx_available, read_text,
|
||||
)
|
||||
from cowork_local.domain.workspaces.workspace_session import WorkspaceSession
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.presentation.folder.code_editor import CodeEditor
|
||||
from cowork_local.presentation.folder.office_document_renderer import OfficeDocumentRenderer
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.libreoffice_view import DOC_SUFFIXES
|
||||
|
||||
_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico"}
|
||||
_HTML_SUFFIXES = {".html", ".htm"}
|
||||
_PPTX_SUFFIXES = {".pptx"} # text-editable in-app via python-pptx (no PowerPoint)
|
||||
_EXCEL_SUFFIXES = {".xlsx", ".xlsm"} # viewed as a real table via openpyxl (no LibreOffice)
|
||||
_MAX_EDIT_BYTES = 2_000_000 # above this, view read-only / note only
|
||||
|
||||
|
||||
class DocumentPreviewManager(QWidget):
|
||||
"""View/edit pane: header (file name, Preview⇄Edit toggle, Save, Open
|
||||
externally) above a ``QStackedWidget`` that renders whichever preview a
|
||||
file's suffix calls for."""
|
||||
|
||||
status_message = Signal(str)
|
||||
ai_reset_requested = Signal() # a DIFFERENT file was opened by the user
|
||||
|
||||
def __init__(self, root: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._root = root
|
||||
self._file_service = FileWorkspaceService(WorkspaceSession.unscoped(Path(root)))
|
||||
self._office = OfficeDocumentRenderer(self)
|
||||
self._edit_kind: Optional[str] = None # None | "html" | "pptx"
|
||||
self._current_file: Optional[str] = None
|
||||
|
||||
rl = QVBoxLayout(self)
|
||||
rl.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
hdr = QHBoxLayout()
|
||||
self.file_label = QLabel("")
|
||||
self.file_label.setStyleSheet("font-weight:600;")
|
||||
self.file_label.setWordWrap(True)
|
||||
hdr.addWidget(self.file_label, 1)
|
||||
self.mode_btn = QPushButton() # Preview⇄Edit toggle (HTML / PPTX)
|
||||
self.mode_btn.setCheckable(True)
|
||||
self.mode_btn.clicked.connect(self._office.toggle_edit_mode)
|
||||
self.mode_btn.setVisible(False)
|
||||
hdr.addWidget(self.mode_btn)
|
||||
self.save_btn = QPushButton()
|
||||
self.save_btn.setIcon(icon("save"))
|
||||
self.save_btn.setObjectName("primary")
|
||||
self.save_btn.clicked.connect(self.save)
|
||||
self.save_btn.setVisible(False)
|
||||
hdr.addWidget(self.save_btn)
|
||||
self.ext_btn = QPushButton()
|
||||
self.ext_btn.setIcon(icon("upload"))
|
||||
self.ext_btn.clicked.connect(self.open_external)
|
||||
self.ext_btn.setVisible(False)
|
||||
hdr.addWidget(self.ext_btn)
|
||||
rl.addLayout(hdr)
|
||||
# Exposed so the shell can insert its own AI-panel toggle button into
|
||||
# this same header row (between mode_btn and save_btn, matching the
|
||||
# original single-class layout) without this class knowing the AI
|
||||
# panel exists.
|
||||
self.header_layout = hdr
|
||||
|
||||
self.stack = QStackedWidget()
|
||||
self._placeholder = QLabel("")
|
||||
self._placeholder.setObjectName("hint")
|
||||
self._placeholder.setAlignment(Qt.AlignCenter)
|
||||
self.stack.addWidget(self._placeholder) # 0
|
||||
|
||||
self.editor = CodeEditor() # 1
|
||||
self.stack.addWidget(self.editor)
|
||||
|
||||
self.web = QTextBrowser() # 2
|
||||
self.web.setOpenExternalLinks(True)
|
||||
self.stack.addWidget(self.web)
|
||||
|
||||
self.doc_view = QTextBrowser() # 3
|
||||
self.doc_view.setObjectName("docPreview")
|
||||
self.stack.addWidget(self.doc_view)
|
||||
|
||||
self._img_scroll = QScrollArea() # 4
|
||||
self._img_scroll.setWidgetResizable(True)
|
||||
self._img_label = QLabel("")
|
||||
self._img_label.setAlignment(Qt.AlignCenter)
|
||||
self._img_scroll.setWidget(self._img_label)
|
||||
self.stack.addWidget(self._img_scroll)
|
||||
|
||||
rl.addWidget(self.stack, 1)
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.save_btn.setText(tr("folder.save"))
|
||||
self.ext_btn.setText(tr("folder.open_external"))
|
||||
if not self._current_file:
|
||||
self._placeholder.setText(tr("folder.select_file"))
|
||||
self._retranslate_mode_btn()
|
||||
|
||||
def _retranslate_mode_btn(self) -> None:
|
||||
self.mode_btn.setText(tr("folder.edit") if not self.mode_btn.isChecked()
|
||||
else tr("folder.preview"))
|
||||
|
||||
# ---- public API used by the shell / AI panel --------------------------- #
|
||||
@property
|
||||
def current_file(self) -> Optional[str]:
|
||||
return self._current_file
|
||||
|
||||
@property
|
||||
def edit_kind(self) -> Optional[str]:
|
||||
return self._edit_kind
|
||||
|
||||
@property
|
||||
def root(self) -> str:
|
||||
return self._root
|
||||
|
||||
def set_root(self, root: str) -> None:
|
||||
self._root = root
|
||||
self._file_service = FileWorkspaceService(WorkspaceSession.unscoped(Path(root)))
|
||||
|
||||
def open_file(self, path: str, reset_ai: bool = True) -> None:
|
||||
# Switching to a DIFFERENT file starts a fresh AI-edit conversation
|
||||
# (reset_ai=False when the AI itself just CREATED this file — keep
|
||||
# that chat). Whether/how to reset is the AI panel's own business —
|
||||
# this class only announces that a genuine file switch happened.
|
||||
if reset_ai and path != self._current_file:
|
||||
self.ai_reset_requested.emit()
|
||||
self._current_file = path
|
||||
self.file_label.setText(path)
|
||||
suffix = Path(path).suffix.lower()
|
||||
self.mode_btn.setVisible(False)
|
||||
self.save_btn.setVisible(False)
|
||||
self.ext_btn.setVisible(False)
|
||||
self._edit_kind = None
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
except OSError:
|
||||
size = 0
|
||||
|
||||
if suffix in _IMAGE_SUFFIXES:
|
||||
self._show_image(path)
|
||||
elif suffix in _HTML_SUFFIXES:
|
||||
self._office.show_html(path, mode_preview=True)
|
||||
elif suffix in _PPTX_SUFFIXES and pptx_available():
|
||||
self._office.show_pptx(path, mode_preview=True)
|
||||
elif suffix in _EXCEL_SUFFIXES:
|
||||
self._office.show_excel(path)
|
||||
elif suffix in DOC_SUFFIXES:
|
||||
self._office.show_document(path)
|
||||
elif size > _MAX_EDIT_BYTES or not is_probably_text(path):
|
||||
self._show_binary(path)
|
||||
else:
|
||||
self._show_code(path)
|
||||
|
||||
def ensure_editable_for_ai(self) -> bool:
|
||||
"""Make the current file editable in the code editor (switching an
|
||||
HTML preview to edit, or loading a text file). Returns False when
|
||||
there's no file open or it isn't a text/code file."""
|
||||
path = self._current_file
|
||||
if not path or not os.path.isfile(path):
|
||||
return False
|
||||
suffix = Path(path).suffix.lower()
|
||||
if suffix in _HTML_SUFFIXES:
|
||||
self._office.show_html(path, mode_preview=False)
|
||||
return True
|
||||
if suffix in _PPTX_SUFFIXES and pptx_available():
|
||||
self._office.show_pptx(path, mode_preview=False)
|
||||
return True
|
||||
if suffix in DOC_SUFFIXES or suffix in _IMAGE_SUFFIXES:
|
||||
return False
|
||||
if is_probably_text(path):
|
||||
self._show_code(path)
|
||||
return True
|
||||
return False
|
||||
|
||||
def save(self) -> None:
|
||||
if not self._current_file:
|
||||
return
|
||||
try:
|
||||
if self._edit_kind == "pptx":
|
||||
if not self._office.write_pptx(self.editor.toPlainText()):
|
||||
return
|
||||
else:
|
||||
self._write_plain_text(self._current_file, self.editor.toPlainText())
|
||||
self.status_message.emit(tr("folder.saved", name=Path(self._current_file).name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.status_message.emit(tr("folder.save_error", err=str(exc)))
|
||||
|
||||
def write_content(self, content: str, skip_image_confirm: bool = False) -> None:
|
||||
"""Persist AI-confirmed content to disk AND refresh the preview.
|
||||
pptx text is written back into the deck (no PowerPoint window)."""
|
||||
if not self._current_file:
|
||||
return
|
||||
try:
|
||||
if self._edit_kind == "pptx":
|
||||
if not self._office.write_pptx(content, skip_confirm=skip_image_confirm):
|
||||
return
|
||||
else:
|
||||
self._write_plain_text(self._current_file, content)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.status_message.emit(tr("folder.save_error", err=str(exc)))
|
||||
return
|
||||
suffix = Path(self._current_file).suffix.lower()
|
||||
if suffix in _HTML_SUFFIXES:
|
||||
self._office.show_html(self._current_file, mode_preview=True)
|
||||
elif suffix in _PPTX_SUFFIXES:
|
||||
self._office.show_pptx(self._current_file, mode_preview=True)
|
||||
|
||||
def create_new_file(self, target: str, content: str) -> Optional[str]:
|
||||
"""Create ``target`` (relative to the folder root) with ``content``
|
||||
and open it — like Cowork's save_file. Refuses paths escaping the
|
||||
root (enforced by ``FileWorkspaceService``/``WorkspaceSession``)."""
|
||||
root = os.path.normpath(self._root)
|
||||
dest = target if os.path.isabs(target) else os.path.join(root, target)
|
||||
dest = os.path.normpath(dest)
|
||||
try:
|
||||
if Path(dest).suffix.lower() in _PPTX_SUFFIXES and pptx_available():
|
||||
# A .pptx is a binary package — build a real deck from the
|
||||
# marker text (writing text straight to .pptx would corrupt it).
|
||||
from cowork_local.core import pptx_edit
|
||||
os.makedirs(os.path.dirname(dest) or root, exist_ok=True)
|
||||
pptx_edit.create_pptx_from_text(dest, content)
|
||||
else:
|
||||
self._write_plain_text(dest, content)
|
||||
except Exception as exc: # noqa: BLE001 - OS error, containment error, or pptx build failure
|
||||
self.status_message.emit(tr("folder.save_error", err=str(exc)))
|
||||
return None
|
||||
self.open_file(dest, reset_ai=False) # show the new file; keep the AI chat
|
||||
return dest
|
||||
|
||||
def open_external(self) -> None:
|
||||
if self._current_file:
|
||||
from cowork_local.ui.osutil import open_location
|
||||
open_location(self._current_file)
|
||||
|
||||
# ---- writes ------------------------------------------------------------- #
|
||||
def _write_plain_text(self, path: str, content: str) -> None:
|
||||
"""Write ``content`` to ``path`` (must resolve inside the current
|
||||
root) via ``FileWorkspaceService`` — same containment check, ``mkdir``
|
||||
and Python-syntax warning the agent's own ``write_file`` tool gets."""
|
||||
rel = os.path.relpath(path, self._root)
|
||||
result = self._file_service.write_file(rel, content)
|
||||
if not result.get("ok"):
|
||||
raise OSError(result.get("output") or "write failed")
|
||||
|
||||
# ---- simple renderers (HTML/PPTX/Excel/PDF/office live in
|
||||
# office_document_renderer.py) --------------------------------------------- #
|
||||
def _show_code(self, path: str) -> None:
|
||||
text = read_text(path)
|
||||
self.editor.setReadOnly(False)
|
||||
self.editor.load_file(path, text)
|
||||
self.save_btn.setVisible(True)
|
||||
self.stack.setCurrentWidget(self.editor)
|
||||
|
||||
def _show_image(self, path: str) -> None:
|
||||
from PySide6.QtGui import QPixmap
|
||||
pix = QPixmap(path)
|
||||
if pix.isNull():
|
||||
self._show_binary(path)
|
||||
return
|
||||
self._img_label.setPixmap(pix)
|
||||
self._img_label.resize(pix.size())
|
||||
self.ext_btn.setVisible(True)
|
||||
self.stack.setCurrentWidget(self._img_scroll)
|
||||
|
||||
def _show_binary(self, path: str) -> None:
|
||||
self._placeholder.setText(tr("folder.binary_file"))
|
||||
self.ext_btn.setVisible(True)
|
||||
self.stack.setCurrentWidget(self._placeholder)
|
||||
|
||||
|
||||
__all__ = ["DocumentPreviewManager"]
|
||||
@@ -0,0 +1,120 @@
|
||||
"""FolderTab shell (R08-T12) — assembles
|
||||
``workspace_file_tree.py::WorkspaceFileTree``,
|
||||
``document_preview_manager.py::DocumentPreviewManager`` and
|
||||
``ai_file_editor_dialog.py::AiFileEditorDialog`` behind the splitter/terminal
|
||||
layout that used to be inline in ``ui/folder_tab.py::FolderTab.__init__``
|
||||
(lines 238-384 of the original 1587-line file).
|
||||
|
||||
The AI-panel toggle button (``ai_btn``) lives here because it controls
|
||||
things two different children own: the panel's own visibility AND the
|
||||
content splitter's sizing — a genuine shell-level concern, not either
|
||||
child's.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QSplitter, QVBoxLayout, QWidget
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.folder.ai_file_editor_dialog import AiFileEditorDialog
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class FolderTab(QWidget):
|
||||
"""Two-pane file explorer: directory tree + view/edit pane (+ collapsible
|
||||
AI-edit panel, + collapsible terminal)."""
|
||||
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext, cowork=None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._root = str(ctx.config.cowork_output_dir())
|
||||
|
||||
root_layout = QVBoxLayout(self)
|
||||
split = QSplitter(Qt.Horizontal)
|
||||
|
||||
self.tree = WorkspaceFileTree(self._root)
|
||||
split.addWidget(self.tree)
|
||||
|
||||
right = QWidget()
|
||||
rl = QVBoxLayout(right)
|
||||
rl.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.preview = DocumentPreviewManager(self._root)
|
||||
self.ai_panel = AiFileEditorDialog(ctx, self.preview, cowork=cowork)
|
||||
|
||||
self.ai_btn = QPushButton() # expand/collapse the AI-edit panel
|
||||
self.ai_btn.setIcon(icon("sparkle"))
|
||||
self.ai_btn.setCheckable(True)
|
||||
self.ai_btn.clicked.connect(self._toggle_ai_panel)
|
||||
# Same visual position as the original single-class header: between
|
||||
# the Preview⇄Edit toggle and Save (file_label=0, mode_btn=1).
|
||||
self.preview.header_layout.insertWidget(2, self.ai_btn)
|
||||
self.ai_panel.badge_changed.connect(self._on_ai_badge_changed)
|
||||
|
||||
content_split = QSplitter(Qt.Horizontal)
|
||||
content_split.addWidget(self.preview)
|
||||
content_split.addWidget(self.ai_panel)
|
||||
content_split.setStretchFactor(0, 1)
|
||||
content_split.setStretchFactor(1, 0)
|
||||
content_split.setSizes([700, 320])
|
||||
self._content_split = content_split
|
||||
self.ai_panel.setVisible(False) # default collapsed
|
||||
rl.addWidget(content_split, 1)
|
||||
|
||||
split.addWidget(right)
|
||||
split.setStretchFactor(0, 0)
|
||||
split.setStretchFactor(1, 1)
|
||||
split.setSizes([300, 800])
|
||||
root_layout.addWidget(split, 1)
|
||||
|
||||
# Terminal CLI below the file view — collapsible, default collapsed;
|
||||
# opening it points the shell at the current workspace folder.
|
||||
from cowork_local.ui.terminal_panel import TerminalPanel
|
||||
|
||||
self.terminal = TerminalPanel()
|
||||
self.terminal.set_cwd(self._root)
|
||||
self.terminal.expanded.connect(lambda: self.terminal.set_cwd(self._root))
|
||||
root_layout.addWidget(self.terminal)
|
||||
|
||||
self.tree.file_selected.connect(self.preview.open_file)
|
||||
self.preview.status_message.connect(self.status_message.emit)
|
||||
self.ai_panel.status_message.connect(self.status_message.emit)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
self._retranslate()
|
||||
|
||||
# ---- public API ---------------------------------------------------------
|
||||
def set_root(self, path: str) -> None:
|
||||
self.tree.set_root(path)
|
||||
# WorkspaceFileTree silently no-ops on an invalid path (same guard
|
||||
# the original single-class _root setter had) — mirror that here by
|
||||
# only propagating when the tree actually accepted it.
|
||||
if self.tree.root == path:
|
||||
self._root = path
|
||||
self.preview.set_root(path)
|
||||
self.terminal.set_cwd(path)
|
||||
|
||||
def _toggle_ai_panel(self) -> None:
|
||||
show = self.ai_btn.isChecked()
|
||||
self.ai_panel.setVisible(show)
|
||||
if show:
|
||||
self._content_split.setSizes([700, 320])
|
||||
self.ai_panel.on_opened()
|
||||
|
||||
def _on_ai_badge_changed(self, suffix: str) -> None:
|
||||
self.ai_btn.setText(tr("folder.ai_edit") + suffix)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self.tree.retranslate()
|
||||
self.preview.retranslate()
|
||||
self.ai_panel.retranslate()
|
||||
self.ai_btn.setText(tr("folder.ai_edit"))
|
||||
self.ai_btn.setToolTip(tr("folder.ai_edit_tooltip"))
|
||||
|
||||
|
||||
__all__ = ["FolderTab"]
|
||||
@@ -0,0 +1,269 @@
|
||||
"""OfficeDocumentRenderer — HTML/PPTX/Excel/PDF/office-doc preview for
|
||||
``document_preview_manager.py`` (R08-T12, split out to keep that file under
|
||||
the 400-line cap; originally ``ui/folder_tab.py``, lines 451-647/679-694 of
|
||||
the original 1587-line file).
|
||||
|
||||
A plain (non-Qt-widget) helper composed BY a ``DocumentPreviewManager``
|
||||
rather than a widget of its own: these renderers are tightly coupled to the
|
||||
manager's shared ``QStackedWidget``/toolbar/editor — genuinely one screen's
|
||||
internal state, not an independent concern — so this is a composition split
|
||||
to respect the line-count cap, the same way
|
||||
``presentation/scheduling/kanban_board_widget.py`` composes
|
||||
``TaskApplicationService`` rather than owning that logic inline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtWidgets import QTabWidget, QTableWidget, QTableWidgetItem
|
||||
|
||||
from cowork_local.application.workspaces.file_preview_helpers import read_text
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE
|
||||
|
||||
try:
|
||||
from PySide6.QtPdf import QPdfDocument # noqa: F401
|
||||
from PySide6.QtPdfWidgets import QPdfView # noqa: F401
|
||||
HAS_PDF = True
|
||||
except Exception: # pragma: no cover - QtPdf not bundled
|
||||
HAS_PDF = False
|
||||
|
||||
|
||||
class OfficeDocumentRenderer:
|
||||
"""Renders HTML/PPTX/Excel/PDF/office docs into ``owner.stack``.
|
||||
|
||||
``owner`` is the ``DocumentPreviewManager`` — this class reaches into
|
||||
``owner.stack``/``owner.editor``/``owner.mode_btn``/``owner.ext_btn``/
|
||||
``owner.save_btn``/``owner.doc_view``/``owner.web`` because those widgets
|
||||
are shared with the manager's simpler renderers (code/image/binary);
|
||||
duplicating them here would mean two stacked widgets fighting over which
|
||||
one is "the" preview.
|
||||
"""
|
||||
|
||||
def __init__(self, owner) -> None:
|
||||
self._owner = owner
|
||||
self._engine = None
|
||||
self._pdf_view = None
|
||||
self._pdf_doc = None
|
||||
self._pdf_tmp: Optional[str] = None
|
||||
self._pdf_cache: dict = {}
|
||||
self._convert_worker = None
|
||||
self._xlsx_view = None
|
||||
|
||||
def show_html(self, path: str, mode_preview: bool) -> None:
|
||||
o = self._owner
|
||||
o._edit_kind = "html"
|
||||
o.mode_btn.setVisible(True)
|
||||
o.mode_btn.setChecked(not mode_preview) # checked = Edit
|
||||
o._retranslate_mode_btn()
|
||||
if mode_preview:
|
||||
from PySide6.QtCore import QUrl
|
||||
html = read_text(path)
|
||||
engine = self._ensure_engine()
|
||||
if engine is not None:
|
||||
engine.setHtml(html, QUrl.fromLocalFile(path))
|
||||
o.stack.setCurrentWidget(engine)
|
||||
else:
|
||||
o.web.setHtml(html)
|
||||
o.stack.setCurrentWidget(o.web)
|
||||
o.save_btn.setVisible(False)
|
||||
else:
|
||||
o._show_code(path)
|
||||
|
||||
def show_pptx(self, path: str, mode_preview: bool) -> None:
|
||||
"""PPTX: Preview renders the slides (PDF via LibreOffice); Edit shows the
|
||||
deck's text (marker-delimited per box) in the editor."""
|
||||
o = self._owner
|
||||
o._edit_kind = "pptx"
|
||||
o.mode_btn.setVisible(True)
|
||||
o.mode_btn.setChecked(not mode_preview) # checked = Edit
|
||||
o._retranslate_mode_btn()
|
||||
o.ext_btn.setVisible(True)
|
||||
if mode_preview:
|
||||
self.show_document(path) # PDF render of the slides
|
||||
o.mode_btn.setVisible(True) # show_document doesn't touch it
|
||||
else:
|
||||
from cowork_local.core.pptx_edit import pptx_to_text
|
||||
try:
|
||||
text = pptx_to_text(path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
text = f"[could not read pptx text: {exc}]"
|
||||
o.editor.setReadOnly(False)
|
||||
o.editor.load_file(path + ".txt", text) # .txt → plain highlighting
|
||||
o.save_btn.setVisible(True)
|
||||
o.stack.setCurrentWidget(o.editor)
|
||||
|
||||
def _ensure_engine(self):
|
||||
"""Create the QWebEngineView on first HTML preview (only when WebEngine
|
||||
is safe to use); otherwise stay on the QTextBrowser fallback."""
|
||||
if not HAS_WEB_ENGINE:
|
||||
return None
|
||||
if self._engine is None:
|
||||
try:
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
self._engine = QWebEngineView()
|
||||
self._owner.stack.addWidget(self._engine)
|
||||
except Exception: # noqa: BLE001
|
||||
self._engine = None
|
||||
return self._engine
|
||||
|
||||
def toggle_edit_mode(self) -> None:
|
||||
o = self._owner
|
||||
if not o.current_file:
|
||||
return
|
||||
preview = not o.mode_btn.isChecked() # checked = Edit
|
||||
if o._edit_kind == "pptx":
|
||||
self.show_pptx(o.current_file, mode_preview=preview)
|
||||
else:
|
||||
self.show_html(o.current_file, mode_preview=preview)
|
||||
|
||||
def show_excel(self, path: str) -> None:
|
||||
"""View a spreadsheet as a real TABLE (openpyxl) — one tab per sheet."""
|
||||
o = self._owner
|
||||
o.ext_btn.setVisible(True)
|
||||
try:
|
||||
from cowork_local.core.deps import ensure_module
|
||||
ensure_module("openpyxl", "openpyxl")
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(path, read_only=True, data_only=True)
|
||||
except Exception: # noqa: BLE001 - no openpyxl / unreadable → try PDF/text
|
||||
self.show_document(path)
|
||||
return
|
||||
MAX_ROWS, MAX_COLS = 2000, 100
|
||||
if self._xlsx_view is None:
|
||||
self._xlsx_view = QTabWidget()
|
||||
o.stack.addWidget(self._xlsx_view)
|
||||
tabs = self._xlsx_view
|
||||
while tabs.count():
|
||||
w = tabs.widget(0); tabs.removeTab(0); w.deleteLater()
|
||||
try:
|
||||
for ws in wb.worksheets:
|
||||
rows = list(ws.iter_rows(max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True))
|
||||
ncols = max((len(r) for r in rows), default=0)
|
||||
table = QTableWidget(len(rows), ncols)
|
||||
table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
table.horizontalHeader().setVisible(False)
|
||||
for r, row in enumerate(rows):
|
||||
for c, val in enumerate(row):
|
||||
if val is not None:
|
||||
table.setItem(r, c, QTableWidgetItem(str(val)))
|
||||
table.resizeColumnsToContents()
|
||||
title = ws.title + (" (…)" if (ws.max_row or 0) > MAX_ROWS
|
||||
or (ws.max_column or 0) > MAX_COLS else "")
|
||||
tabs.addTab(table, title)
|
||||
finally:
|
||||
wb.close()
|
||||
if tabs.count() == 0:
|
||||
self.show_document(path)
|
||||
return
|
||||
o.stack.setCurrentWidget(tabs)
|
||||
|
||||
def show_document(self, path: str) -> None:
|
||||
"""Office docs + PDF are RENDERED via QtPdf — LibreOffice converts
|
||||
them to PDF first. Falls back to text extraction when QtPdf/
|
||||
LibreOffice aren't available."""
|
||||
o = self._owner
|
||||
o.ext_btn.setVisible(True)
|
||||
suffix = Path(path).suffix.lower()
|
||||
if not HAS_PDF:
|
||||
self.show_document_text(path)
|
||||
return
|
||||
if suffix == ".pdf":
|
||||
self._render_pdf(path)
|
||||
return
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
except OSError:
|
||||
mtime = 0
|
||||
cached = self._pdf_cache.get((path, mtime))
|
||||
if cached and os.path.exists(cached):
|
||||
self._render_pdf(cached)
|
||||
return
|
||||
from cowork_local.core.doc_extract import convert_to_pdf, find_soffice
|
||||
if not find_soffice() and os.name != "nt":
|
||||
self.show_document_text(path)
|
||||
return
|
||||
o.doc_view.setPlainText(tr("folder.converting"))
|
||||
o.stack.setCurrentWidget(o.doc_view)
|
||||
if self._pdf_tmp is None:
|
||||
import tempfile
|
||||
self._pdf_tmp = tempfile.mkdtemp(prefix="cowork_folder_pdf_")
|
||||
src, out_dir = path, self._pdf_tmp
|
||||
|
||||
def job(worker):
|
||||
return {"src": src, "mtime": mtime, "pdf": convert_to_pdf(src, out_dir)}
|
||||
|
||||
def done(result):
|
||||
if result.get("src") != o.current_file:
|
||||
return # user moved on to another file
|
||||
pdf = result.get("pdf")
|
||||
if pdf:
|
||||
self._pdf_cache[(result["src"], result["mtime"])] = pdf
|
||||
self._render_pdf(pdf)
|
||||
else:
|
||||
self.show_document_text(src)
|
||||
|
||||
worker = AgentWorker(job)
|
||||
worker.finished_ok.connect(done)
|
||||
worker.failed.connect(lambda _e, p=src: self.show_document_text(p))
|
||||
self._convert_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _ensure_pdf_view(self):
|
||||
if not HAS_PDF:
|
||||
return None
|
||||
if self._pdf_view is None:
|
||||
from PySide6.QtPdf import QPdfDocument
|
||||
from PySide6.QtPdfWidgets import QPdfView
|
||||
self._pdf_doc = QPdfDocument(self._owner)
|
||||
self._pdf_view = QPdfView(self._owner)
|
||||
self._pdf_view.setDocument(self._pdf_doc)
|
||||
try:
|
||||
self._pdf_view.setPageMode(QPdfView.PageMode.MultiPage)
|
||||
self._pdf_view.setZoomMode(QPdfView.ZoomMode.FitToWidth)
|
||||
except Exception: # noqa: BLE001 - enum names vary slightly across versions
|
||||
pass
|
||||
self._owner.stack.addWidget(self._pdf_view)
|
||||
return self._pdf_view
|
||||
|
||||
def _render_pdf(self, pdf_path: str) -> None:
|
||||
view = self._ensure_pdf_view()
|
||||
if view is None:
|
||||
self.show_document_text(pdf_path)
|
||||
return
|
||||
self._pdf_doc.load(pdf_path)
|
||||
self._owner.stack.setCurrentWidget(view)
|
||||
|
||||
def show_document_text(self, path: str) -> None:
|
||||
from cowork_local.core.doc_extract import extract_text
|
||||
o = self._owner
|
||||
try:
|
||||
text, note = extract_text(path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
text, note = None, str(exc)
|
||||
body = text if text else tr("folder.doc_unreadable", note=note or "?")
|
||||
o.doc_view.setPlainText(body)
|
||||
o.stack.setCurrentWidget(o.doc_view)
|
||||
|
||||
def write_pptx(self, content: str, skip_confirm: bool = False) -> bool:
|
||||
"""Write edited pptx text back into the deck. If the edit REPLACES any
|
||||
image, ask the user to confirm first. ``skip_confirm`` is used when
|
||||
the image was already confirmed (e.g. just generated). Returns False
|
||||
if the user declined."""
|
||||
from cowork_local.core import pptx_edit
|
||||
o = self._owner
|
||||
if not skip_confirm and pptx_edit.image_change_requested(content):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
ok = QMessageBox.question(o, tr("folder.ai_image_confirm_title"),
|
||||
tr("folder.ai_image_confirm"))
|
||||
if ok != QMessageBox.Yes:
|
||||
o.status_message.emit(tr("folder.ai_image_declined"))
|
||||
return False
|
||||
pptx_edit.apply_text_to_pptx(o.current_file, content)
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["OfficeDocumentRenderer", "HAS_PDF"]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""WorkspaceFileTree — the folder-picker bar + directory tree pane of the
|
||||
Folder Explorer (R08-T12, extracted from ``ui/folder_tab.py::FolderTab``,
|
||||
lines 264-293/386-408 of the original 1587-line file).
|
||||
|
||||
Owns navigation only: which root is browsed and which file was clicked.
|
||||
Rendering/editing the SELECTED file is
|
||||
``document_preview_manager.py::DocumentPreviewManager``'s job — this widget
|
||||
just emits :attr:`file_selected`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QFileDialog, QFileSystemModel, QHBoxLayout, QLabel, QPushButton,
|
||||
QTreeView, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class WorkspaceFileTree(QWidget):
|
||||
"""The left-hand tree pane: a path bar (label + "open folder" button)
|
||||
above a ``QFileSystemModel``-backed ``QTreeView``."""
|
||||
|
||||
file_selected = Signal(str) # absolute path of the clicked file
|
||||
root_changed = Signal(str) # absolute path of the new root
|
||||
|
||||
def __init__(self, initial_root: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self._root = initial_root
|
||||
|
||||
root_layout = QVBoxLayout(self)
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# The path IS the title of this screen, so it is written as one
|
||||
# rather than shown in a read-only text box that looks editable.
|
||||
# Full path on hover; the button still opens the folder picker.
|
||||
bar = QHBoxLayout()
|
||||
self.path_lbl = QLabel(self._root)
|
||||
self.path_lbl.setObjectName("folderTitle")
|
||||
self.path_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
self.path_lbl.setToolTip(self._root)
|
||||
self._open_btn = QPushButton()
|
||||
self._open_btn.setIcon(icon("folder"))
|
||||
self._open_btn.setObjectName("primary")
|
||||
self._open_btn.clicked.connect(self._pick_root)
|
||||
bar.addWidget(self.path_lbl, 1)
|
||||
bar.addWidget(self._open_btn)
|
||||
root_layout.addLayout(bar)
|
||||
|
||||
self.model = QFileSystemModel()
|
||||
self.model.setRootPath(self._root)
|
||||
self.tree = QTreeView()
|
||||
self.tree.setModel(self.model)
|
||||
self.tree.setRootIndex(self.model.index(self._root))
|
||||
for col in (1, 2, 3): # hide Size / Type / Date-modified columns
|
||||
self.tree.hideColumn(col)
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.clicked.connect(self._on_tree_clicked)
|
||||
root_layout.addWidget(self.tree, 1)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self._open_btn.setToolTip(tr("folder.path_placeholder"))
|
||||
self._open_btn.setText(tr("folder.open_folder"))
|
||||
|
||||
@property
|
||||
def root(self) -> str:
|
||||
return self._root
|
||||
|
||||
def set_root(self, path: str) -> None:
|
||||
p = str(path or "").strip()
|
||||
if not p or not os.path.isdir(p):
|
||||
return
|
||||
self._root = p
|
||||
self.path_lbl.setText(p)
|
||||
self.path_lbl.setToolTip(p)
|
||||
self.model.setRootPath(p)
|
||||
self.tree.setRootIndex(self.model.index(p))
|
||||
self.root_changed.emit(p)
|
||||
|
||||
def _pick_root(self) -> None:
|
||||
chosen = QFileDialog.getExistingDirectory(self, tr("folder.open_folder"), self._root)
|
||||
if chosen:
|
||||
self.set_root(chosen)
|
||||
|
||||
def _on_tree_clicked(self, index) -> None:
|
||||
path = self.model.filePath(index)
|
||||
if path and os.path.isfile(path):
|
||||
self.file_selected.emit(path)
|
||||
|
||||
|
||||
__all__ = ["WorkspaceFileTree"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""GraphRAG (Structure) screen, split into single-responsibility widgets
|
||||
(R08-T14): ``graph_scene_items``, ``graph_renderer``, ``graph_qa_widget``,
|
||||
assembled by the ``structure_graph_view`` shell."""
|
||||
@@ -0,0 +1,99 @@
|
||||
"""GraphMessagesView — the "Messages by day" tab of GraphRAG (R08-T14, split
|
||||
out of ``graph_renderer.py`` to keep that file under the 400-line cap;
|
||||
originally ``ui/structure_graph_view.py``, lines 427-497 of the original
|
||||
1035-line file: ``_on_view_tab``, ``_toggle_messages``, ``_reload_messages``,
|
||||
``_show_msg_json``).
|
||||
|
||||
A plain (non-Qt-widget) helper composed BY ``GraphRenderer`` — same
|
||||
composition-to-respect-the-line-cap pattern as
|
||||
``office_document_renderer.py``. Owns the ``QTreeWidget`` itself (built
|
||||
here, added to the owner's stack at construction) since nothing else needs
|
||||
it, but reaches into ``owner._stack``/``owner.web``/``owner.view``/
|
||||
``owner.active_project_id`` to switch the shared stack and scope by project.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
|
||||
class GraphMessagesView:
|
||||
def __init__(self, owner) -> None:
|
||||
self._owner = owner
|
||||
self.widget = QTreeWidget()
|
||||
self.widget.setHeaderHidden(True)
|
||||
self.widget.itemClicked.connect(self._show_msg_json)
|
||||
owner._stack.addWidget(self.widget)
|
||||
|
||||
def on_view_tab(self, index: int) -> None:
|
||||
"""Tab 0 = graph, tab 1 = messages."""
|
||||
o = self._owner
|
||||
if index == 1:
|
||||
self.reload()
|
||||
o._stack.setCurrentWidget(self.widget)
|
||||
else:
|
||||
o._stack.setCurrentWidget(o.web if o.web is not None else o.view)
|
||||
|
||||
def toggle(self) -> None:
|
||||
"""Kept for callers that still ask for a flip (e.g. keyboard paths)."""
|
||||
o = self._owner
|
||||
showing = o._stack.currentWidget() is self.widget
|
||||
o.view_tabs.setCurrentIndex(0 if showing else 1)
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Build the tree: day -> conversation. Click a conversation to see
|
||||
its messages as JSON. Scoped to the current project's history."""
|
||||
from cowork_local.core.history import list_conversations
|
||||
|
||||
o = self._owner
|
||||
self.widget.clear()
|
||||
pid = o.active_project_id or ""
|
||||
by_day: "OrderedDict[str, list]" = OrderedDict()
|
||||
try:
|
||||
convs = list_conversations(o.ctx.config.history_dir())
|
||||
except Exception: # noqa: BLE001
|
||||
convs = []
|
||||
for conv in convs:
|
||||
if pid and conv.get("project_id", "default") != pid:
|
||||
continue
|
||||
day = (conv.get("created") or "")[:10] or "—"
|
||||
by_day.setdefault(day, []).append(conv)
|
||||
if not by_day:
|
||||
self.widget.addTopLevelItem(QTreeWidgetItem([tr("structure.msgs_none")]))
|
||||
return
|
||||
for day in sorted(by_day, reverse=True):
|
||||
convs_d = by_day[day]
|
||||
day_item = QTreeWidgetItem([f"{day} ({len(convs_d)})"])
|
||||
for conv in convs_d:
|
||||
it = QTreeWidgetItem([conv.get("title", "(untitled)")])
|
||||
it.setData(0, Qt.UserRole, str(conv.get("path", "")))
|
||||
day_item.addChild(it)
|
||||
self.widget.addTopLevelItem(day_item)
|
||||
day_item.setExpanded(True)
|
||||
|
||||
def _show_msg_json(self, item, _col: int = 0) -> None:
|
||||
import html
|
||||
import json
|
||||
|
||||
from cowork_local.core.history import load_conversation
|
||||
path = item.data(0, Qt.UserRole)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
conv = load_conversation(path)
|
||||
payload = {"title": conv.get("title", ""), "created": conv.get("created", ""),
|
||||
"kind": conv.get("kind", ""), "project_id": conv.get("project_id", ""),
|
||||
"messages": conv.get("messages", [])}
|
||||
text = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
text = f"(could not read: {exc})"
|
||||
self._owner.raw_json_ready.emit(
|
||||
f'<pre style="white-space:pre-wrap; font-family:Consolas,monospace; '
|
||||
f'font-size:12px;">{html.escape(text)}</pre>')
|
||||
|
||||
|
||||
__all__ = ["GraphMessagesView"]
|
||||
@@ -0,0 +1,374 @@
|
||||
"""GraphQaWidget — the right-side "ask questions about this graph" panel of
|
||||
GraphRAG (R08-T14, extracted from
|
||||
``ui/structure_graph_view.py::StructureGraphView``, lines 288-334/342-360
|
||||
(partial)/647-663/704-955 of the original 1035-line file).
|
||||
|
||||
Reads the current graph and scene selection from a
|
||||
``graph_renderer.py::GraphRenderer`` instance passed at construction
|
||||
(``renderer.graph``, ``renderer.selected_node_data()``,
|
||||
``renderer.active_project_id``) and reacts to its
|
||||
``node_selected``/``graph_rendered``/``raw_json_ready`` signals — this class
|
||||
has no rendering state of its own, matching how
|
||||
``presentation/folder/ai_file_editor_dialog.py`` reads
|
||||
``DocumentPreviewManager`` rather than duplicating file state.
|
||||
|
||||
File-content extraction for grounding the answer goes through
|
||||
``application/workspaces/graph_index_service.py`` (R08-T14 also moved that
|
||||
out of this file, as pure Python — see its own docstring).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from PySide6.QtCore import QUrl, Signal
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QTextBrowser, QVBoxLayout, QWidget
|
||||
|
||||
from cowork_local.application.workspaces.graph_index_service import extract_file_contents
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.icons import collapse_right_icon, icon
|
||||
from cowork_local.ui.osutil import open_folder, open_location
|
||||
from cowork_local.ui.widgets import CollapseStrip
|
||||
|
||||
|
||||
class GraphQaWidget(QWidget):
|
||||
"""The collapsible pane itself (strip + header + ask row + detail
|
||||
browser) — the shell adds ONE widget to its splitter."""
|
||||
|
||||
status_message = Signal(str)
|
||||
collapse_changed = Signal(bool) # so the shell can resize its own splitter
|
||||
|
||||
def __init__(self, ctx, renderer, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._renderer = renderer
|
||||
self._ask_worker: Optional[AgentWorker] = None
|
||||
self._answer = ""
|
||||
self._detail_mode = "idle" # "answer" | "node" | "idle"
|
||||
self._extract_cache: dict = {}
|
||||
self._extract_dir = None
|
||||
|
||||
outer = QHBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(0)
|
||||
self._strip = CollapseStrip(tr("structure.expand_agent_tooltip"), expand_dir="left")
|
||||
self._strip.clicked.connect(lambda: self._set_collapsed(False))
|
||||
self._strip.setVisible(False)
|
||||
outer.addWidget(self._strip)
|
||||
|
||||
self._panel = QWidget()
|
||||
rl = QVBoxLayout(self._panel)
|
||||
rl.setContentsMargins(0, 0, 0, 0)
|
||||
ag_hdr = QHBoxLayout()
|
||||
self._collapse_btn = QPushButton()
|
||||
self._collapse_btn.setIcon(collapse_right_icon())
|
||||
self._collapse_btn.setFixedWidth(28)
|
||||
self._collapse_btn.clicked.connect(lambda: self._set_collapsed(True))
|
||||
self._label = QLabel()
|
||||
ag_hdr.addWidget(self._collapse_btn)
|
||||
ag_hdr.addWidget(self._label, 1)
|
||||
rl.addLayout(ag_hdr)
|
||||
|
||||
ask_row = QHBoxLayout()
|
||||
self.ask_edit = QLineEdit()
|
||||
self.ask_edit.returnPressed.connect(self._ask)
|
||||
self._ask_btn = QPushButton()
|
||||
self._ask_btn.setIcon(icon("chat"))
|
||||
self._ask_btn.setObjectName("primary")
|
||||
self._ask_btn.clicked.connect(self._ask)
|
||||
ask_row.addWidget(self.ask_edit, 1)
|
||||
ask_row.addWidget(self._ask_btn)
|
||||
rl.addLayout(ask_row)
|
||||
|
||||
self.detail = QTextBrowser()
|
||||
self.detail.setReadOnly(True)
|
||||
self.detail.setOpenLinks(False)
|
||||
self.detail.anchorClicked.connect(self._on_detail_link)
|
||||
rl.addWidget(self.detail, 1)
|
||||
outer.addWidget(self._panel, 1)
|
||||
|
||||
renderer.node_selected.connect(self._on_node_selected)
|
||||
renderer.graph_rendered.connect(self._preserve_answer)
|
||||
renderer.raw_json_ready.connect(self._show_raw_json)
|
||||
renderer.project_changed.connect(self.clear_extracts)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self._collapse_btn.setToolTip(tr("structure.collapse_agent_tooltip"))
|
||||
self._label.setText(tr("structure.agent_header"))
|
||||
self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder"))
|
||||
self._ask_btn.setText(tr("structure.ask"))
|
||||
if self._detail_mode == "idle":
|
||||
self.detail.setPlaceholderText(tr("structure.detail_placeholder"))
|
||||
self._strip.setToolTip(tr("structure.expand_agent_tooltip"))
|
||||
|
||||
# ---- collapse ------------------------------------------------------------- #
|
||||
def _set_collapsed(self, collapsed: bool) -> None:
|
||||
self._panel.setVisible(not collapsed)
|
||||
self._strip.setVisible(collapsed)
|
||||
self.collapse_changed.emit(collapsed)
|
||||
|
||||
# ---- reacting to the renderer ----------------------------------------------- #
|
||||
def _on_node_selected(self, data) -> None:
|
||||
self.detail.setPlainText(f"[{data.kind.upper()}] {data.label}\n\n{data.detail}")
|
||||
self._detail_mode = "node"
|
||||
|
||||
def _show_raw_json(self, html_text: str) -> None:
|
||||
self.detail.setHtml(html_text)
|
||||
|
||||
def _preserve_answer(self) -> None:
|
||||
if self._detail_mode == "answer" and self._answer.strip():
|
||||
self._render_answer()
|
||||
|
||||
# ---- Q&A -------------------------------------------------------------------- #
|
||||
@staticmethod
|
||||
def _graph_context(graph) -> str:
|
||||
from collections import defaultdict
|
||||
by_kind = defaultdict(list)
|
||||
for n in graph.nodes:
|
||||
by_kind[n.kind].append(n.label)
|
||||
lines = []
|
||||
for kind in ("file", "class", "function", "method", "module", "section"):
|
||||
items = by_kind.get(kind, [])
|
||||
if items:
|
||||
lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60]))
|
||||
id2label = {n.id: n.label for n in graph.nodes}
|
||||
rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}"
|
||||
for e in graph.edges[:140]]
|
||||
if rels:
|
||||
lines.append("Relationships (sample):\n" + "\n".join(rels))
|
||||
return "\n".join(lines)[:7000]
|
||||
|
||||
def _matched_sources(self, text: str):
|
||||
graph = self._renderer.graph
|
||||
if graph is None or not text:
|
||||
return []
|
||||
found: dict = {}
|
||||
for n in graph.nodes:
|
||||
if not n.path:
|
||||
continue
|
||||
label = n.label.rstrip("()")
|
||||
if len(label) < 3:
|
||||
continue
|
||||
if n.path not in found and re.search(rf"\b{re.escape(label)}\b", text):
|
||||
found[n.path] = (n.kind, n.label, n.detail or n.path)
|
||||
return sorted(found.items(), key=lambda kv: kv[1][1].lower())[:12]
|
||||
|
||||
def _linkify_files(self, text: str, sources) -> str:
|
||||
"""Turn file/entity NAMES mentioned in the answer into clickable
|
||||
links that open the file."""
|
||||
for path, (kind, label, rel) in sources:
|
||||
href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
|
||||
tokens = []
|
||||
base = Path(path).name
|
||||
if base and len(base) >= 3:
|
||||
tokens.append(base)
|
||||
lab = (label or "").rstrip("()").strip()
|
||||
if lab and lab != base and len(lab) >= 3:
|
||||
tokens.append(lab)
|
||||
for tok in tokens:
|
||||
esc = re.escape(tok)
|
||||
text = re.sub(rf"`{esc}`", f"[`{tok}`]({href})", text)
|
||||
text = re.sub(rf"(?<![\w`/\\.\]\)]){esc}(?![\w`\]\(])", f"[{tok}]({href})", text)
|
||||
return text
|
||||
|
||||
def _render_answer(self) -> None:
|
||||
text = self._answer
|
||||
sources = self._matched_sources(text)
|
||||
if sources:
|
||||
text = self._linkify_files(text, sources)
|
||||
lines = [text, "", "---", f"**{tr('structure.related_sources')}**"]
|
||||
for path, (kind, label, rel) in sources:
|
||||
href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
|
||||
kind_badge = f" [{kind.upper()}]" if kind not in ("file",) else ""
|
||||
lines.append(f"- **[{label}⧉]({href})**{kind_badge} — `{rel}`")
|
||||
text = "\n".join(lines)
|
||||
self.detail.setMarkdown(text)
|
||||
|
||||
def _on_detail_link(self, url: QUrl) -> None:
|
||||
if url.isLocalFile():
|
||||
p = url.toLocalFile()
|
||||
if Path(p).is_file():
|
||||
open_location(p)
|
||||
else:
|
||||
open_folder(p)
|
||||
|
||||
def _ask(self) -> None:
|
||||
question = self.ask_edit.text().strip()
|
||||
if not question:
|
||||
return
|
||||
from cowork_local.core.skills import parse_skill_command
|
||||
skill_prefix, question, info = parse_skill_command(question)
|
||||
if info is not None:
|
||||
self.detail.setMarkdown(info)
|
||||
self._detail_mode = "answer"
|
||||
self.ask_edit.clear()
|
||||
return
|
||||
graph = self._renderer.graph
|
||||
if graph is None:
|
||||
self.status_message.emit(tr("structure.scan_first"))
|
||||
return
|
||||
context = self._graph_context(graph)
|
||||
file_paths = self._candidate_file_paths()
|
||||
extract_cache = dict(self._extract_cache)
|
||||
extract_dir = str(self._extract_tmp_dir())
|
||||
self._answer = ""
|
||||
self._detail_mode = "answer"
|
||||
self.detail.setPlainText("…")
|
||||
self.ask_edit.clear()
|
||||
|
||||
active_project_id = self._renderer.active_project_id
|
||||
selected_nodes = self._renderer.selected_node_data()
|
||||
selected_context = self._selection_context(selected_nodes, graph)
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
provider = self.ctx.build_active_provider()
|
||||
system = self._system_prompt(skill_prefix, active_project_id)
|
||||
user_content = f"Graph context:\n{context}"
|
||||
if selected_context:
|
||||
user_content += f"\n\nSelected node(s) context (focus your answer on these):\n{selected_context}"
|
||||
content_block, new_cache = extract_file_contents(file_paths, extract_cache, extract_dir)
|
||||
if content_block:
|
||||
user_content += ("\n\nExtracted file contents (read these to answer about file "
|
||||
"details/data; cite the file path):\n" + content_block)
|
||||
user_content += f"\n\nQuestion: {question}"
|
||||
messages = [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
from cowork_local.core import agent_roles, audit_log
|
||||
ok = True
|
||||
try:
|
||||
provider.chat(messages, on_text=lambda t: worker.emit_event({"type": "text", "delta": t}),
|
||||
cancel=worker.is_cancelled)
|
||||
except Exception:
|
||||
ok = False
|
||||
raise
|
||||
finally:
|
||||
audit_log.record("tool_call", "graphrag_ask", ok, question[:500],
|
||||
agent_role=agent_roles.KNOWLEDGE)
|
||||
return {"extracted": new_cache}
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.event.connect(self._on_ask_event)
|
||||
w.finished_ok.connect(self._on_ask_done)
|
||||
w.failed.connect(lambda e: self.detail.setPlainText(f"Error: {e}"))
|
||||
self._ask_worker = w
|
||||
w.start()
|
||||
|
||||
@staticmethod
|
||||
def _selection_context(selected_nodes, graph) -> str:
|
||||
if not selected_nodes:
|
||||
return ""
|
||||
node_lines = []
|
||||
for nd in selected_nodes:
|
||||
node_lines.append(f"- {nd.label} (kind: {nd.kind}, path: {getattr(nd, 'path', '')})")
|
||||
if nd.detail:
|
||||
node_lines.append(f" detail: {nd.detail}")
|
||||
connected_ids = set()
|
||||
for nd in selected_nodes:
|
||||
for edge in graph.edges:
|
||||
if edge.source == nd.id:
|
||||
connected_ids.add(edge.target)
|
||||
elif edge.target == nd.id:
|
||||
connected_ids.add(edge.source)
|
||||
connected_nodes = [n for n in graph.nodes if n.id in connected_ids]
|
||||
if connected_nodes:
|
||||
node_lines.append("\nConnected nodes:")
|
||||
for cn in connected_nodes:
|
||||
node_lines.append(f"- {cn.label} (kind: {cn.kind})")
|
||||
return "\n".join(node_lines)
|
||||
|
||||
@staticmethod
|
||||
def _system_prompt(skill_prefix: str, active_project_id: str) -> str:
|
||||
system = ("You answer questions about a code/document knowledge graph. Use the provided "
|
||||
"graph context AND the extracted file contents to retrieve, synthesize and "
|
||||
"explain the answer. Be concise. Answer ONLY from what is provided (graph "
|
||||
"context + extracted contents) — never invent files, functions, or facts that "
|
||||
"aren't in it.\n\n"
|
||||
"EACH answer MUST include source citations so the user can verify where "
|
||||
"information came from. For every factual claim, file reference, or code "
|
||||
"element you mention, add a citation using this format:\n\n"
|
||||
" [source: filename.ext, line/section: XXX]\n\n"
|
||||
"Rules for citations:\n"
|
||||
" 1. Cite the EXACT file path from the graph context (use the path field).\n"
|
||||
" 2. For Python files: cite the function/class name and approximate line "
|
||||
" if available, or the module name.\n"
|
||||
" 3. For document files (.md, .txt): cite the section heading.\n"
|
||||
" 4. For JSON files: cite the key path (e.g. settings > database > host).\n"
|
||||
" 5. Place citations inline after the relevant sentence or fact.\n"
|
||||
" 6. At the end of your answer, add a '---' separator followed by a "
|
||||
" numbered **Sources cited:** section listing each unique source with "
|
||||
" its full path so the user can click to open it.\n\n"
|
||||
"Example citation format in text:\n"
|
||||
" The `process_data()` function handles CSV parsing "
|
||||
"[source: src/utils/parser.py, function: process_data].\n\n"
|
||||
"Example end-of-answer source list:\n"
|
||||
" ---\n"
|
||||
" **Sources cited:**\n"
|
||||
" 1. `src/utils/parser.py` — process_data function\n"
|
||||
" 2. `docs/api.md` — Section: Authentication\n")
|
||||
if skill_prefix:
|
||||
system += "\n\nFollow this skill:\n" + skill_prefix
|
||||
if active_project_id:
|
||||
from cowork_local.core.projects import load_project, project_context_text
|
||||
proj_ctx = project_context_text(load_project(active_project_id))
|
||||
if proj_ctx:
|
||||
system += "\n\n" + proj_ctx
|
||||
return system
|
||||
|
||||
def _on_ask_event(self, ev: dict) -> None:
|
||||
if ev.get("type") == "text":
|
||||
if self._answer == "":
|
||||
self.detail.clear()
|
||||
self._answer += ev.get("delta", "")
|
||||
self.detail.setPlainText(self._answer)
|
||||
|
||||
def _on_ask_done(self, result: dict) -> None:
|
||||
# Keep the (temporary) extracted content so repeated questions reuse
|
||||
# it without re-extracting — dropped when leaving the tab.
|
||||
if isinstance(result, dict):
|
||||
self._extract_cache.update(result.get("extracted", {}) or {})
|
||||
self._render_answer()
|
||||
|
||||
# ---- temporary file-content extraction for Q&A -------------------------------- #
|
||||
def _candidate_file_paths(self) -> List[str]:
|
||||
"""File paths to read for a question: the SELECTED file nodes if
|
||||
any, else every file node in the graph (capped downstream)."""
|
||||
graph = self._renderer.graph
|
||||
if graph is None:
|
||||
return []
|
||||
sel = self._renderer.selected_node_data()
|
||||
nodes = sel or list(graph.nodes)
|
||||
out, seen = [], set()
|
||||
for nd in nodes:
|
||||
p = (getattr(nd, "path", "") or "").strip()
|
||||
if p and p not in seen and Path(p).is_file():
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
def _extract_tmp_dir(self) -> Path:
|
||||
if self._extract_dir is None:
|
||||
import tempfile
|
||||
from cowork_local.config import CONFIG_DIR
|
||||
base = CONFIG_DIR / "tmp" / "graphrag_extract"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
self._extract_dir = Path(tempfile.mkdtemp(dir=str(base)))
|
||||
return self._extract_dir
|
||||
|
||||
def clear_extracts(self) -> None:
|
||||
"""Discard the temporary extracted content (on leaving the tab /
|
||||
switching project). The extraction is a scratch aid, never
|
||||
persisted."""
|
||||
self._extract_cache = {}
|
||||
d, self._extract_dir = self._extract_dir, None
|
||||
if d is not None:
|
||||
import shutil
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
__all__ = ["GraphQaWidget"]
|
||||
@@ -0,0 +1,391 @@
|
||||
"""GraphRenderer — the toolbar, scan/render pipeline, and graph/messages
|
||||
stack of GraphRAG (R08-T14, extracted from
|
||||
``ui/structure_graph_view.py::StructureGraphView``, lines 188-286/336-661/
|
||||
664-702 of the original 1035-line file — everything except the right-side
|
||||
Q&A panel, which is ``graph_qa_widget.py::GraphQaWidget``).
|
||||
|
||||
Talks to the Q&A panel only through signals (:attr:`node_selected`,
|
||||
:attr:`graph_rendered`) and a small read API (:attr:`graph`,
|
||||
:meth:`selected_node_data`, :attr:`active_project_id`) — this class has no
|
||||
idea ``GraphQaWidget`` exists, matching how
|
||||
``presentation/folder/document_preview_manager.py`` doesn't know about the
|
||||
AI-edit panel either.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import QPointF, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QFileDialog, QGraphicsScene, QHBoxLayout, QLineEdit,
|
||||
QPushButton, QStackedWidget, QTabBar, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.graph.graph_messages_view import GraphMessagesView
|
||||
from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class GraphRenderer(QWidget):
|
||||
status_message = Signal(str)
|
||||
node_selected = Signal(object) # a node's .data, whenever the scene selection changes
|
||||
graph_rendered = Signal() # a scan just finished rendering (fresh OR re-fit)
|
||||
raw_json_ready = Signal(str) # pre-formatted HTML for a clicked Messages entry
|
||||
project_changed = Signal() # a DIFFERENT project was selected (or cleared)
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._worker: Optional[AgentWorker] = None
|
||||
self._node_items: List[_Node] = []
|
||||
self._edge_items: List[_Edge] = []
|
||||
self._centroid = QPointF(0, 0)
|
||||
self._graph = None
|
||||
self._needs_scan = False
|
||||
self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite)
|
||||
self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox
|
||||
|
||||
self._rescan_timer = QTimer(self)
|
||||
self._rescan_timer.setSingleShot(True)
|
||||
self._rescan_timer.setInterval(1500)
|
||||
self._rescan_timer.timeout.connect(self._scan)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
bar = QHBoxLayout()
|
||||
self.path_edit = QLineEdit(str(ctx.config.cowork_output_dir()))
|
||||
self.path_edit.setPlaceholderText(tr("structure.path_placeholder"))
|
||||
self._pick_btn = QPushButton()
|
||||
self._pick_btn.setIcon(icon("folder"))
|
||||
self._pick_btn.setObjectName("primary")
|
||||
self._pick_btn.clicked.connect(self._pick)
|
||||
self.project_combo = QComboBox()
|
||||
self.project_combo.currentIndexChanged.connect(self._on_project_changed)
|
||||
self._scan_btn = QPushButton()
|
||||
self._scan_btn.setIcon(icon("search"))
|
||||
self._scan_btn.setObjectName("primary")
|
||||
self._scan_btn.clicked.connect(self._scan)
|
||||
self._export_btn = QPushButton()
|
||||
self._export_btn.setIcon(icon("upload"))
|
||||
self._export_btn.setObjectName("primary")
|
||||
self._export_btn.clicked.connect(self._export)
|
||||
bar.addWidget(self.path_edit, 1)
|
||||
bar.addWidget(self._pick_btn)
|
||||
bar.addWidget(self.project_combo)
|
||||
bar.addWidget(self._scan_btn)
|
||||
bar.addWidget(self._export_btn)
|
||||
root.addLayout(bar)
|
||||
self._refresh_project_combo()
|
||||
|
||||
# Đồ thị | Tin nhắn as a real pair of tabs.
|
||||
self.view_tabs = QTabBar()
|
||||
self.view_tabs.setObjectName("viewTabs")
|
||||
self.view_tabs.setDrawBase(False)
|
||||
self.view_tabs.setExpanding(False)
|
||||
self.view_tabs.addTab(icon("graph"), "")
|
||||
self.view_tabs.addTab(icon("message"), "")
|
||||
self.view_tabs.currentChanged.connect(self._on_view_tab)
|
||||
tab_row = QHBoxLayout()
|
||||
tab_row.setContentsMargins(0, 0, 0, 0)
|
||||
tab_row.addWidget(self.view_tabs)
|
||||
tab_row.addStretch(1)
|
||||
root.addLayout(tab_row)
|
||||
|
||||
self.scene = QGraphicsScene()
|
||||
self.scene.setBackgroundBrush(QColor(current_palette().bg))
|
||||
self.scene.selectionChanged.connect(self._on_selection)
|
||||
self.view = _GraphView(self.scene)
|
||||
|
||||
self._stack = QStackedWidget()
|
||||
self._stack.addWidget(self.view)
|
||||
self.web = None
|
||||
self._bridge = None
|
||||
self._channel = None
|
||||
root.addWidget(self._stack, 1)
|
||||
# "Messages" view: all conversation messages grouped BY DAY, shown as
|
||||
# JSON — a separate concern composed in (see graph_messages_view.py).
|
||||
self._messages = GraphMessagesView(self)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self.path_edit.setPlaceholderText(tr("structure.path_placeholder"))
|
||||
self._pick_btn.setText(tr("structure.browse"))
|
||||
self._scan_btn.setText(tr("structure.scan"))
|
||||
self._export_btn.setText(tr("structure.export_png"))
|
||||
self.view_tabs.setTabText(0, tr("structure.graph_btn"))
|
||||
self.view_tabs.setTabText(1, tr("structure.msgs_btn"))
|
||||
self.view_tabs.setTabToolTip(1, tr("structure.msgs_tooltip"))
|
||||
self.project_combo.setToolTip(tr("structure.project_tooltip"))
|
||||
self._refresh_project_combo()
|
||||
|
||||
# ---- public read API for GraphQaWidget ----------------------------------- #
|
||||
@property
|
||||
def graph(self):
|
||||
return self._graph
|
||||
|
||||
@property
|
||||
def active_project_id(self) -> str:
|
||||
return self._active_project_id
|
||||
|
||||
def selected_node_data(self) -> list:
|
||||
return [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
|
||||
|
||||
# ---- project sandbox lock ------------------------------------------------- #
|
||||
def _refresh_project_combo(self) -> None:
|
||||
from cowork_local.core.projects import list_projects
|
||||
|
||||
keep = self._active_project_id
|
||||
self.project_combo.blockSignals(True)
|
||||
self.project_combo.clear()
|
||||
self.project_combo.addItem(tr("structure.project_none"), "")
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects(), start=1):
|
||||
self.project_combo.addItem(p.name, p.project_id)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_combo.setCurrentIndex(row_to_select)
|
||||
self.project_combo.blockSignals(False)
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
pid = project_id or ""
|
||||
self._refresh_project_combo()
|
||||
target = self.project_combo.findData(pid)
|
||||
if target < 0:
|
||||
target = 0
|
||||
if self.project_combo.currentIndex() == target:
|
||||
self._on_project_changed(target)
|
||||
else:
|
||||
self.project_combo.setCurrentIndex(target)
|
||||
|
||||
def _on_project_changed(self, _idx: int) -> None:
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
self._pick_btn.setEnabled(not locked)
|
||||
if locked:
|
||||
project = load_project(pid)
|
||||
if project is not None:
|
||||
self.path_edit.setText(str(project.workspace_dir()))
|
||||
if project_changed:
|
||||
self.project_changed.emit() # GraphQaWidget drops its temp extraction cache
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
|
||||
# ---- helpers ---------------------------------------------------------------- #
|
||||
def _pick(self) -> None:
|
||||
chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text())
|
||||
if chosen:
|
||||
self.path_edit.setText(chosen)
|
||||
|
||||
def schedule_rescan(self, path: str = "") -> None:
|
||||
if self._graph is None:
|
||||
self._needs_scan = True
|
||||
return
|
||||
self._rescan_timer.start()
|
||||
|
||||
# ---- Messages (by day, as JSON) — see graph_messages_view.py --------------- #
|
||||
def _on_view_tab(self, index: int) -> None:
|
||||
self._messages.on_view_tab(index)
|
||||
|
||||
def _toggle_messages(self) -> None:
|
||||
"""Kept for callers that still ask for a flip (e.g. keyboard paths)."""
|
||||
self._messages.toggle()
|
||||
|
||||
# ---- prewarm / scan lifecycle -------------------------------------------------- #
|
||||
def prewarm(self) -> None:
|
||||
"""Pay for the graph view before it is clicked on, not during."""
|
||||
if not HAS_WEB_ENGINE or self.web is not None:
|
||||
return
|
||||
self._ensure_web()
|
||||
if self._graph is None and self.path_edit.text().strip():
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
|
||||
def _ensure_web(self) -> None:
|
||||
if self.web is not None or not HAS_WEB_ENGINE:
|
||||
return
|
||||
from PySide6.QtWebChannel import QWebChannel
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
self.web = QWebEngineView()
|
||||
self.web.setHtml(
|
||||
f"<body style='margin:0;background:{current_palette().bg}'></body>")
|
||||
self._bridge = _Bridge()
|
||||
self._channel = QWebChannel()
|
||||
self._channel.registerObject("py", self._bridge)
|
||||
self.web.page().setWebChannel(self._channel)
|
||||
self._stack.addWidget(self.web)
|
||||
self._stack.setCurrentWidget(self.web)
|
||||
if self._graph is not None:
|
||||
self._render_d3()
|
||||
|
||||
def auto_scan_and_fit(self) -> None:
|
||||
self._ensure_web()
|
||||
if not self.path_edit.text().strip():
|
||||
return
|
||||
if self._worker is not None and self._worker.isRunning():
|
||||
self._fit()
|
||||
self.graph_rendered.emit()
|
||||
return
|
||||
if self._graph is not None and not self._needs_scan:
|
||||
self._fit()
|
||||
self.graph_rendered.emit()
|
||||
return
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
|
||||
# ---- scan --------------------------------------------------------------------- #
|
||||
def _scan(self) -> None:
|
||||
path = self.path_edit.text().strip() or str(Path.cwd())
|
||||
mode = "files"
|
||||
use_cmem = bool(self.ctx.config.codebase_memory.get("enabled"))
|
||||
cmem_bin = self.ctx.config.codebase_memory.get("binary_path", "")
|
||||
st = self.ctx.config.structure
|
||||
max_nodes = int(st.get("max_nodes", 500) or 0)
|
||||
max_edges = int(st.get("max_edges", 500) or 0)
|
||||
self._scan_seq += 1
|
||||
seq = self._scan_seq
|
||||
self.status_message.emit(tr("structure.scanning"))
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from cowork_local.core.structure_graph import (
|
||||
build_from_codebase_memory, build_from_directory, force_layout,
|
||||
)
|
||||
if use_cmem:
|
||||
from cowork_local.core.codebase_memory import CodebaseMemory
|
||||
mem = CodebaseMemory(cmem_bin)
|
||||
graph = (build_from_codebase_memory(mem, path, mode, max_nodes, max_edges)
|
||||
if mem.available else build_from_directory(path, mode, max_nodes, max_edges))
|
||||
else:
|
||||
graph = build_from_directory(path, mode, max_nodes, max_edges)
|
||||
pos = force_layout(graph)
|
||||
return {"graph": graph, "pos": pos, "seq": seq}
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(self._render)
|
||||
w.failed.connect(lambda e: self.status_message.emit(tr("structure.scan_error", err=e)))
|
||||
self._worker = w
|
||||
w.start()
|
||||
|
||||
def _render(self, result: dict) -> None:
|
||||
if result.get("seq") is not None and result["seq"] != self._scan_seq:
|
||||
return
|
||||
graph = result.get("graph")
|
||||
pos = result.get("pos", {})
|
||||
if graph is None:
|
||||
return
|
||||
self._graph = graph
|
||||
|
||||
self.scene.clear()
|
||||
self.scene.setBackgroundBrush(QColor(current_palette().bg))
|
||||
self._node_items = []
|
||||
self._edge_items = []
|
||||
degree = {n.id: 0 for n in graph.nodes}
|
||||
for e in graph.edges:
|
||||
if e.source in degree:
|
||||
degree[e.source] += 1
|
||||
if e.target in degree:
|
||||
degree[e.target] += 1
|
||||
items = {}
|
||||
sx = sy = 0.0
|
||||
for node in graph.nodes:
|
||||
radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0))))
|
||||
item = _Node(node, radius)
|
||||
x, y = pos.get(node.id, (0, 0))
|
||||
item.setPos(x, y)
|
||||
self.scene.addItem(item)
|
||||
items[node.id] = item
|
||||
self._node_items.append(item)
|
||||
sx += x
|
||||
sy += y
|
||||
for edge in graph.edges:
|
||||
a, b = items.get(edge.source), items.get(edge.target)
|
||||
if a and b:
|
||||
e = _Edge(a, b, getattr(edge, "type", ""))
|
||||
self.scene.addItem(e)
|
||||
self._edge_items.append(e)
|
||||
n = max(1, len(self._node_items))
|
||||
self._centroid = QPointF(sx / n, sy / n)
|
||||
self._fit()
|
||||
|
||||
if self.web is not None:
|
||||
self._render_d3()
|
||||
|
||||
note = tr("structure.truncated_note") if getattr(graph, "truncated", False) else ""
|
||||
self.status_message.emit(tr(
|
||||
"structure.graph_summary", nodes=len(graph.nodes), edges=len(graph.edges), note=note))
|
||||
self.graph_rendered.emit()
|
||||
|
||||
def _render_d3(self) -> None:
|
||||
if self.web is None or self._graph is None:
|
||||
return
|
||||
from cowork_local.core.d3_graph import build_html
|
||||
try:
|
||||
self.web.setHtml(build_html(self._graph), QUrl("https://cowork.local/"))
|
||||
except Exception as exc:
|
||||
self.status_message.emit(f"D3 view error: {exc}")
|
||||
|
||||
# ---- native interactions ------------------------------------------------------- #
|
||||
def _on_selection(self) -> None:
|
||||
for item in self.scene.selectedItems():
|
||||
if isinstance(item, _Node):
|
||||
self.node_selected.emit(item.data)
|
||||
return
|
||||
|
||||
def _fit(self) -> None:
|
||||
if self.web is not None and self._stack.currentWidget() is self.web:
|
||||
self.web.page().runJavaScript("window.fitGraph && window.fitGraph();")
|
||||
return
|
||||
rect = self.scene.itemsBoundingRect()
|
||||
if not rect.isNull():
|
||||
self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
|
||||
|
||||
def _export(self) -> None:
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)")
|
||||
if not path:
|
||||
return
|
||||
showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web)
|
||||
if showing_d3:
|
||||
self._export_d3_png(path)
|
||||
else:
|
||||
self._export_widget_grab(path)
|
||||
|
||||
def _export_d3_png(self, path: str) -> None:
|
||||
def on_result(data_url) -> None:
|
||||
if not isinstance(data_url, str) or "," not in data_url:
|
||||
self._export_widget_grab(path)
|
||||
return
|
||||
import base64
|
||||
try:
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(data_url.split(",", 1)[1]))
|
||||
self.status_message.emit(tr("structure.export_done", path=path))
|
||||
except (OSError, ValueError) as exc:
|
||||
self.status_message.emit(tr("structure.export_failed", err=str(exc)))
|
||||
self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result)
|
||||
|
||||
def _export_widget_grab(self, path: str) -> None:
|
||||
ok = self._stack.currentWidget().grab().save(path, "PNG")
|
||||
if ok:
|
||||
self.status_message.emit(tr("structure.export_done", path=path))
|
||||
else:
|
||||
self.status_message.emit(tr("structure.export_failed", err="grab() returned no image"))
|
||||
|
||||
|
||||
__all__ = ["GraphRenderer"]
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Native QGraphicsScene primitives for the fallback (non-WebEngine) graph
|
||||
view (R08-T14, split out of ``graph_renderer.py`` to keep it under the
|
||||
400-line cap; originally ``ui/structure_graph_view.py``, lines 65-186 of the
|
||||
original 1035-line file: ``_Bridge``, ``_Edge``, ``_Node``, ``_GraphView``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from PySide6.QtCore import QObject, QPointF, Qt, Slot
|
||||
from PySide6.QtGui import QBrush, QColor, QFont, QPen
|
||||
from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsSimpleTextItem, QGraphicsView
|
||||
|
||||
from cowork_local.core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.osutil import open_folder, open_location
|
||||
|
||||
|
||||
class _Bridge(QObject):
|
||||
"""Exposed to the D3 page so a Shift+click on a node can open its
|
||||
storage folder/link (local path or URL — see osutil.open_location)."""
|
||||
|
||||
@Slot(str)
|
||||
def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name
|
||||
if path:
|
||||
open_location(path)
|
||||
|
||||
|
||||
class _Edge(QGraphicsLineItem):
|
||||
def __init__(self, a: "_Node", b: "_Node", type_: str = ""):
|
||||
super().__init__()
|
||||
self.a, self.b = a, b
|
||||
self.type = type_
|
||||
# Colour the edge by its RELATIONSHIP type (contains/defines/method/…),
|
||||
# so the graph shows what each connection MEANS — falling back to the
|
||||
# source node's tint for any untyped edge.
|
||||
color = QColor(EDGE_KIND_COLORS.get(type_, "")) if type_ else QColor()
|
||||
if not color.isValid():
|
||||
color = a.brush().color().lighter(130)
|
||||
self._color = color
|
||||
self.setPen(QPen(color, 1.4))
|
||||
self.setZValue(-1)
|
||||
# A small label naming the relationship, shown at the edge midpoint.
|
||||
self._label = None
|
||||
if type_:
|
||||
self._label = QGraphicsSimpleTextItem(type_, self)
|
||||
self._label.setBrush(QBrush(color.lighter(140)))
|
||||
f = QFont()
|
||||
f.setPointSize(7)
|
||||
self._label.setFont(f)
|
||||
self._label.setZValue(0)
|
||||
a.edges.append(self)
|
||||
b.edges.append(self)
|
||||
self.adjust()
|
||||
|
||||
def adjust(self) -> None:
|
||||
pa, pb = self.a.scenePos(), self.b.scenePos()
|
||||
self.setLine(pa.x(), pa.y(), pb.x(), pb.y())
|
||||
if self._label is not None:
|
||||
br = self._label.boundingRect()
|
||||
self._label.setPos((pa.x() + pb.x()) / 2 - br.width() / 2,
|
||||
(pa.y() + pb.y()) / 2 - br.height() / 2)
|
||||
|
||||
|
||||
class _Node(QGraphicsEllipseItem):
|
||||
def __init__(self, data, radius: int):
|
||||
super().__init__(-radius, -radius, 2 * radius, 2 * radius)
|
||||
self.data = data
|
||||
self.edges = []
|
||||
tok = current_palette()
|
||||
# NODE_KIND_COLORS is a categorical data encoding (one hue per node
|
||||
# kind), not UI chrome — it stays fixed across themes on purpose so a
|
||||
# given kind is always the same colour. Only the chrome follows tokens.
|
||||
color = QColor(NODE_KIND_COLORS.get(data.kind, tok.text_muted))
|
||||
self.setBrush(QBrush(color))
|
||||
self.setPen(QPen(color.darker(160), 1.5))
|
||||
self.setFlags(
|
||||
QGraphicsEllipseItem.ItemIsMovable
|
||||
| QGraphicsEllipseItem.ItemIsSelectable
|
||||
| QGraphicsEllipseItem.ItemSendsGeometryChanges
|
||||
)
|
||||
self.setZValue(1)
|
||||
label = QGraphicsSimpleTextItem(data.label, self)
|
||||
label.setBrush(QBrush(QColor(tok.text)))
|
||||
label.setPos(radius + 3, -8)
|
||||
|
||||
def itemChange(self, change, value): # noqa: N802
|
||||
if change == QGraphicsEllipseItem.ItemPositionHasChanged:
|
||||
for edge in self.edges:
|
||||
edge.adjust()
|
||||
return super().itemChange(change, value)
|
||||
|
||||
|
||||
class _GraphView(QGraphicsView):
|
||||
def __init__(self, scene):
|
||||
super().__init__(scene)
|
||||
self.setDragMode(QGraphicsView.NoDrag)
|
||||
self._panning = False
|
||||
self._pan_start = QPointF()
|
||||
|
||||
def wheelEvent(self, e): # noqa: N802
|
||||
self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15,
|
||||
1.15 if e.angleDelta().y() > 0 else 1 / 1.15)
|
||||
|
||||
def mousePressEvent(self, e): # noqa: N802
|
||||
if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None:
|
||||
self._panning = True
|
||||
self._pan_start = e.position()
|
||||
self.setCursor(Qt.ClosedHandCursor)
|
||||
e.accept()
|
||||
return
|
||||
super().mousePressEvent(e)
|
||||
|
||||
def mouseMoveEvent(self, e): # noqa: N802
|
||||
if self._panning:
|
||||
delta = e.position() - self._pan_start
|
||||
self._pan_start = e.position()
|
||||
self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().value() - delta.x()))
|
||||
self.verticalScrollBar().setValue(int(self.verticalScrollBar().value() - delta.y()))
|
||||
e.accept()
|
||||
return
|
||||
super().mouseMoveEvent(e)
|
||||
|
||||
def mouseReleaseEvent(self, e): # noqa: N802
|
||||
if self._panning:
|
||||
self._panning = False
|
||||
self.setCursor(Qt.ArrowCursor)
|
||||
e.accept()
|
||||
return
|
||||
super().mouseReleaseEvent(e)
|
||||
|
||||
def mouseDoubleClickEvent(self, e): # noqa: N802
|
||||
"""Double-click or Ctrl+click on a node opens its storage folder."""
|
||||
item = self.itemAt(e.pos())
|
||||
if isinstance(item, _Node) and getattr(item.data, "path", ""):
|
||||
open_folder(item.data.path)
|
||||
e.accept()
|
||||
return
|
||||
super().mouseDoubleClickEvent(e)
|
||||
|
||||
|
||||
__all__ = ["_Bridge", "_Edge", "_Node", "_GraphView"]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""StructureGraphView shell (R08-T14) — assembles
|
||||
``graph_renderer.py::GraphRenderer`` and
|
||||
``graph_qa_widget.py::GraphQaWidget`` behind the splitter that used to be
|
||||
inline in ``ui/structure_graph_view.py::StructureGraphView.__init__`` (lines
|
||||
188-343 of the original 1035-line file), and forwards the public methods
|
||||
``app.py``/``ui/workspace_tab.py`` call: ``schedule_rescan``,
|
||||
``auto_scan_and_fit``, ``set_project``, ``prewarm``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QSplitter, QVBoxLayout, QWidget
|
||||
|
||||
from cowork_local.i18n import on_language_changed
|
||||
from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.widgets import CollapseStrip
|
||||
|
||||
_COLLAPSED_SIZES_HINT = (840, 320) # matches the original single-class default
|
||||
|
||||
|
||||
class StructureGraphView(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
self.renderer = GraphRenderer(ctx)
|
||||
self.renderer.status_message.connect(self.status_message.emit)
|
||||
self.qa = GraphQaWidget(ctx, self.renderer)
|
||||
self.qa.status_message.connect(self.status_message.emit)
|
||||
self.qa.collapse_changed.connect(self._on_qa_collapse_changed)
|
||||
|
||||
self._split = QSplitter(Qt.Horizontal)
|
||||
self._split.addWidget(self.renderer)
|
||||
self._split.addWidget(self.qa)
|
||||
self._split.setChildrenCollapsible(False)
|
||||
self._split.setSizes(list(_COLLAPSED_SIZES_HINT))
|
||||
root.addWidget(self._split, 1)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self.renderer._retranslate()
|
||||
self.qa.retranslate()
|
||||
|
||||
def _on_qa_collapse_changed(self, collapsed: bool) -> None:
|
||||
strip_w = CollapseStrip.WIDTH + 2
|
||||
if collapsed:
|
||||
self.qa.setMaximumWidth(strip_w)
|
||||
sizes = self._split.sizes()
|
||||
if len(sizes) == 2:
|
||||
self._split.setSizes([max(1, sum(sizes) - strip_w), strip_w])
|
||||
else:
|
||||
self.qa.setMaximumWidth(16777215)
|
||||
self._split.setSizes(list(_COLLAPSED_SIZES_HINT))
|
||||
|
||||
# ---- public API (app.py / ui/workspace_tab.py) --------------------------- #
|
||||
def schedule_rescan(self, path: str = "") -> None:
|
||||
self.renderer.schedule_rescan(path)
|
||||
|
||||
def auto_scan_and_fit(self) -> None:
|
||||
self.renderer.auto_scan_and_fit()
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
self.renderer.set_project(project_id)
|
||||
|
||||
def prewarm(self) -> None:
|
||||
self.renderer.prewarm()
|
||||
|
||||
def hideEvent(self, e): # noqa: N802
|
||||
# Leaving the GraphRAG tab → drop the temporary extracted info.
|
||||
self.qa.clear_extracts()
|
||||
super().hideEvent(e)
|
||||
|
||||
|
||||
__all__ = ["StructureGraphView"]
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Schedule Task screen, split into single-responsibility widgets (R08-T11):
|
||||
``kanban_board_widget``, ``calendar_view_widget``, ``ai_task_creator_dialog``,
|
||||
``ai_task_import_dialog``, assembled by the ``schedule_task_tab`` shell."""
|
||||
@@ -0,0 +1,196 @@
|
||||
"""AiTaskCreatorDialog — "AI Create Task" (R08-T11, extracted from
|
||||
``ui/schedule_task_tab.py``'s ``_AiCreateDialog``, lines 579-641/722-794 of
|
||||
the original 795-line file).
|
||||
|
||||
Still one dialog with two tabs (AI-gen, then Import — the latter is
|
||||
:class:`~presentation.scheduling.ai_task_import_dialog.ImportTaskPanel`,
|
||||
embedded here rather than duplicated): the physical file split matches
|
||||
``docs/refactor/Feature_Architecture_Proposal.md``'s R08-T11 breakdown, the
|
||||
user-visible dialog is unchanged. ``_confirm`` still uses "whichever tab
|
||||
produced a task list most recently" (mirroring the original class's shared
|
||||
``self._planned`` attribute) — the AI-gen tab sets it on completion, the
|
||||
Import tab reports it through :attr:`ImportTaskPanel.tasks_changed`.
|
||||
|
||||
AI generation goes through
|
||||
``application/scheduling/ai_task_planner_service.py::AiTaskPlannerService``
|
||||
(R07-T05) instead of ``core.ai_task_planner.plan_tasks`` directly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPlainTextEdit, QPushButton, QTabWidget, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.application.scheduling.ai_task_planner_service import (
|
||||
AiTaskPlannerService,
|
||||
)
|
||||
from cowork_local.core.projects import list_projects
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.presentation.scheduling.ai_task_import_dialog import ImportTaskPanel
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class AiTaskCreatorDialog(QDialog):
|
||||
"""Create tasks two ways, one tab each (both preview first — nothing is
|
||||
saved until the user confirms): ✨ AI gen from a natural-language
|
||||
description, or 📥 Import from a filled Excel/CSV/JSON file."""
|
||||
|
||||
def __init__(self, ctx: AppContext, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._planner = AiTaskPlannerService(provider_factory=ctx.build_active_provider)
|
||||
self.created_tasks: List[dict] = []
|
||||
self._ai_planned: List[dict] = []
|
||||
# Which tab produced the task list currently backing the Ok button —
|
||||
# mirrors the original single-class dialog's shared `self._planned`
|
||||
# attribute, where whichever of _on_planned()/_load_import_file()
|
||||
# ran LAST (regardless of which tab is currently showing) won.
|
||||
self._active_source = "ai"
|
||||
self._worker: Optional[AgentWorker] = None
|
||||
self.setWindowTitle(tr("schedtask.ai_btn"))
|
||||
self.resize(600, 520)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
ws_row = QHBoxLayout()
|
||||
ws_row.addWidget(QLabel(tr("schedtask.f_workspace")))
|
||||
self.workspace_combo = QComboBox()
|
||||
self.workspace_combo.addItem(tr("schedtask.no_workspace"), "")
|
||||
for p in list_projects():
|
||||
self.workspace_combo.addItem(p.name, p.project_id)
|
||||
self.workspace_combo.setToolTip(tr("schedtask.hint_workspace"))
|
||||
ws_row.addWidget(self.workspace_combo, 1)
|
||||
root.addLayout(ws_row)
|
||||
self.tabs = QTabWidget()
|
||||
root.addWidget(self.tabs, 1)
|
||||
|
||||
self.tabs.addTab(self._build_ai_gen_page(), tr("schedtask.tab_ai"))
|
||||
self.import_panel = ImportTaskPanel(self._planner)
|
||||
self.import_panel.tasks_changed.connect(self._on_import_tasks_changed)
|
||||
self.tabs.addTab(self.import_panel, tr("schedtask.tab_import"))
|
||||
|
||||
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm"))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||
self.buttons.accepted.connect(self._confirm)
|
||||
self.buttons.rejected.connect(self.reject)
|
||||
root.addWidget(self.buttons)
|
||||
|
||||
# ---- AI-gen tab -------------------------------------------------------
|
||||
def _build_ai_gen_page(self) -> QWidget:
|
||||
ai_page = QWidget()
|
||||
al = QVBoxLayout(ai_page)
|
||||
al.addWidget(QLabel(tr("schedtask.ai_desc_label")))
|
||||
self.desc_edit = QPlainTextEdit()
|
||||
self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph"))
|
||||
self.desc_edit.setMaximumHeight(110)
|
||||
al.addWidget(self.desc_edit)
|
||||
# Attachments (files + links) — merged into every task this generates,
|
||||
# AND into the planning prompt so the AI knows they exist.
|
||||
attach_row = QHBoxLayout()
|
||||
self.ai_files_edit = QLineEdit()
|
||||
self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder"))
|
||||
ai_pick_btn = QPushButton(tr("schedtask.pick_files"))
|
||||
ai_pick_btn.setIcon(icon("folder"))
|
||||
ai_pick_btn.clicked.connect(self._ai_pick_files)
|
||||
attach_row.addWidget(self.ai_files_edit, 1)
|
||||
attach_row.addWidget(ai_pick_btn)
|
||||
al.addWidget(QLabel(tr("schedtask.f_files")))
|
||||
al.addLayout(attach_row)
|
||||
self.ai_links_edit = QLineEdit()
|
||||
self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder"))
|
||||
al.addWidget(QLabel(tr("schedtask.f_links")))
|
||||
al.addWidget(self.ai_links_edit)
|
||||
self.gen_btn = QPushButton(tr("schedtask.ai_generate"))
|
||||
self.gen_btn.setIcon(icon("sparkle"))
|
||||
self.gen_btn.setObjectName("primary")
|
||||
self.gen_btn.clicked.connect(self._generate)
|
||||
al.addWidget(self.gen_btn)
|
||||
al.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
||||
self.preview = QPlainTextEdit()
|
||||
self.preview.setReadOnly(True)
|
||||
al.addWidget(self.preview, 1)
|
||||
return ai_page
|
||||
|
||||
def _ai_pick_files(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files"))
|
||||
if files:
|
||||
existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()]
|
||||
self.ai_files_edit.setText("; ".join(existing + files))
|
||||
|
||||
def _attached_files(self) -> List[str]:
|
||||
return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()]
|
||||
|
||||
def _attached_links(self) -> List[str]:
|
||||
return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()]
|
||||
|
||||
def _generate(self) -> None:
|
||||
description = self.desc_edit.toPlainText().strip()
|
||||
if not description or self._worker is not None:
|
||||
return
|
||||
files, links = self._attached_files(), self._attached_links()
|
||||
self.gen_btn.setEnabled(False)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generating"))
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
full_desc = description
|
||||
if files or links:
|
||||
attach_note = "; ".join(files + links)
|
||||
full_desc += f"\n\n(Attached references available: {attach_note})"
|
||||
planned = self._planner.plan(
|
||||
full_desc, file_paths=files, links=links, cancel=worker.is_cancelled)
|
||||
return {"tasks": planned}
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(self._on_planned)
|
||||
w.failed.connect(self._on_failed)
|
||||
self._worker = w
|
||||
w.start()
|
||||
|
||||
def _on_planned(self, result: dict) -> None:
|
||||
self._worker = None
|
||||
self.gen_btn.setEnabled(True)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
||||
self._ai_planned = result.get("tasks") or []
|
||||
self._active_source = "ai"
|
||||
lines = []
|
||||
for i, t in enumerate(self._ai_planned, 1):
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
||||
dep = t.get("dependency", {})
|
||||
chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else ""
|
||||
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
||||
f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n"
|
||||
f" {t.get('description', '')[:150]}")
|
||||
self.preview.setPlainText("\n\n".join(lines))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._ai_planned))
|
||||
|
||||
def _on_failed(self, err: str) -> None:
|
||||
self._worker = None
|
||||
self.gen_btn.setEnabled(True)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
||||
self.preview.setPlainText(str(err))
|
||||
|
||||
# ---- Import tab ---------------------------------------------------------
|
||||
def _on_import_tasks_changed(self, has_tasks: bool) -> None:
|
||||
if has_tasks:
|
||||
self._active_source = "import"
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(has_tasks)
|
||||
|
||||
# ---- confirm ------------------------------------------------------------
|
||||
def _confirm(self) -> None:
|
||||
planned = self.import_panel.planned if self._active_source == "import" else self._ai_planned
|
||||
project_id = self.workspace_combo.currentData() or ""
|
||||
for t in planned:
|
||||
t["project_id"] = project_id
|
||||
self.created_tasks = planned
|
||||
self.accept()
|
||||
|
||||
|
||||
__all__ = ["AiTaskCreatorDialog"]
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Import-from-file tab content for AI Create Task (R08-T11, extracted from
|
||||
``ui/schedule_task_tab.py``'s ``_AiCreateDialog`` — the Import tab + its
|
||||
``_DropZone``, lines 551-576/643-665/674-720 of the original file).
|
||||
|
||||
:class:`ImportTaskPanel` is a plain ``QWidget`` (not its own dialog) so
|
||||
``ai_task_creator_dialog.py`` can embed it as one tab of the single AI-create
|
||||
dialog the user sees — the two files are a code split, not a UX split; there
|
||||
is still one dialog with two tabs, exactly as before.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from PySide6.QtCore import Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QLabel, QMessageBox, QPlainTextEdit, QPushButton,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.application.scheduling.ai_task_planner_service import (
|
||||
AiTaskPlannerService,
|
||||
)
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
from cowork_local.ui.osutil import open_path
|
||||
|
||||
|
||||
class _DropZone(QLabel):
|
||||
"""Drag-an-.xlsx-here area for the Import tab."""
|
||||
|
||||
file_dropped = Signal(str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
self.setAlignment(Qt.AlignCenter)
|
||||
self.setMinimumHeight(70)
|
||||
_p = current_palette()
|
||||
self.setStyleSheet(
|
||||
f"QLabel {{ border: 1px dashed {_p.border_strong};"
|
||||
f" border-radius: {_p.radius_lg}px;"
|
||||
f" color: {_p.text_muted}; padding: 10px; }}")
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
def dragEnterEvent(self, event): # noqa: N802
|
||||
urls = event.mimeData().urls()
|
||||
if urls and urls[0].toLocalFile().lower().endswith(
|
||||
(".xlsx", ".xlsm", ".xls", ".csv", ".json")):
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event): # noqa: N802
|
||||
urls = event.mimeData().urls()
|
||||
if urls:
|
||||
self.file_dropped.emit(urls[0].toLocalFile())
|
||||
|
||||
|
||||
class ImportTaskPanel(QWidget):
|
||||
"""Pick/drag an Excel/CSV/JSON file, preview the tasks it maps to, and
|
||||
hold that NOT-yet-saved list — the dialog reads :attr:`planned` when the
|
||||
user confirms.
|
||||
|
||||
Args:
|
||||
planner: an ``AiTaskPlannerService`` — ``import_file`` is called
|
||||
through it (R07-T05) rather than ``core.task_import`` directly.
|
||||
"""
|
||||
|
||||
tasks_changed = Signal(bool) # True when the current preview has >=1 valid task
|
||||
|
||||
def __init__(self, planner: AiTaskPlannerService, parent=None):
|
||||
super().__init__(parent)
|
||||
self._planner = planner
|
||||
self.planned: List[dict] = []
|
||||
|
||||
il = QVBoxLayout(self)
|
||||
tpl_btn = QPushButton(tr("schedtask.export_template_btn"))
|
||||
tpl_btn.setIcon(icon("upload"))
|
||||
tpl_btn.clicked.connect(self._export_template)
|
||||
il.addWidget(tpl_btn)
|
||||
pick_row = QHBoxLayout()
|
||||
pick_btn = QPushButton(tr("schedtask.import_pick_btn"))
|
||||
pick_btn.setIcon(icon("folder"))
|
||||
pick_btn.clicked.connect(self._pick_import_file)
|
||||
pick_row.addWidget(pick_btn)
|
||||
pick_row.addStretch(1)
|
||||
il.addLayout(pick_row)
|
||||
self.drop_zone = _DropZone()
|
||||
self.drop_zone.setText(tr("schedtask.drop_hint"))
|
||||
self.drop_zone.file_dropped.connect(self._load_import_file)
|
||||
il.addWidget(self.drop_zone)
|
||||
il.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
||||
self.preview = QPlainTextEdit()
|
||||
self.preview.setReadOnly(True)
|
||||
il.addWidget(self.preview, 1)
|
||||
|
||||
def _export_template(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from cowork_local.core.task_excel import export_template
|
||||
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, tr("schedtask.export_template_btn"),
|
||||
"cowork_tasks_template.xlsx", "Excel (*.xlsx)")
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
export_template(path)
|
||||
open_path(str(Path(path).parent))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc))
|
||||
|
||||
def _pick_import_file(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from cowork_local.core.task_import import IMPORT_FILTER
|
||||
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, tr("schedtask.import_pick_btn"), "", IMPORT_FILTER)
|
||||
if path:
|
||||
self._load_import_file(path)
|
||||
|
||||
def _load_import_file(self, path: str) -> None:
|
||||
try:
|
||||
self.planned = self._planner.import_file(path)
|
||||
except ValueError as exc:
|
||||
# Same as the original single-class dialog: a bad file leaves
|
||||
# whatever was previously loaded in `planned` untouched (only the
|
||||
# preview text and the Ok button reflect the failure) rather than
|
||||
# discarding a prior successful load.
|
||||
self.preview.setPlainText(str(exc))
|
||||
self.tasks_changed.emit(False)
|
||||
return
|
||||
by_id = {t["task_id"]: t["title"] for t in self.planned}
|
||||
lines = []
|
||||
for i, t in enumerate(self.planned, 1):
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
||||
deps = t.get("dependency", {}).get("depends_on") or []
|
||||
dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else ""
|
||||
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
||||
f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}")
|
||||
self.preview.setPlainText("\n\n".join(lines))
|
||||
self.tasks_changed.emit(bool(self.planned))
|
||||
|
||||
|
||||
__all__ = ["ImportTaskPanel"]
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Calendar view for Schedule Task — an alternative to the Kanban board:
|
||||
"""Calendar view for Schedule Task — an alternative to the Kanban board
|
||||
(R08-T11, relocated from ``ui/calendar_view.py`` with no logic changes):
|
||||
Week / Month / Year granularity, each task placed on its scheduled date
|
||||
(``schedule.run_at``). Click a task to edit it (same editor the Kanban
|
||||
board's double-click opens); click a day's "+" to create a task pre-filled
|
||||
@@ -16,12 +17,12 @@ from PySide6.QtWidgets import (
|
||||
QListWidgetItem, QPushButton, QScrollArea, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..core.calendar_grid import (
|
||||
from cowork_local.core.calendar_grid import (
|
||||
GRANULARITIES, group_tasks_by_date, month_grid, month_task_counts, shift_period, week_days,
|
||||
)
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..theme import current_palette
|
||||
from .icons import icon
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
_WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
||||
|
||||
@@ -229,3 +230,6 @@ class CalendarView(QWidget):
|
||||
self.period_lbl.setText(str(self.anchor.year))
|
||||
else:
|
||||
self.period_lbl.setText(self.anchor.strftime("%Y-%m"))
|
||||
|
||||
|
||||
__all__ = ["CalendarView"]
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Kanban board for Schedule Task (R08-T11, extracted from
|
||||
``ui/schedule_task_tab.py``'s ``ScheduleTaskTab`` — Kanban rendering +
|
||||
drag-drop + row actions, lines 41-79/222-300/302-484/496-548 of the original
|
||||
795-line file).
|
||||
|
||||
Owns the 7-lane board itself. What used to be plain module-function calls
|
||||
into ``core/tasks.py`` (``duplicate_task``, ``taskrepo.save_task``,
|
||||
``taskrepo.delete_task``, ...) and ``self.scheduler.run_now(...)`` inline are
|
||||
now calls into
|
||||
``application/scheduling/task_application_service.py::TaskApplicationService``
|
||||
(R07-T04) — the drag-drop business rules (dropping on Running/Done/Scheduled)
|
||||
in particular used to be ~30 lines of if/elif inside a Qt slot; now it's
|
||||
``TaskApplicationService.move_to_status`` plus a few branches on its result.
|
||||
|
||||
Task EDITING (opening ``TaskEditorDialog``) is deliberately NOT owned here —
|
||||
``CalendarView`` needs the exact same "open the editor for this task id"
|
||||
behaviour for its own click handler, so it stays a shell-level concern
|
||||
(``schedule_task_tab.py``) both widgets request via a signal, instead of
|
||||
being duplicated in two places.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import QEvent, Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||
QMenu, QMessageBox, QScrollArea, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.application.scheduling.task_application_service import (
|
||||
TaskApplicationService,
|
||||
)
|
||||
from cowork_local.core.tasks import STATUSES, chain_error, new_task
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.infrastructure.persistence.json.task_repository_impl import (
|
||||
TaskRepository,
|
||||
)
|
||||
from cowork_local.presentation.scheduling.run_history_dialog import RunHistoryDialog
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.osutil import open_path
|
||||
|
||||
# Priority shown as a plain text tag (no colored-emoji squares). Only the
|
||||
# elevated priorities get a visible marker; low/medium stay unmarked as before.
|
||||
_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"}
|
||||
|
||||
|
||||
class _KanbanColumn(QListWidget):
|
||||
"""One status lane. Accepts drops from sibling columns; a drop means
|
||||
'move this task to my status'."""
|
||||
|
||||
task_dropped = Signal(str, str) # task_id, new_status
|
||||
|
||||
def __init__(self, status: str):
|
||||
super().__init__()
|
||||
self.status = status
|
||||
self.setDragDropMode(QAbstractItemView.DragDrop)
|
||||
self.setDefaultDropAction(Qt.MoveAction)
|
||||
# Shift/Ctrl-click several cards in the SAME column, then right-click
|
||||
# → "Delete N selected" to bulk-remove tasks instead of one at a time.
|
||||
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||
self.setWordWrap(True)
|
||||
# Cards wrap, so there is never anything to reach by scrolling
|
||||
# sideways — but QListWidget's own column hint runs 1-6px past the
|
||||
# viewport; the board divides whatever width it has by seven instead
|
||||
# (see KanbanBoardWidget._fit_lanes()).
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setResizeMode(QListWidget.Adjust) # re-wrap on every resize
|
||||
|
||||
def dropEvent(self, event): # noqa: N802
|
||||
source = event.source()
|
||||
if isinstance(source, _KanbanColumn) and source is not self:
|
||||
item = source.currentItem()
|
||||
tid = item.data(Qt.UserRole) if item else None
|
||||
if tid:
|
||||
event.acceptProposedAction()
|
||||
self.task_dropped.emit(tid, self.status)
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
|
||||
class KanbanBoardWidget(QWidget):
|
||||
"""The 7-lane board: Backlog / Scheduled / Running / Waiting Input /
|
||||
Done / Failed / Paused. Cards drag between columns (dropping = changing
|
||||
status via ``TaskApplicationService.move_to_status``), double-click and
|
||||
the right-click menu request an edit via :attr:`edit_requested`.
|
||||
|
||||
Args:
|
||||
ctx: ``AppContext`` — passed through to ``TaskEditorDialog`` callers
|
||||
need it for, kept here only so callers don't have to fetch it
|
||||
separately.
|
||||
tasks_dir: ``None`` -> the app's default task-storage directory;
|
||||
tests pass a ``tmp_path``.
|
||||
scheduler: ``TaskScheduler`` (may be ``None`` — matches the original
|
||||
widget's "no scheduler in tests" tolerance) used as the
|
||||
``run_now`` dispatch source for the service.
|
||||
service: inject a ready-made ``TaskApplicationService`` (tests); when
|
||||
``None``, one is built from ``tasks_dir``/``scheduler``.
|
||||
"""
|
||||
|
||||
status_message = Signal(str)
|
||||
counts_changed = Signal(dict) # status -> count, for the shell's summary label
|
||||
edit_requested = Signal(str) # task_id — shell opens TaskEditorDialog
|
||||
|
||||
_LANE_FLOOR_CH = 8 # roughly eight characters of a task title, plus padding
|
||||
|
||||
def __init__(self, ctx, tasks_dir=None, scheduler=None,
|
||||
service: Optional[TaskApplicationService] = None, parent=None):
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._tasks_dir = tasks_dir
|
||||
self._repo = TaskRepository(tasks_dir)
|
||||
self._service = service or TaskApplicationService(
|
||||
self._repo, run_now=scheduler.run_now if scheduler is not None else None)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
board = QWidget()
|
||||
scroll.setWidget(board)
|
||||
cols = QHBoxLayout(board)
|
||||
cols.setSpacing(2)
|
||||
self.columns: Dict[str, _KanbanColumn] = {}
|
||||
self.column_headers: Dict[str, QLabel] = {}
|
||||
for status in STATUSES:
|
||||
box = QVBoxLayout()
|
||||
box.setContentsMargins(0, 0, 0, 0)
|
||||
box.setSpacing(2)
|
||||
head = QLabel()
|
||||
head.setStyleSheet("font-weight:600;")
|
||||
col = _KanbanColumn(status)
|
||||
col.setObjectName("kanbanLane")
|
||||
col.task_dropped.connect(self._on_task_dropped)
|
||||
col.itemDoubleClicked.connect(self._on_double_click)
|
||||
col.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
col.customContextMenuRequested.connect(
|
||||
lambda pos, c=col: self._context_menu(c, pos))
|
||||
box.addWidget(head)
|
||||
box.addWidget(col, 1)
|
||||
holder = QWidget()
|
||||
holder.setLayout(box)
|
||||
cols.addWidget(holder)
|
||||
self.columns[status] = col
|
||||
self.column_headers[status] = head
|
||||
root.addWidget(scroll, 1)
|
||||
self._board_scroll = scroll
|
||||
scroll.viewport().installEventFilter(self)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
for status, col in self.columns.items():
|
||||
col.setToolTip(tr(f"schedtask.col_tip.{status}"))
|
||||
|
||||
# ---- lane widths ------------------------------------------------------
|
||||
def eventFilter(self, obj, event): # noqa: N802
|
||||
if obj is self._board_scroll.viewport() and event.type() == QEvent.Resize:
|
||||
self._fit_lanes()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def _fit_lanes(self) -> None:
|
||||
floor = self.fontMetrics().averageCharWidth() * self._LANE_FLOOR_CH + 24
|
||||
for col in self.columns.values():
|
||||
if col.minimumWidth() != floor:
|
||||
col.setMinimumWidth(floor)
|
||||
|
||||
# ---- rendering ----------------------------------------------------------
|
||||
def _card_text(self, t: dict) -> str:
|
||||
prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "")
|
||||
ai = "[AI] " if t.get("is_ai_generated") else ""
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else None
|
||||
when_line = when or tr("schedtask.no_schedule")
|
||||
chain = ""
|
||||
if t.get("dependency", {}).get("next_task_id") or t.get("dependency", {}).get("previous_task_id"):
|
||||
chain = " (linked)"
|
||||
last = t.get("logs", {}).get("last_status")
|
||||
last_line = {"success": tr("schedtask.last_success"),
|
||||
"failed": tr("schedtask.last_failed")}.get(last, tr("schedtask.last_never"))
|
||||
return (f"{ai}{t.get('title', '')}{chain}\n"
|
||||
f"{when_line} {prio}\n{last_line}")
|
||||
|
||||
def refresh(self) -> List[dict]:
|
||||
"""Re-render every lane from disk. Returns the full task list so the
|
||||
shell can hand the same read to ``CalendarView.set_tasks`` without a
|
||||
second ``list_tasks`` call."""
|
||||
all_tasks = self._repo.list()
|
||||
counts = {s: 0 for s in STATUSES}
|
||||
for col in self.columns.values():
|
||||
col.clear()
|
||||
for t in all_tasks:
|
||||
status = t.get("status", "backlog")
|
||||
if status not in self.columns:
|
||||
continue
|
||||
counts[status] += 1
|
||||
item = QListWidgetItem(self._card_text(t))
|
||||
item.setData(Qt.UserRole, t["task_id"])
|
||||
self.columns[status].addItem(item)
|
||||
pal = current_palette()
|
||||
for status, col in self.columns.items():
|
||||
self.column_headers[status].setText(
|
||||
f"{tr(f'schedtask.status.{status}')} ({counts[status]})")
|
||||
# Dropping a card into Running STARTS the task for real, so that
|
||||
# lane is outlined while it holds anything.
|
||||
if status == "running" and counts[status]:
|
||||
col.setStyleSheet(
|
||||
f"border: 1px solid {pal.warning}; border-radius: {pal.radius}px;")
|
||||
self.column_headers[status].setStyleSheet(
|
||||
f"font-weight:600; color: {pal.warning};")
|
||||
else:
|
||||
col.setStyleSheet("")
|
||||
self.column_headers[status].setStyleSheet("font-weight:600;")
|
||||
if col.count() == 0:
|
||||
empty = QListWidgetItem(tr("schedtask.no_tasks"))
|
||||
empty.setFlags(Qt.NoItemFlags)
|
||||
col.addItem(empty)
|
||||
self.counts_changed.emit(counts)
|
||||
return all_tasks
|
||||
|
||||
# ---- actions --------------------------------------------------------
|
||||
def _on_double_click(self, item: QListWidgetItem) -> None:
|
||||
tid = item.data(Qt.UserRole)
|
||||
if tid:
|
||||
self.edit_requested.emit(tid)
|
||||
|
||||
def _on_task_dropped(self, task_id: str, new_status: str) -> None:
|
||||
"""Dropping a card ACTS on the task via ``TaskApplicationService.
|
||||
move_to_status`` — see that method's docstring for the exact rules."""
|
||||
result = self._service.move_to_status(task_id, new_status)
|
||||
if result is None:
|
||||
self.refresh()
|
||||
return
|
||||
if result.blocked:
|
||||
self.refresh() # can't drag a running task
|
||||
return
|
||||
if result.ran_now:
|
||||
self._emit_run_now_message(result.run_now_result,
|
||||
(result.task or {}).get("title", ""))
|
||||
self.refresh()
|
||||
return
|
||||
self.refresh()
|
||||
if result.needs_schedule:
|
||||
# No time set yet — a silently-disabled "Scheduled" card would
|
||||
# never run and look broken. Open the editor right away.
|
||||
self.status_message.emit(tr("schedtask.msg_set_schedule"))
|
||||
self.edit_requested.emit(task_id)
|
||||
|
||||
@staticmethod
|
||||
def _is_multi_selection(item, selected) -> bool:
|
||||
"""True when the right-clicked card is part of an existing multi-item
|
||||
selection — pure boolean, kept separate from _context_menu so it's
|
||||
testable without ever invoking Qt's (modal, event-loop-blocking) menu."""
|
||||
return len(selected) > 1 and item in selected
|
||||
|
||||
def _context_menu(self, col: _KanbanColumn, pos) -> None:
|
||||
item = col.itemAt(pos)
|
||||
if item is None or not item.data(Qt.UserRole):
|
||||
return
|
||||
selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)]
|
||||
if self._is_multi_selection(item, selected):
|
||||
self._bulk_delete_menu(col, pos, selected)
|
||||
return
|
||||
tid = item.data(Qt.UserRole)
|
||||
task = self._repo.get(tid)
|
||||
if not task:
|
||||
return
|
||||
menu = QMenu(col)
|
||||
run_act = menu.addAction(tr("schedtask.menu_run"))
|
||||
edit_act = menu.addAction(tr("schedtask.menu_edit"))
|
||||
dup_act = menu.addAction(tr("schedtask.menu_duplicate"))
|
||||
paused = task.get("status") == "paused"
|
||||
pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause"))
|
||||
logs_act = menu.addAction(tr("schedtask.menu_logs"))
|
||||
hist_act = menu.addAction(tr("schedtask.menu_history"))
|
||||
next_act = menu.addAction(tr("schedtask.menu_create_next"))
|
||||
menu.addSeparator()
|
||||
del_act = menu.addAction(tr("schedtask.menu_delete"))
|
||||
chosen = menu.exec(col.viewport().mapToGlobal(pos))
|
||||
if chosen == run_act:
|
||||
self._emit_run_now_message(self._service.run_now(tid), task.get("title", ""))
|
||||
self.refresh()
|
||||
elif chosen == edit_act:
|
||||
self.edit_requested.emit(tid)
|
||||
elif chosen == dup_act:
|
||||
self._service.duplicate(tid)
|
||||
self.refresh()
|
||||
elif chosen == pause_act:
|
||||
self._service.toggle_pause(tid)
|
||||
self.refresh()
|
||||
elif chosen == logs_act:
|
||||
self._view_logs(task)
|
||||
elif chosen == hist_act:
|
||||
RunHistoryDialog(task, self).exec()
|
||||
elif chosen == next_act:
|
||||
self._create_next_from_output(task)
|
||||
elif chosen == del_act:
|
||||
if QMessageBox.question(self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_confirm", title=task.get("title", ""))
|
||||
) == QMessageBox.Yes:
|
||||
self._service.delete(tid)
|
||||
self.refresh()
|
||||
|
||||
def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None:
|
||||
menu = QMenu(col)
|
||||
del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected)))
|
||||
chosen = menu.exec(col.viewport().mapToGlobal(pos))
|
||||
if chosen == del_act:
|
||||
self._confirm_and_delete_selected(selected)
|
||||
|
||||
def _confirm_and_delete_selected(self, selected) -> bool:
|
||||
"""Confirm, then delete every task in ``selected``. Split out of
|
||||
_bulk_delete_menu so tests can drive it directly without having to
|
||||
fake a real (modal, event-loop-blocking) QMenu popup."""
|
||||
if QMessageBox.question(
|
||||
self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
|
||||
return False
|
||||
ids = [it.data(Qt.UserRole) for it in selected if it.data(Qt.UserRole)]
|
||||
self._service.bulk_delete(ids)
|
||||
self.refresh()
|
||||
return True
|
||||
|
||||
def _emit_run_now_message(self, result, title: str = "") -> None:
|
||||
if result is None:
|
||||
return
|
||||
if result.ok:
|
||||
self.status_message.emit(tr("schedtask.msg_running", title=title))
|
||||
elif result.reason == "manual_task":
|
||||
self.status_message.emit(tr("schedtask.msg_manual_norun"))
|
||||
elif result.reason == "no_scheduler":
|
||||
self.status_message.emit(tr("schedtask.msg_no_scheduler"))
|
||||
|
||||
def _view_logs(self, task: dict) -> None:
|
||||
from cowork_local.core.tasks import ARTIFACTS_DIR
|
||||
|
||||
run_id = task.get("logs", {}).get("last_run_id")
|
||||
if not run_id:
|
||||
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
|
||||
return
|
||||
folder = ARTIFACTS_DIR / task["task_id"] / run_id
|
||||
if folder.exists():
|
||||
open_path(str(folder))
|
||||
else:
|
||||
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
|
||||
|
||||
def _create_next_from_output(self, task: dict) -> None:
|
||||
"""Scaffold a follow-up task pre-wired to consume this task's output.
|
||||
Chain-cycle validation (``chain_error``) is core/tasks.py domain
|
||||
logic already, not duplicated here — only the save + edit-request
|
||||
wiring is this widget's job."""
|
||||
nxt = new_task(tr("schedtask.next_of", title=task.get("title", "")))
|
||||
nxt["task_type"] = "cowork"
|
||||
nxt["input"]["mode"] = "previous_task_output"
|
||||
nxt["input"]["previous_task_id"] = task["task_id"]
|
||||
nxt["dependency"]["previous_task_id"] = task["task_id"]
|
||||
err = chain_error(self._repo.list() + [nxt], task["task_id"], nxt["task_id"])
|
||||
if err:
|
||||
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
|
||||
return
|
||||
self._repo.save(nxt)
|
||||
task["dependency"]["next_task_id"] = nxt["task_id"]
|
||||
task["dependency"]["pass_output_to_next"] = True
|
||||
if task["dependency"].get("run_next_mode", "none") == "none":
|
||||
task["dependency"]["run_next_mode"] = "run_after_success"
|
||||
self._repo.save(task)
|
||||
self.refresh()
|
||||
self.edit_requested.emit(nxt["task_id"])
|
||||
|
||||
|
||||
__all__ = ["KanbanBoardWidget"]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""RunHistoryDialog — one task's run history as a table (R08-T11, split out
|
||||
of ``kanban_board_widget.py`` to keep that file under the 400-line cap;
|
||||
originally ``ui/schedule_task_tab.py::_RunHistoryDialog``, lines 496-548)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView, QDialog, QDialogButtonBox, QLabel, QTableWidget,
|
||||
QTableWidgetItem, QVBoxLayout,
|
||||
)
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.osutil import open_path
|
||||
|
||||
|
||||
class RunHistoryDialog(QDialog):
|
||||
"""Run history of one task as a table (newest first): time, status, error;
|
||||
double-click a row to open that run's artifact folder."""
|
||||
|
||||
def __init__(self, task: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self._task = task
|
||||
self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}")
|
||||
self.resize(620, 380)
|
||||
root = QVBoxLayout(self)
|
||||
hint = QLabel(tr("schedtask.hist_hint"))
|
||||
hint.setObjectName("hint")
|
||||
root.addWidget(hint)
|
||||
|
||||
runs = list(reversed(task.get("runs", []) or []))
|
||||
self.table = QTableWidget(len(runs), 4)
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"),
|
||||
tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"),
|
||||
])
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
for row, run in enumerate(runs):
|
||||
cells = (
|
||||
run.get("finished_at", ""),
|
||||
str(run.get("status", "")),
|
||||
run.get("run_id", ""),
|
||||
(run.get("error") or "")[:200],
|
||||
)
|
||||
for col, text in enumerate(cells):
|
||||
item = QTableWidgetItem(str(text))
|
||||
if col == 0:
|
||||
item.setData(Qt.UserRole, run.get("run_id", ""))
|
||||
self.table.setItem(row, col, item)
|
||||
self.table.resizeColumnsToContents()
|
||||
self.table.horizontalHeader().setStretchLastSection(True)
|
||||
self.table.itemDoubleClicked.connect(self._open_artifact)
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
buttons.rejected.connect(self.reject)
|
||||
buttons.accepted.connect(self.accept)
|
||||
root.addWidget(buttons)
|
||||
|
||||
def _open_artifact(self, item: QTableWidgetItem) -> None:
|
||||
from cowork_local.core.tasks import ARTIFACTS_DIR
|
||||
|
||||
first = self.table.item(item.row(), 0)
|
||||
run_id = first.data(Qt.UserRole) if first else ""
|
||||
if not run_id:
|
||||
return
|
||||
folder = ARTIFACTS_DIR / self._task["task_id"] / run_id
|
||||
if folder.exists():
|
||||
open_path(str(folder))
|
||||
|
||||
|
||||
__all__ = ["RunHistoryDialog"]
|
||||
@@ -0,0 +1,185 @@
|
||||
"""ScheduleTaskTab shell (R08-T11) — assembles
|
||||
``kanban_board_widget.py::KanbanBoardWidget`` and
|
||||
``calendar_view_widget.py::CalendarView`` behind the header/view-switch that
|
||||
used to be inline in ``ui/schedule_task_tab.py`` (lines 81-245 of the
|
||||
original 795-line file: header, view-tab wiring, lane-fit event filter moved
|
||||
into the Kanban widget itself, the belt-and-braces 10s refresh timer).
|
||||
|
||||
Task EDITING (opening ``TaskEditorDialog``) lives HERE, not in either child
|
||||
widget, because both need the exact same "open the editor for this task id"
|
||||
behaviour — Kanban's double-click/edit-menu and Calendar's task click both
|
||||
request it via a signal instead of each importing ``TaskEditorDialog``
|
||||
themselves.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PySide6.QtCore import QTimer, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QLabel, QPushButton, QSizePolicy, QStackedWidget,
|
||||
QTabBar, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.core.tasks import STATUSES, list_tasks, load_task, new_task, save_task
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.scheduling.calendar_view_widget import CalendarView
|
||||
from cowork_local.presentation.scheduling.kanban_board_widget import KanbanBoardWidget
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
_VIEWS = ("kanban", "calendar")
|
||||
|
||||
|
||||
class ScheduleTaskTab(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext, scheduler=None, tasks_dir: Optional[Path] = None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self.scheduler = scheduler # TaskScheduler (may be None in tests)
|
||||
# None -> the app's default TASKS_DIR (core/tasks.py). Overridable
|
||||
# (new in R08-T11; the original monolithic tab hardcoded None with no
|
||||
# way to point it at a tmp_path) so this shell is actually testable
|
||||
# without touching the user's real config folder — same shape
|
||||
# TaskScheduler.__init__ already accepts.
|
||||
self._tasks_dir: Optional[Path] = tasks_dir
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
# ---- header ----------------------------------------------------
|
||||
header = QHBoxLayout()
|
||||
self._title = QLabel()
|
||||
self._title.setStyleSheet("font-weight:700; font-size:15px;")
|
||||
self.counts_lbl = QLabel("")
|
||||
self.counts_lbl.setObjectName("hint")
|
||||
self.counts_lbl.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
|
||||
self.counts_lbl.setMinimumWidth(0)
|
||||
self.add_btn = QPushButton()
|
||||
self.add_btn.setIcon(icon("plus"))
|
||||
self.add_btn.setObjectName("primary")
|
||||
self.add_btn.clicked.connect(self._add_task)
|
||||
self.ai_btn = QPushButton()
|
||||
self.ai_btn.setIcon(icon("sparkle"))
|
||||
self.ai_btn.clicked.connect(self._ai_create)
|
||||
# Two views of the same tasks, so they read as a pair of tabs rather
|
||||
# than a drop-list you have to open to discover the Calendar exists.
|
||||
self.view_tabs = QTabBar()
|
||||
self.view_tabs.setObjectName("viewTabs")
|
||||
self.view_tabs.setDrawBase(False)
|
||||
self.view_tabs.setExpanding(False)
|
||||
for _v in _VIEWS:
|
||||
self.view_tabs.addTab("")
|
||||
self.view_tabs.currentChanged.connect(self._on_view_changed)
|
||||
header.addWidget(self._title)
|
||||
header.addWidget(self.counts_lbl, 1)
|
||||
header.addWidget(self.view_tabs)
|
||||
header.addWidget(self.add_btn)
|
||||
header.addWidget(self.ai_btn)
|
||||
root.addLayout(header)
|
||||
|
||||
# ---- board / calendar (two views of the SAME tasks) -----------------
|
||||
self._view_stack = QStackedWidget()
|
||||
self.kanban = KanbanBoardWidget(ctx, tasks_dir=self._tasks_dir, scheduler=scheduler)
|
||||
self.kanban.status_message.connect(self.status_message.emit)
|
||||
self.kanban.counts_changed.connect(self._on_counts_changed)
|
||||
self.kanban.edit_requested.connect(self._edit_task)
|
||||
self._view_stack.addWidget(self.kanban)
|
||||
self.calendar = CalendarView()
|
||||
self.calendar.edit_task.connect(self._edit_task)
|
||||
self.calendar.add_task_on_date.connect(self._add_task_on_date)
|
||||
self._view_stack.addWidget(self.calendar)
|
||||
root.addWidget(self._view_stack, 1)
|
||||
|
||||
if self.scheduler is not None:
|
||||
self.scheduler.tasks_changed.connect(self.refresh)
|
||||
self.scheduler.task_started.connect(lambda _tid: self.refresh())
|
||||
self.scheduler.task_finished.connect(lambda _tid, _ok: self.refresh())
|
||||
|
||||
# Belt-and-braces: also re-read the board every 10s so a card's lane
|
||||
# ALWAYS reflects reality (Scheduled → Running → Done) even if some
|
||||
# change slipped past the signals (e.g. task files edited externally).
|
||||
self._refresh_timer = QTimer(self)
|
||||
self._refresh_timer.setInterval(10_000)
|
||||
self._refresh_timer.timeout.connect(self.refresh)
|
||||
self._refresh_timer.start()
|
||||
|
||||
self.refresh()
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
# ---- i18n ------------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
self._title.setText(tr("schedtask.title"))
|
||||
self.add_btn.setText(tr("schedtask.add_btn"))
|
||||
self.add_btn.setToolTip(tr("schedtask.add_tooltip"))
|
||||
self.ai_btn.setText(tr("schedtask.ai_btn"))
|
||||
self.ai_btn.setToolTip(tr("schedtask.ai_tooltip"))
|
||||
for i, v in enumerate(_VIEWS):
|
||||
self.view_tabs.setTabText(i, tr(f"schedtask.view.{v}"))
|
||||
self.kanban.retranslate()
|
||||
self.refresh()
|
||||
|
||||
# ---- Kanban / Calendar view switch --------------------------------
|
||||
def _on_view_changed(self) -> None:
|
||||
self._view_stack.setCurrentIndex(self.view_tabs.currentIndex())
|
||||
|
||||
def _on_counts_changed(self, counts: dict) -> None:
|
||||
summary = " ".join(
|
||||
f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s])
|
||||
self.counts_lbl.setText(summary)
|
||||
self.counts_lbl.setToolTip(summary) # full text stays reachable if clipped
|
||||
|
||||
def refresh(self) -> None:
|
||||
all_tasks = self.kanban.refresh()
|
||||
self.calendar.set_tasks(all_tasks)
|
||||
|
||||
# ---- task creation / editing (shared by Kanban + Calendar) -----------
|
||||
def _save_and_refresh(self, task: dict) -> None:
|
||||
save_task(task, self._tasks_dir)
|
||||
self.refresh()
|
||||
|
||||
def _add_task(self) -> None:
|
||||
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
||||
|
||||
dlg = TaskEditorDialog(None, list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
self.status_message.emit(tr("schedtask.msg_created"))
|
||||
|
||||
def _edit_task(self, task_id: str) -> None:
|
||||
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
||||
|
||||
task = load_task(task_id, self._tasks_dir)
|
||||
if not task:
|
||||
return
|
||||
dlg = TaskEditorDialog(task, list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
|
||||
def _add_task_on_date(self, date_str: str) -> None:
|
||||
"""Create a task pre-filled with the clicked calendar date (default
|
||||
09:00) — same editor Add Task opens, nothing is saved until confirmed."""
|
||||
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
|
||||
|
||||
t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"})
|
||||
dlg = TaskEditorDialog(t, list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
self.status_message.emit(tr("schedtask.msg_created"))
|
||||
|
||||
# ---- AI create ----------------------------------------------------------
|
||||
def _ai_create(self) -> None:
|
||||
from cowork_local.presentation.scheduling.ai_task_creator_dialog import (
|
||||
AiTaskCreatorDialog,
|
||||
)
|
||||
|
||||
dlg = AiTaskCreatorDialog(self.ctx, self)
|
||||
if dlg.exec() and dlg.created_tasks:
|
||||
for t in dlg.created_tasks:
|
||||
save_task(t, self._tasks_dir)
|
||||
self.refresh()
|
||||
self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks)))
|
||||
|
||||
|
||||
__all__ = ["ScheduleTaskTab"]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Small pieces shared across more than one presentation screen (EPIC R08).
|
||||
|
||||
Kept intentionally minimal — this is NOT a dumping ground for every reusable
|
||||
widget (``ui/icons.py``, ``ui/widgets.py``, ``ui/routing_toggle.py`` stay
|
||||
where they are; migrating those is a separate concern from R08-T12/T14).
|
||||
Only ``HAS_WEB_ENGINE`` lives here so far — it was one module-level flag
|
||||
duplicated between two God files being split by two different R08 tasks
|
||||
(``ui/folder_tab.py`` and ``ui/structure_graph_view.py``), and a shared
|
||||
constant beats one screen importing another screen's module.
|
||||
"""
|
||||
|
||||
from .web_engine_support import HAS_WEB_ENGINE
|
||||
|
||||
__all__ = ["HAS_WEB_ENGINE"]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""HAS_WEB_ENGINE — whether ``QWebEngineView`` is safe to construct here
|
||||
(R08-T12/T14, extracted from ``ui/structure_graph_view.py``, lines 25-48 of
|
||||
its original 1035-line version — the ONLY place this detection logic lived;
|
||||
``ui/folder_tab.py`` used to import it FROM that module via a try/except).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _frozen_onefile() -> bool:
|
||||
"""True only for a PyInstaller ONEFILE build. Onefile extracts itself to a
|
||||
temp dir (sys._MEIPASS under %TEMP%), where the QtWebEngine helper process
|
||||
can't run — creating a QWebEngineView hard-crashes the app (reported as
|
||||
"click the graph tab → app closes"). A ONEDIR build keeps _MEIPASS as the
|
||||
``_internal`` folder right next to the exe, where WebEngine works fine, so
|
||||
it keeps the full embedded D3/HTML view."""
|
||||
if not getattr(sys, "frozen", False):
|
||||
return False
|
||||
meipass = getattr(sys, "_MEIPASS", "")
|
||||
if not meipass:
|
||||
return False
|
||||
try:
|
||||
return Path(meipass).resolve().parent != Path(sys.executable).resolve().parent
|
||||
except OSError: # can't tell → play safe: use the native/fallback view
|
||||
return True
|
||||
|
||||
|
||||
try: # WebEngine + WebChannel are optional PySide6 add-ons
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401
|
||||
from PySide6.QtWebChannel import QWebChannel # noqa: F401
|
||||
HAS_WEB_ENGINE = not _frozen_onefile()
|
||||
except Exception: # pragma: no cover
|
||||
HAS_WEB_ENGINE = False
|
||||
|
||||
|
||||
__all__ = ["HAS_WEB_ENGINE"]
|
||||
@@ -0,0 +1,142 @@
|
||||
"""EPIC R08-T13: TokenUsageCardWidget / UsageChartWidget / HabitsWidget /
|
||||
DashboardTab shell, real Qt offscreen.
|
||||
|
||||
``DashboardQueryService``'s own logic is unit tested (R08-T13, no Qt) in
|
||||
``tests/unit/test_dashboard_query_service.py``; this file proves the three
|
||||
widgets and the shell are actually wired to it and to each other (the period
|
||||
selector living on ``UsageChartWidget`` driving all three).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.core import usage_tracker as ut # noqa: E402
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def usage_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "usage"
|
||||
monkeypatch.setattr(ut, "USAGE_DIR", d)
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(qt_app, tmp_path):
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
def _write_event(usage_dir, day: date, **overrides):
|
||||
usage_dir.mkdir(parents=True, exist_ok=True)
|
||||
event = {
|
||||
"ts": f"{day.isoformat()}T10:00:00", "source": "cowork", "label": "Test chat",
|
||||
"provider": "anthropic", "model": "claude-sonnet-4-6",
|
||||
"in": 100, "out": 50, "cache": 0, "estimated": False,
|
||||
"account": "", "machine": "",
|
||||
}
|
||||
event.update(overrides)
|
||||
path = usage_dir / f"{day.isoformat()}.jsonl"
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event) + "\n")
|
||||
|
||||
|
||||
def test_dashboard_tab_builds_and_populates_cards(usage_dir, ctx):
|
||||
from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab
|
||||
|
||||
_write_event(usage_dir, date.today())
|
||||
tab = DashboardTab(ctx) # refresh() runs once at construction
|
||||
|
||||
assert "100" in tab.token_cards.card_in.value_lbl.text() \
|
||||
or tab.token_cards.card_in.value_lbl.text() # non-crashing, has SOME text
|
||||
assert tab.token_cards.card_total.value_lbl.text() != ""
|
||||
|
||||
|
||||
def test_period_navigation_on_chart_refreshes_the_whole_shell(usage_dir, ctx):
|
||||
from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab
|
||||
|
||||
_write_event(usage_dir, date.today())
|
||||
tab = DashboardTab(ctx)
|
||||
calls = []
|
||||
tab.refresh = lambda *a, orig=tab.refresh: (calls.append(1), orig(*a))[-1]
|
||||
|
||||
tab.chart._chart_prev()
|
||||
|
||||
assert calls == [1]
|
||||
|
||||
|
||||
def test_granularity_change_resets_offset_to_current(usage_dir, ctx):
|
||||
from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab
|
||||
from cowork_local.presentation.dashboard.usage_chart_widget import UsageChartWidget
|
||||
|
||||
tab = DashboardTab(ctx)
|
||||
tab.chart._chart_offset = -3
|
||||
|
||||
idx = tab.chart.gran_combo.findData("month")
|
||||
tab.chart.gran_combo.setCurrentIndex(idx)
|
||||
|
||||
assert tab.chart.chart_offset == 0
|
||||
|
||||
|
||||
def test_currency_change_persists_and_triggers_refresh(usage_dir, ctx):
|
||||
from cowork_local.presentation.dashboard.dashboard_tab import DashboardTab
|
||||
|
||||
tab = DashboardTab(ctx)
|
||||
idx = tab.chart.currency_combo.findData("EUR") \
|
||||
if tab.chart.currency_combo.findData("EUR") >= 0 else 0
|
||||
seen = []
|
||||
tab.chart.currency_changed.connect(lambda: seen.append(1))
|
||||
|
||||
tab.chart.currency_combo.setCurrentIndex(idx)
|
||||
|
||||
if tab.chart.currency_combo.currentData() != "USD":
|
||||
assert seen == [1]
|
||||
assert ctx.config.data["usage"]["currency"] == tab.chart.currency_combo.currentData()
|
||||
|
||||
|
||||
def test_habits_widget_ai_analyze_noop_without_data(usage_dir, ctx):
|
||||
"""No events in range -> emits a status message instead of starting a
|
||||
background worker (matches the original _ai_analyze guard)."""
|
||||
from cowork_local.presentation.dashboard.habits_widget import HabitsWidget
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
|
||||
query = DashboardQueryService(ctx)
|
||||
widget = HabitsWidget(ctx, query)
|
||||
widget.refresh(date(2020, 1, 1), date(2020, 1, 1))
|
||||
messages = []
|
||||
widget.status_message.connect(messages.append)
|
||||
|
||||
widget._ai_analyze()
|
||||
|
||||
assert messages # "no data" status, no worker started
|
||||
assert widget._ai_worker is None
|
||||
|
||||
|
||||
def test_budget_apply_updates_budget_card(usage_dir, ctx):
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.presentation.dashboard.token_usage_card_widget import TokenUsageCardWidget
|
||||
|
||||
query = DashboardQueryService(ctx)
|
||||
widget = TokenUsageCardWidget(ctx, query)
|
||||
widget.budget_card.budget_spin.setValue(50.0)
|
||||
|
||||
widget._apply_budget()
|
||||
|
||||
status = query.budget_status()
|
||||
assert status is not None
|
||||
assert status["amount_usd"] == pytest.approx(50.0)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""EPIC R08-T12: WorkspaceFileTree / DocumentPreviewManager / FolderTab,
|
||||
real Qt offscreen.
|
||||
|
||||
Scope note: the AI-Edit panel's send -> plan -> edit -> apply pipeline
|
||||
(``ai_edit_pipeline.py``) runs on a real ``core.worker.AgentWorker`` QThread
|
||||
and had ZERO existing tests before this task (confirmed by grep — nothing
|
||||
under tests/ exercised ``ui/folder_tab.py``'s AI methods except the routing
|
||||
surface test, fixed alongside this task in
|
||||
``tests/integration/test_routing_surfaces.py``). Driving that full async
|
||||
pipeline end to end is out of scope here; what's covered is everything that
|
||||
doesn't need a live QThread: navigation, preview rendering, and — the
|
||||
concrete R08-T12 deliverable — that saves/creates actually go through
|
||||
``FileWorkspaceService`` (containment enforced, not just "writes a file").
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(qt_app, tmp_path: Path):
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root(tmp_path: Path) -> Path:
|
||||
d = tmp_path / "workspace"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
# ---- WorkspaceFileTree ----------------------------------------------------- #
|
||||
def test_workspace_file_tree_set_root_updates_label_and_model(qt_app, root):
|
||||
from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree
|
||||
|
||||
tree = WorkspaceFileTree(str(root))
|
||||
other = root.parent / "other-root"
|
||||
other.mkdir()
|
||||
|
||||
tree.set_root(str(other))
|
||||
|
||||
assert tree.root == str(other)
|
||||
assert tree.path_lbl.text() == str(other)
|
||||
|
||||
|
||||
def test_workspace_file_tree_ignores_an_invalid_root(qt_app, root):
|
||||
from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree
|
||||
|
||||
tree = WorkspaceFileTree(str(root))
|
||||
tree.set_root(str(root / "does-not-exist"))
|
||||
|
||||
assert tree.root == str(root) # unchanged
|
||||
|
||||
|
||||
def test_workspace_file_tree_click_emits_file_selected(qt_app, root):
|
||||
from cowork_local.presentation.folder.workspace_file_tree import WorkspaceFileTree
|
||||
|
||||
f = root / "a.txt"
|
||||
f.write_text("hello", encoding="utf-8")
|
||||
tree = WorkspaceFileTree(str(root))
|
||||
seen = []
|
||||
tree.file_selected.connect(seen.append)
|
||||
|
||||
index = tree.model.index(str(f))
|
||||
tree._on_tree_clicked(index)
|
||||
|
||||
assert seen and os.path.normpath(seen[0]) == os.path.normpath(str(f))
|
||||
|
||||
|
||||
# ---- DocumentPreviewManager ------------------------------------------------- #
|
||||
def test_open_file_renders_code_into_the_editor(qt_app, root):
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
f = root / "script.py"
|
||||
f.write_text("print('hi')\n", encoding="utf-8")
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
|
||||
preview.open_file(str(f))
|
||||
|
||||
assert preview.stack.currentWidget() is preview.editor
|
||||
assert preview.editor.toPlainText() == "print('hi')\n"
|
||||
assert preview.current_file == str(f)
|
||||
|
||||
|
||||
def test_open_file_on_a_binary_file_shows_the_placeholder(qt_app, root):
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
f = root / "data.bin"
|
||||
f.write_bytes(b"\x00\x01\x02binary")
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
|
||||
preview.open_file(str(f))
|
||||
|
||||
assert preview.stack.currentWidget() is preview._placeholder
|
||||
|
||||
|
||||
def test_save_writes_through_file_workspace_service(qt_app, root):
|
||||
"""The concrete R08-T12/R06-T05 deliverable: FileWorkspaceService (built
|
||||
at R06, unused until this task) is now the actual write path."""
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
f = root / "note.txt"
|
||||
f.write_text("old", encoding="utf-8")
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
preview.open_file(str(f))
|
||||
|
||||
preview.editor.setPlainText("new content")
|
||||
preview.save()
|
||||
|
||||
assert f.read_text(encoding="utf-8") == "new content"
|
||||
|
||||
|
||||
def test_create_new_file_writes_inside_root_via_file_workspace_service(qt_app, root):
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
|
||||
dest = preview.create_new_file("sub/new.md", "# hello")
|
||||
|
||||
assert dest == str(root / "sub" / "new.md")
|
||||
assert (root / "sub" / "new.md").read_text(encoding="utf-8") == "# hello"
|
||||
assert preview.current_file == dest # opened it, kept the AI chat (reset_ai=False)
|
||||
|
||||
|
||||
def test_create_new_file_rejects_a_path_escaping_the_root(qt_app, root):
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
messages = []
|
||||
preview.status_message.connect(messages.append)
|
||||
|
||||
dest = preview.create_new_file("../escaped.txt", "nope")
|
||||
|
||||
assert dest is None
|
||||
assert not (root.parent / "escaped.txt").exists()
|
||||
assert messages # a save_error status was emitted
|
||||
|
||||
|
||||
def test_write_content_persists_and_refreshes_html_preview(qt_app, root):
|
||||
from cowork_local.presentation.folder.document_preview_manager import DocumentPreviewManager
|
||||
|
||||
f = root / "page.html"
|
||||
f.write_text("<p>old</p>", encoding="utf-8")
|
||||
preview = DocumentPreviewManager(str(root))
|
||||
preview.open_file(str(f)) # HTML opens in preview mode by default
|
||||
|
||||
preview.write_content("<p>new</p>")
|
||||
|
||||
assert f.read_text(encoding="utf-8") == "<p>new</p>"
|
||||
|
||||
|
||||
# ---- FolderTab shell -------------------------------------------------------- #
|
||||
def test_folder_tab_builds_and_wires_tree_to_preview(ctx, root):
|
||||
from cowork_local.presentation.folder.folder_tab import FolderTab
|
||||
|
||||
tab = FolderTab(ctx)
|
||||
tab.set_root(str(root))
|
||||
f = root / "hello.py"
|
||||
f.write_text("x = 1\n", encoding="utf-8")
|
||||
|
||||
tab.tree.file_selected.emit(str(f)) # simulate a tree click
|
||||
|
||||
assert tab.preview.current_file == str(f)
|
||||
assert tab.preview.editor.toPlainText() == "x = 1\n"
|
||||
|
||||
|
||||
def test_folder_tab_ai_panel_toggle_updates_button_badge(ctx, root):
|
||||
from cowork_local.presentation.folder.folder_tab import FolderTab
|
||||
|
||||
tab = FolderTab(ctx)
|
||||
assert tab.ai_panel.isHidden()
|
||||
|
||||
tab.ai_btn.setChecked(True)
|
||||
tab._toggle_ai_panel()
|
||||
|
||||
assert not tab.ai_panel.isHidden()
|
||||
@@ -6,7 +6,7 @@ of that algorithm now call it, on real (offscreen) widgets:
|
||||
|
||||
* ``ui/chat_panel.py::_apply_routing`` (Cowork)
|
||||
* ``ui/co4e_tab.py::_apply_co4e_routing`` (Co4E)
|
||||
* ``ui/folder_tab.py::_ai_apply_routing`` (AI-Edit)
|
||||
* ``presentation/folder/ai_edit_model_resolver.py::AiEditModelResolver.apply_routing`` (AI-Edit)
|
||||
|
||||
It also pins the Manual-mode handshake, including the field contract the
|
||||
existing confirm dialog reads off the decision - the one place where the new
|
||||
@@ -189,15 +189,20 @@ def test_co4e_returns_an_empty_model_when_routing_is_off(ctx):
|
||||
# AI-Edit
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_ai_edit_routes_on_its_own_surface_key(ctx):
|
||||
from cowork_local.ui.folder_tab import FolderTab
|
||||
"""R08-T12: the routing call this test pins moved from
|
||||
``ui/folder_tab.py::FolderTab._ai_apply_routing`` to
|
||||
``presentation/folder/ai_edit_model_resolver.py::AiEditModelResolver.
|
||||
apply_routing`` - same RoutingApplicationService call, same surface key,
|
||||
now independently testable without the whole FolderTab widget tree."""
|
||||
from cowork_local.presentation.folder.folder_tab import FolderTab
|
||||
|
||||
router = _install(ctx, "auto")
|
||||
tab = FolderTab(ctx)
|
||||
|
||||
tab._ai_apply_routing("rename this variable")
|
||||
tab.ai_panel.resolver.apply_routing("rename this variable")
|
||||
|
||||
assert router.surfaces == ["ai_edit"]
|
||||
assert (tab._ai_routed_provider, tab._ai_routed_model) == (
|
||||
assert (tab.ai_panel.resolver.routed_provider, tab.ai_panel.resolver.routed_model) == (
|
||||
"anthropic", "claude-sonnet-4-6")
|
||||
|
||||
|
||||
@@ -206,7 +211,7 @@ def test_ai_edit_pins_the_coding_task_type(ctx):
|
||||
classification entirely - the constraint has to survive the move into the
|
||||
shared service or it is silently dropped."""
|
||||
from cowork_local.core.routing.models import TaskType
|
||||
from cowork_local.ui.folder_tab import FolderTab
|
||||
from cowork_local.presentation.folder.folder_tab import FolderTab
|
||||
|
||||
seen: List[Any] = []
|
||||
|
||||
@@ -219,7 +224,7 @@ def test_ai_edit_pins_the_coding_task_type(ctx):
|
||||
_Recorder(), mode_reader=lambda _s: "auto")
|
||||
tab = FolderTab(ctx)
|
||||
|
||||
tab._ai_apply_routing("rename this variable")
|
||||
tab.ai_panel.resolver.apply_routing("rename this variable")
|
||||
|
||||
assert seen == [TaskType.CODING]
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""EPIC R08-T11: ScheduleTaskTab shell + KanbanBoardWidget, real Qt offscreen.
|
||||
|
||||
Drives the real widgets end to end (build -> refresh -> drag-drop rule via
|
||||
TaskApplicationService -> refresh) against a tmp_path task repository, the
|
||||
way ``test_history_dir_race.py`` proves R06-T04 against real Qt rather than
|
||||
a double. ``TaskApplicationService``'s own business rules are already unit
|
||||
tested (R07-T04); this file exists to prove the WIDGET is actually wired to
|
||||
that service, not to re-test the rules themselves.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.infrastructure.persistence.json.task_repository_impl import ( # noqa: E402
|
||||
TaskRepository,
|
||||
)
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(qt_app, tmp_path: Path):
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tasks_dir(tmp_path: Path) -> Path:
|
||||
d = tmp_path / "tasks"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
def test_schedule_task_tab_builds_and_refreshes_with_no_tasks(ctx, tasks_dir):
|
||||
from cowork_local.presentation.scheduling.schedule_task_tab import ScheduleTaskTab
|
||||
|
||||
tab = ScheduleTaskTab(ctx, scheduler=None, tasks_dir=tasks_dir)
|
||||
tab.refresh() # must not raise against an empty repo
|
||||
|
||||
assert tab.kanban.columns.keys() # 7 lanes were built
|
||||
|
||||
|
||||
def test_kanban_renders_a_task_into_its_status_lane(ctx, tasks_dir):
|
||||
from cowork_local.presentation.scheduling.kanban_board_widget import KanbanBoardWidget
|
||||
|
||||
repo = TaskRepository(tasks_dir)
|
||||
task = repo.create("My Task", task_type="cowork")
|
||||
task["status"] = "backlog"
|
||||
repo.save(task)
|
||||
|
||||
board = KanbanBoardWidget(ctx, tasks_dir=tasks_dir)
|
||||
board.refresh()
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
backlog_ids = [board.columns["backlog"].item(i).data(Qt.UserRole)
|
||||
for i in range(board.columns["backlog"].count())]
|
||||
assert task["task_id"] in backlog_ids
|
||||
|
||||
|
||||
def test_dropping_a_card_on_done_disables_its_schedule_through_the_real_widget(ctx, tasks_dir):
|
||||
"""Same rule TaskApplicationService.move_to_status covers at the unit
|
||||
level (R07-T04) — this proves the Kanban widget's drop handler actually
|
||||
calls it, end to end, with a real TaskRepository on disk."""
|
||||
from cowork_local.presentation.scheduling.kanban_board_widget import KanbanBoardWidget
|
||||
|
||||
repo = TaskRepository(tasks_dir)
|
||||
task = repo.create("Recurring", task_type="cowork")
|
||||
task["schedule"]["enabled"] = True
|
||||
task["schedule"]["run_at"] = "2026-08-28 09:00"
|
||||
task["schedule"]["repeat_type"] = "daily"
|
||||
task["status"] = "scheduled"
|
||||
repo.save(task)
|
||||
|
||||
board = KanbanBoardWidget(ctx, tasks_dir=tasks_dir)
|
||||
board.refresh()
|
||||
|
||||
board._on_task_dropped(task["task_id"], "done")
|
||||
|
||||
on_disk = repo.get(task["task_id"])
|
||||
assert on_disk["status"] == "done"
|
||||
assert on_disk["schedule"]["enabled"] is False
|
||||
|
||||
|
||||
def test_kanban_edit_requested_is_wired_to_the_shells_edit_task(ctx, tasks_dir, monkeypatch):
|
||||
"""Proves ScheduleTaskTab actually connects
|
||||
``kanban.edit_requested -> self._edit_task`` (not just that the Kanban
|
||||
widget emits the signal in isolation) by monkeypatching the dialog class
|
||||
``_edit_task`` opens and checking it was constructed for the right task."""
|
||||
import cowork_local.ui.task_editor_dialog as task_editor_dialog_module
|
||||
from cowork_local.presentation.scheduling.schedule_task_tab import ScheduleTaskTab
|
||||
|
||||
repo = TaskRepository(tasks_dir)
|
||||
task = repo.create("Editable")
|
||||
repo.save(task)
|
||||
|
||||
seen_task_ids = []
|
||||
|
||||
class _FakeDialog:
|
||||
def __init__(self, task, all_tasks, parent, ctx):
|
||||
seen_task_ids.append(task["task_id"] if task else None)
|
||||
self.edited_task = None
|
||||
|
||||
def exec(self):
|
||||
return False # Cancel — nothing further should happen
|
||||
|
||||
monkeypatch.setattr(task_editor_dialog_module, "TaskEditorDialog", _FakeDialog)
|
||||
|
||||
tab = ScheduleTaskTab(ctx, scheduler=None, tasks_dir=tasks_dir)
|
||||
tab.kanban.edit_requested.emit(task["task_id"])
|
||||
|
||||
assert seen_task_ids == [task["task_id"]]
|
||||
@@ -0,0 +1,162 @@
|
||||
"""EPIC R08-T14: GraphRenderer / GraphQaWidget / StructureGraphView shell,
|
||||
real Qt offscreen.
|
||||
|
||||
Scope note: a real directory SCAN (``GraphRenderer._scan``) runs on a
|
||||
``core.worker.AgentWorker`` QThread and had no existing test before this
|
||||
task either (grep confirms nothing under tests/ exercised
|
||||
``ui/structure_graph_view.py``). These tests drive ``_render()`` directly
|
||||
with a hand-built ``StructureGraph`` instead of a live scan — enough to
|
||||
prove the renderer <-> Q&A wiring (the actual R08-T14 deliverable) without
|
||||
needing a real codebase to walk.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from cowork_local.config import AppConfig # noqa: E402
|
||||
from cowork_local.core.structure_graph import GEdge, GNode, StructureGraph # noqa: E402
|
||||
from cowork_local.state import AppContext # noqa: E402
|
||||
|
||||
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(qt_app, tmp_path):
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
def _fake_graph(tmp_path):
|
||||
f = tmp_path / "mod.py"
|
||||
f.write_text("def hello():\n pass\n", encoding="utf-8")
|
||||
nodes = [
|
||||
GNode(id="n1", label="mod.py", kind="file", detail="a module", path=str(f)),
|
||||
GNode(id="n2", label="hello", kind="function", detail="says hi", path=str(f)),
|
||||
]
|
||||
edges = [GEdge(source="n1", target="n2", type="defines")]
|
||||
return StructureGraph(nodes=nodes, edges=edges)
|
||||
|
||||
|
||||
def test_structure_graph_view_builds(ctx):
|
||||
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
|
||||
|
||||
view = StructureGraphView(ctx)
|
||||
assert view.renderer is not None
|
||||
assert view.qa is not None
|
||||
|
||||
|
||||
def test_render_populates_the_scene_and_emits_graph_rendered(ctx, tmp_path):
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
renderer = GraphRenderer(ctx)
|
||||
graph = _fake_graph(tmp_path)
|
||||
seen = []
|
||||
renderer.graph_rendered.connect(lambda: seen.append(1))
|
||||
|
||||
renderer._render({"graph": graph, "pos": {"n1": (0, 0), "n2": (100, 0)}, "seq": renderer._scan_seq})
|
||||
|
||||
assert renderer.graph is graph
|
||||
assert len(renderer._node_items) == 2
|
||||
assert seen == [1]
|
||||
|
||||
|
||||
def test_render_ignores_a_stale_scan_result(ctx, tmp_path):
|
||||
"""Only the LATEST scan's result is ever rendered (no stale overwrite)."""
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
renderer = GraphRenderer(ctx)
|
||||
first = _fake_graph(tmp_path)
|
||||
renderer._render({"graph": first, "pos": {}, "seq": renderer._scan_seq})
|
||||
renderer._scan_seq += 1 # a second scan started
|
||||
|
||||
stale = StructureGraph(nodes=[], edges=[])
|
||||
renderer._render({"graph": stale, "pos": {}, "seq": renderer._scan_seq - 1})
|
||||
|
||||
assert renderer.graph is first # stale result was dropped
|
||||
|
||||
|
||||
def test_node_selection_updates_the_qa_detail_panel(ctx, tmp_path):
|
||||
from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
renderer = GraphRenderer(ctx)
|
||||
graph = _fake_graph(tmp_path)
|
||||
renderer._render({"graph": graph, "pos": {"n1": (0, 0), "n2": (100, 0)}, "seq": renderer._scan_seq})
|
||||
qa = GraphQaWidget(ctx, renderer)
|
||||
|
||||
node = graph.nodes[1]
|
||||
renderer.node_selected.emit(node)
|
||||
|
||||
assert "hello" in qa.detail.toPlainText()
|
||||
assert "says hi" in qa.detail.toPlainText()
|
||||
|
||||
|
||||
def test_candidate_file_paths_uses_selected_nodes_when_present(ctx, tmp_path):
|
||||
from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
renderer = GraphRenderer(ctx)
|
||||
graph = _fake_graph(tmp_path)
|
||||
renderer._render({"graph": graph, "pos": {"n1": (0, 0), "n2": (100, 0)}, "seq": renderer._scan_seq})
|
||||
qa = GraphQaWidget(ctx, renderer)
|
||||
|
||||
# No selection -> every file node in the graph (both nodes share one
|
||||
# file here, so this also proves the path-dedup in _candidate_file_paths).
|
||||
assert qa._candidate_file_paths() == [graph.nodes[0].path]
|
||||
|
||||
renderer._node_items[0].setSelected(True)
|
||||
assert qa._candidate_file_paths() == [graph.nodes[0].path]
|
||||
|
||||
|
||||
def test_project_change_clears_the_qa_extraction_cache(ctx, tmp_path):
|
||||
from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
|
||||
renderer = GraphRenderer(ctx)
|
||||
qa = GraphQaWidget(ctx, renderer)
|
||||
qa._extract_cache = {"some/path.py": "cached text"}
|
||||
|
||||
renderer.project_changed.emit()
|
||||
|
||||
assert qa._extract_cache == {}
|
||||
|
||||
|
||||
def test_qa_collapse_resizes_the_shells_splitter(ctx):
|
||||
"""Exact pixel width is Qt's splitter-layout arithmetic, not this code's
|
||||
concern - what matters is that collapsing narrows the QA pane a lot
|
||||
(down near the strip's width) while the strip itself becomes visible and
|
||||
the total splitter width is conserved."""
|
||||
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
|
||||
from cowork_local.ui.widgets import CollapseStrip
|
||||
|
||||
view = StructureGraphView(ctx)
|
||||
before = view._split.sizes()
|
||||
|
||||
view.qa._set_collapsed(True)
|
||||
|
||||
after = view._split.sizes()
|
||||
assert after[1] <= CollapseStrip.WIDTH + 2
|
||||
assert view.qa.maximumWidth() == CollapseStrip.WIDTH + 2
|
||||
assert sum(after) == sum(before) # total width conserved, just redistributed
|
||||
|
||||
|
||||
def test_hide_event_clears_extracts(ctx):
|
||||
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
|
||||
|
||||
view = StructureGraphView(ctx)
|
||||
view.qa._extract_cache = {"x": "y"}
|
||||
|
||||
from PySide6.QtGui import QHideEvent
|
||||
view.hideEvent(QHideEvent())
|
||||
|
||||
assert view.qa._extract_cache == {}
|
||||
@@ -0,0 +1,103 @@
|
||||
"""EPIC R08-T13: DashboardQueryService — no Qt.
|
||||
|
||||
``core/usage_tracker.py::USAGE_DIR`` is a module-level constant (not
|
||||
injectable per-call except through an explicit ``directory=`` kwarg
|
||||
``load_events`` alone accepts) — this is a pre-existing testability gap the
|
||||
original ``ui/dashboard_tab.py`` also had (it had zero tests before this
|
||||
task). Monkeypatching the module attribute is what lets these tests write
|
||||
usage events without touching the real ``~/.cowork_local/usage/``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.application.monitoring import DashboardQueryService
|
||||
from cowork_local.config import AppConfig
|
||||
from cowork_local.core import usage_tracker as ut
|
||||
from cowork_local.state import AppContext
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def usage_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "usage"
|
||||
monkeypatch.setattr(ut, "USAGE_DIR", d)
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctx(tmp_path):
|
||||
return AppContext(AppConfig.load(tmp_path / "config.json"))
|
||||
|
||||
|
||||
def _write_event(usage_dir, day: date, **overrides):
|
||||
usage_dir.mkdir(parents=True, exist_ok=True)
|
||||
event = {
|
||||
"ts": f"{day.isoformat()}T10:00:00", "source": "cowork", "label": "Test chat",
|
||||
"provider": "anthropic", "model": "claude-sonnet-4-6",
|
||||
"in": 100, "out": 50, "cache": 0, "estimated": False,
|
||||
"account": "", "machine": "",
|
||||
}
|
||||
event.update(overrides)
|
||||
path = usage_dir / f"{day.isoformat()}.jsonl"
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(event) + "\n")
|
||||
|
||||
|
||||
def test_period_range_is_inclusive_end(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
start, end = query.period_range("week", 0)
|
||||
assert start <= end
|
||||
|
||||
|
||||
def test_summary_aggregates_events_in_range(usage_dir, ctx):
|
||||
today = date.today()
|
||||
_write_event(usage_dir, today, **{"in": 100, "out": 50})
|
||||
_write_event(usage_dir, today - timedelta(days=400), **{"in": 999, "out": 999}) # out of range
|
||||
query = DashboardQueryService(ctx)
|
||||
|
||||
summary = query.summary(today, today)
|
||||
|
||||
assert len(summary["events"]) == 1
|
||||
assert summary["stats"]["in"] == 100
|
||||
assert summary["stats"]["out"] == 50
|
||||
assert summary["total_cost"] >= 0
|
||||
|
||||
|
||||
def test_summary_empty_range_has_no_events(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
summary = query.summary(date(2020, 1, 1), date(2020, 1, 1))
|
||||
assert summary["events"] == []
|
||||
assert summary["stats"]["total"] == 0
|
||||
|
||||
|
||||
def test_pricing_returns_a_dict_with_currency(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
pricing = query.pricing()
|
||||
assert "currency" in pricing
|
||||
|
||||
|
||||
def test_chart_series_returns_points_for_the_granularity(usage_dir, ctx):
|
||||
today = date.today()
|
||||
_write_event(usage_dir, today)
|
||||
query = DashboardQueryService(ctx)
|
||||
|
||||
pts = query.chart_series("week", 0, "tokens")
|
||||
|
||||
assert len(pts) == 7 # week view = 7 days
|
||||
assert all(isinstance(p, tuple) and len(p) == 2 for p in pts)
|
||||
|
||||
|
||||
def test_budget_status_none_when_no_budget_set(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
assert query.budget_status() is None
|
||||
|
||||
|
||||
def test_set_budget_then_status_reflects_it(usage_dir, ctx):
|
||||
query = DashboardQueryService(ctx)
|
||||
query.set_budget(100.0, "USD")
|
||||
status = query.budget_status()
|
||||
assert status is not None
|
||||
assert status["amount_usd"] == pytest.approx(100.0)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""EPIC R08-T12: pure helpers moved out of ui/folder_tab.py into
|
||||
application/workspaces/ (file_preview_helpers.py, ai_edit_output.py) — no Qt,
|
||||
directly unit-testable, unlike when they lived as private module functions
|
||||
inside the Qt widget file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.application.workspaces.ai_edit_output import (
|
||||
parse_ai_output,
|
||||
split_code_block,
|
||||
)
|
||||
from cowork_local.application.workspaces.file_preview_helpers import (
|
||||
is_probably_text,
|
||||
read_text,
|
||||
)
|
||||
|
||||
|
||||
def test_read_text_returns_file_contents(tmp_path):
|
||||
f = tmp_path / "a.txt"
|
||||
f.write_text("hello", encoding="utf-8")
|
||||
assert read_text(str(f)) == "hello"
|
||||
|
||||
|
||||
def test_read_text_on_missing_file_returns_a_note_not_raise(tmp_path):
|
||||
result = read_text(str(tmp_path / "missing.txt"))
|
||||
assert "could not read file" in result
|
||||
|
||||
|
||||
def test_is_probably_text_true_for_utf8(tmp_path):
|
||||
f = tmp_path / "a.txt"
|
||||
f.write_text("hello world", encoding="utf-8")
|
||||
assert is_probably_text(str(f)) is True
|
||||
|
||||
|
||||
def test_is_probably_text_false_for_null_bytes(tmp_path):
|
||||
f = tmp_path / "a.bin"
|
||||
f.write_bytes(b"\x00\x01\x02")
|
||||
assert is_probably_text(str(f)) is False
|
||||
|
||||
|
||||
def test_split_code_block_extracts_fenced_block_and_summary():
|
||||
text = "Here is the change:\n\n```python\nprint('hi')\n```"
|
||||
content, summary = split_code_block(text)
|
||||
assert content == "print('hi')\n"
|
||||
assert summary == "Here is the change:"
|
||||
|
||||
|
||||
def test_split_code_block_no_fence_returns_none_and_full_text():
|
||||
content, summary = split_code_block("just prose, no code")
|
||||
assert content is None
|
||||
assert summary == "just prose, no code"
|
||||
|
||||
|
||||
def test_parse_ai_output_extracts_file_target():
|
||||
text = "FILE: new/thing.py\n```python\nx = 1\n```"
|
||||
target, content, summary, image_gens = parse_ai_output(text)
|
||||
assert target == "new/thing.py"
|
||||
assert content == "x = 1\n"
|
||||
assert image_gens == []
|
||||
|
||||
|
||||
def test_parse_ai_output_extracts_image_gen_directives():
|
||||
text = ("Adding an illustration.\n"
|
||||
"IMAGE_GEN: a red fox in a forest => assets/fox.png\n"
|
||||
"```html\n<img src='assets/fox.png'>\n```")
|
||||
target, content, summary, image_gens = parse_ai_output(text)
|
||||
assert image_gens == [("a red fox in a forest", "assets/fox.png")]
|
||||
assert "IMAGE_GEN" not in summary
|
||||
|
||||
|
||||
def test_parse_ai_output_no_directives_or_code_block():
|
||||
target, content, summary, image_gens = parse_ai_output("just an answer")
|
||||
assert target is None
|
||||
assert content is None
|
||||
assert image_gens == []
|
||||
assert summary == "just an answer"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""EPIC R08-T14: graph_index_service.py — pure helpers moved out of
|
||||
ui/structure_graph_view.py, no Qt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from cowork_local.application.workspaces.graph_index_service import extract_file_contents
|
||||
|
||||
|
||||
def test_extract_file_contents_reads_text_files(tmp_path):
|
||||
f = tmp_path / "a.py"
|
||||
f.write_text("print('hello')\n", encoding="utf-8")
|
||||
|
||||
block, cache = extract_file_contents([str(f)], {}, str(tmp_path))
|
||||
|
||||
assert "print('hello')" in block
|
||||
assert str(f) in cache
|
||||
assert cache[str(f)]
|
||||
|
||||
|
||||
def test_extract_file_contents_reuses_the_cache(tmp_path):
|
||||
f = tmp_path / "a.txt"
|
||||
f.write_text("original", encoding="utf-8")
|
||||
seed_cache = {str(f): "cached content, not re-read"}
|
||||
|
||||
block, cache = extract_file_contents([str(f)], seed_cache, str(tmp_path))
|
||||
|
||||
assert "cached content, not re-read" in block
|
||||
|
||||
|
||||
def test_extract_file_contents_skips_unreadable_paths_without_raising(tmp_path):
|
||||
missing = tmp_path / "does-not-exist.txt"
|
||||
block, cache = extract_file_contents([str(missing)], {}, str(tmp_path))
|
||||
assert block == ""
|
||||
|
||||
|
||||
def test_extract_file_contents_respects_max_total_budget(tmp_path):
|
||||
f1 = tmp_path / "a.txt"
|
||||
f1.write_text("x" * 100, encoding="utf-8")
|
||||
f2 = tmp_path / "b.txt"
|
||||
f2.write_text("y" * 100, encoding="utf-8")
|
||||
|
||||
block, cache = extract_file_contents([str(f1), str(f2)], {}, str(tmp_path), max_total=50)
|
||||
|
||||
assert len(block) < 250 # bounded, not both files in full
|
||||
@@ -1,438 +0,0 @@
|
||||
"""Dashboard tab — token usage & cost overview.
|
||||
|
||||
Top: header (period filter + display-currency picker + refresh), then stat
|
||||
cards (total, input, output, cache tokens, and cost per bucket). Unit prices
|
||||
still come from Monitoring's model pricing table (same ``usage.*`` config keys
|
||||
— both screens always agree); the currency picker itself lives HERE, beside
|
||||
refresh. Bottom: a habits summary — which tasks/sessions burn the most tokens,
|
||||
average per prompt, busiest day/hour. Data comes from the local usage log (one
|
||||
event per model turn, recorded by the providers — real server counts when
|
||||
available, ~4 chars/token estimates otherwise).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QGridLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QScrollArea, QTextBrowser, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..core import usage_tracker as ut
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..state import AppContext
|
||||
from ..theme import current_palette
|
||||
from .icons import icon
|
||||
from .spline_chart import SplineChart
|
||||
from .widgets import BudgetCard as _BudgetCard
|
||||
from .widgets import StatCard as _StatCard
|
||||
from .widgets import fmt_tokens as _fmt_tokens
|
||||
|
||||
|
||||
class DashboardTab(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
_PERIODS = ("today", "week", "month", "all")
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
content = QWidget()
|
||||
scroll.setWidget(content)
|
||||
outer.addWidget(scroll)
|
||||
root = QVBoxLayout(content)
|
||||
|
||||
# ---- header: title + the PERIOD FILTER (applies to the WHOLE dashboard —
|
||||
# cards, chart and habits all follow the selected week/month) + refresh
|
||||
self._chart_offset = 0 # 0 = current period; <0 = a past period
|
||||
head = QHBoxLayout()
|
||||
self._title = QLabel()
|
||||
self._title.setStyleSheet("font-weight:700; font-size:15px;")
|
||||
self.chart_prev_btn = QPushButton()
|
||||
self.chart_prev_btn.setIcon(icon("chevron-left"))
|
||||
self.chart_prev_btn.setFixedWidth(30)
|
||||
self.chart_prev_btn.clicked.connect(self._chart_prev)
|
||||
self._chart_period_lbl = QLabel()
|
||||
self._chart_period_lbl.setObjectName("hint")
|
||||
self._chart_period_lbl.setAlignment(Qt.AlignCenter)
|
||||
self._chart_period_lbl.setMinimumWidth(170)
|
||||
self.chart_next_btn = QPushButton()
|
||||
self.chart_next_btn.setIcon(icon("chevron-right"))
|
||||
self.chart_next_btn.setFixedWidth(30)
|
||||
self.chart_next_btn.clicked.connect(self._chart_next)
|
||||
self.gran_combo = QComboBox()
|
||||
for g in ("week", "month", "year"):
|
||||
self.gran_combo.addItem(tr(f"dashboard.gran_{g}"), g)
|
||||
self.gran_combo.currentIndexChanged.connect(self._on_gran_changed)
|
||||
self.metric_combo = QComboBox()
|
||||
for m in ("cost", "tokens"):
|
||||
self.metric_combo.addItem(tr(f"dashboard.metric_{m}"), m)
|
||||
self.metric_combo.currentIndexChanged.connect(self._refresh_chart)
|
||||
# Display-currency picker — moved here from Monitoring's Token Usage
|
||||
# card, right beside refresh; both screens still share the same
|
||||
# usage.currency config key, so changing it here updates everywhere.
|
||||
self.currency_lbl = QLabel()
|
||||
self.currency_lbl.setObjectName("hint")
|
||||
self.currency_combo = QComboBox()
|
||||
for cur in ut.SUPPORTED_CURRENCIES:
|
||||
self.currency_combo.addItem(cur, cur)
|
||||
idx = self.currency_combo.findData(
|
||||
(self.ctx.config.data.get("usage") or {}).get("currency", "USD"))
|
||||
self.currency_combo.setCurrentIndex(max(0, idx))
|
||||
self.currency_combo.currentIndexChanged.connect(self._on_currency_changed)
|
||||
self.refresh_btn = QPushButton("")
|
||||
self.refresh_btn.setIcon(icon("refresh"))
|
||||
self.refresh_btn.setFixedWidth(34)
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
# Two rows, grouped by what the controls do, instead of nine widgets
|
||||
# strung across one line where the title, a date pager, two chart
|
||||
# selectors, a currency picker and Refresh all read as one undifferentiated
|
||||
# strip. Row 1 is "where am I"; row 2 is "what am I looking at".
|
||||
head.addWidget(self._title, 1)
|
||||
head.addWidget(self.refresh_btn)
|
||||
root.addLayout(head)
|
||||
|
||||
controls = QHBoxLayout()
|
||||
controls.setSpacing(6)
|
||||
controls.addWidget(self.chart_prev_btn) # period pager
|
||||
controls.addWidget(self._chart_period_lbl)
|
||||
controls.addWidget(self.chart_next_btn)
|
||||
controls.addSpacing(12)
|
||||
controls.addWidget(self.gran_combo) # what the chart plots
|
||||
controls.addWidget(self.metric_combo)
|
||||
controls.addStretch(1)
|
||||
controls.addWidget(self.currency_lbl) # how money is displayed
|
||||
controls.addWidget(self.currency_combo)
|
||||
root.addLayout(controls)
|
||||
|
||||
# ---- stat cards ---------------------------------------------------
|
||||
# Cost is the headline this screen exists for, so it gets a card twice
|
||||
# the height of the rest instead of being the fifth of five identical
|
||||
# tiles — with six equal cards nothing said which number mattered.
|
||||
cards_grid = QGridLayout()
|
||||
cards_grid.setSpacing(8)
|
||||
self.card_total = _StatCard()
|
||||
self.card_in = _StatCard()
|
||||
self.card_out = _StatCard()
|
||||
self.card_cache = _StatCard()
|
||||
self.card_cost = _StatCard().as_hero()
|
||||
# Hero on the left, spanning both rows; the four supporting figures fill
|
||||
# a 2×2 block beside it.
|
||||
cards_grid.addWidget(self.card_cost, 0, 0, 2, 1)
|
||||
for i, card in enumerate((self.card_total, self.card_in,
|
||||
self.card_out, self.card_cache)):
|
||||
cards_grid.addWidget(card, i // 2, 1 + i % 2)
|
||||
# Budget: remaining/budget, direct entry, auto-warns red past 85% used.
|
||||
self.budget_card = _BudgetCard()
|
||||
self.budget_card.apply_btn.setIcon(icon("check"))
|
||||
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
|
||||
cards_grid.addWidget(self.budget_card, 0, 3, 2, 1)
|
||||
# The hero and Budget columns get more room than the small tiles.
|
||||
for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)):
|
||||
cards_grid.setColumnStretch(col, stretch)
|
||||
root.addLayout(cards_grid)
|
||||
|
||||
# ---- token/cost within the selected period (spline): WEEK → 7 days
|
||||
# (Mon–Sun) · MONTH → weeks W1…Wn · YEAR → 12 months. Dashed lines
|
||||
# compare the previous week / month. ----
|
||||
chart_head = QHBoxLayout()
|
||||
self._chart_title = QLabel()
|
||||
self._chart_title.setStyleSheet("font-weight:600;")
|
||||
chart_head.addWidget(self._chart_title, 1)
|
||||
root.addLayout(chart_head)
|
||||
self.chart = SplineChart()
|
||||
root.addWidget(self.chart)
|
||||
|
||||
# ---- habits summary -------------------------------------------------
|
||||
self._habits_title = QLabel()
|
||||
self._habits_title.setStyleSheet("font-weight:600;")
|
||||
habits_head = QHBoxLayout()
|
||||
self.ai_analyze_btn = QPushButton()
|
||||
self.ai_analyze_btn.setIcon(icon("sparkle"))
|
||||
self.ai_analyze_btn.clicked.connect(self._ai_analyze)
|
||||
# Apply an AI-suggested cost-saving strategy (enable auto-compress + tune
|
||||
# the compression threshold) — only after the user clicks to approve it.
|
||||
self.apply_strategy_btn = QPushButton()
|
||||
self.apply_strategy_btn.setIcon(icon("bolt"))
|
||||
self.apply_strategy_btn.setVisible(False)
|
||||
self.apply_strategy_btn.clicked.connect(self._apply_saving_strategy)
|
||||
habits_head.addWidget(self._habits_title, 1)
|
||||
habits_head.addWidget(self.apply_strategy_btn)
|
||||
habits_head.addWidget(self.ai_analyze_btn)
|
||||
root.addLayout(habits_head)
|
||||
self.habits = QTextBrowser()
|
||||
self.habits.setOpenExternalLinks(False)
|
||||
self.habits.setMinimumHeight(160)
|
||||
root.addWidget(self.habits, 1)
|
||||
# AI recommendations panel (filled by the ✨ button).
|
||||
self._ai_title = QLabel()
|
||||
self._ai_title.setStyleSheet("font-weight:600;")
|
||||
self._ai_title.setVisible(False)
|
||||
root.addWidget(self._ai_title)
|
||||
self.ai_advice = QTextBrowser()
|
||||
self.ai_advice.setOpenExternalLinks(False)
|
||||
self.ai_advice.setMinimumHeight(140)
|
||||
self.ai_advice.setVisible(False)
|
||||
root.addWidget(self.ai_advice, 1)
|
||||
|
||||
# Auto-refresh every 30s so numbers follow ongoing work.
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(30_000)
|
||||
self._timer.timeout.connect(self.refresh)
|
||||
self._timer.start()
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
self.refresh()
|
||||
|
||||
# ---- helpers -----------------------------------------------------------
|
||||
def _pricing(self) -> Dict:
|
||||
from ..core import model_pricing as mp
|
||||
mp.sync_to_usage(self.ctx.config) # cost/total comes straight from the price table
|
||||
return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||||
|
||||
def _on_currency_changed(self, _idx: int) -> None:
|
||||
cur = self.currency_combo.currentData()
|
||||
if not cur:
|
||||
return
|
||||
self.ctx.config.data.setdefault("usage", {})["currency"] = cur
|
||||
self.ctx.save()
|
||||
self.refresh()
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self._title.setText(tr("dashboard.title"))
|
||||
self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip"))
|
||||
self.currency_lbl.setText(tr("monitoring.overview_currency"))
|
||||
self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
self.ai_analyze_btn.setToolTip(tr("dashboard.ai_analyze_tooltip"))
|
||||
self.apply_strategy_btn.setText(tr("dashboard.strategy_btn"))
|
||||
self.apply_strategy_btn.setToolTip(tr("dashboard.strategy_tooltip"))
|
||||
self._habits_title.setText(tr("dashboard.habits_title"))
|
||||
self._chart_title.setText(tr("dashboard.chart_title"))
|
||||
self.chart_prev_btn.setToolTip(tr("dashboard.chart_prev"))
|
||||
self.chart_next_btn.setToolTip(tr("dashboard.chart_next"))
|
||||
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
|
||||
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
|
||||
self.refresh()
|
||||
|
||||
def _apply_budget(self) -> None:
|
||||
"""Persist the spin box's value as the new budget — starts a fresh
|
||||
remaining-balance window (spend before now is no longer counted)."""
|
||||
ccy = (self.ctx.config.data.get("usage") or {}).get("currency", "USD")
|
||||
ut.set_budget(self.ctx.config, self.budget_card.budget_spin.value(), ccy)
|
||||
self.ctx.save()
|
||||
self._refresh_budget()
|
||||
|
||||
def _refresh_budget(self) -> None:
|
||||
from ..core import model_pricing as mp
|
||||
pricing = self._pricing()
|
||||
status = ut.budget_status(self.ctx.config)
|
||||
if status is None:
|
||||
self.budget_card.set(tr("usage.budget_title"), "—", tr("usage.budget_no_budget"))
|
||||
self.budget_card.budget_spin.setValue(0.0)
|
||||
return
|
||||
remaining_disp = mp.convert(status["remaining_usd"], "USD",
|
||||
pricing.get("currency", "USD"), self.ctx.config)
|
||||
amount_disp = mp.convert(status["amount_usd"], "USD",
|
||||
pricing.get("currency", "USD"), self.ctx.config)
|
||||
value = (f"{ut.format_cost(status['remaining_usd'], pricing, digits=2)}"
|
||||
f" / {ut.format_cost(status['amount_usd'], pricing, digits=2)}")
|
||||
pct = int(round(status["pct_used"] * 100))
|
||||
sub = tr("usage.budget_over_warning") if status["over_85"] else tr("usage.budget_used_pct", pct=pct)
|
||||
self.budget_card.set(tr("usage.budget_title"), value, sub, warn=status["over_85"])
|
||||
# keep the entry field showing the CURRENT budget (in display currency) —
|
||||
# only when it doesn't already have unsaved focus/edits from the user.
|
||||
if not self.budget_card.budget_spin.hasFocus():
|
||||
self.budget_card.budget_spin.setValue(round(amount_disp, 2))
|
||||
|
||||
def _period_range(self):
|
||||
"""The SELECTED period as an inclusive (start, end) date range — drives
|
||||
the whole dashboard (cards, chart, habits)."""
|
||||
gran = self.gran_combo.currentData() or "week"
|
||||
start, end = ut.period_bounds(gran, self._chart_offset)
|
||||
return start, end - timedelta(days=1) # load_events end is inclusive
|
||||
|
||||
def _on_gran_changed(self, *_a) -> None:
|
||||
self._chart_offset = 0 # period size changed → back to current
|
||||
self.refresh() # the filter drives the WHOLE dashboard
|
||||
|
||||
def _chart_prev(self) -> None:
|
||||
self._chart_offset -= 1 # page one period into the past
|
||||
self.refresh()
|
||||
|
||||
def _chart_next(self) -> None:
|
||||
self._chart_offset = min(0, self._chart_offset + 1) # never past the present
|
||||
self.refresh()
|
||||
|
||||
@staticmethod
|
||||
def _delta_txt(cur: float, prev: float) -> str:
|
||||
"""▲/▼ percent change of ``cur`` vs ``prev`` (empty if no baseline)."""
|
||||
if not prev:
|
||||
return ""
|
||||
pct = (cur - prev) / prev * 100
|
||||
arrow = "▲" if pct > 0.5 else ("▼" if pct < -0.5 else "•")
|
||||
return f"{arrow}{abs(pct):.0f}%"
|
||||
|
||||
def _refresh_chart(self, *_a) -> None:
|
||||
"""Break the SELECTED period into its parts: WEEK → 7 days (Mon–Sun) ·
|
||||
MONTH → weeks W1…Wn · YEAR → 12 months. Dashed lines mark the previous
|
||||
week's / month's average per point with the % change of the totals."""
|
||||
if not hasattr(self, "chart"):
|
||||
return
|
||||
gran = self.gran_combo.currentData() or "week"
|
||||
metric = self.metric_combo.currentData() or "cost"
|
||||
events = ut.load_events() # all events; breakdown slices by period
|
||||
pricing = self._pricing()
|
||||
parts = ut.period_breakdown(events, gran, pricing, offset=self._chart_offset)
|
||||
mi = 0 if metric == "tokens" else 1 # (label, tokens, cost) → +1 for the value
|
||||
pts = [(row[0], float(row[mi + 1])) for row in parts]
|
||||
# Compact cost format (2 decimals, K/M above 1,000/1,000,000) — the
|
||||
# chart's y-axis label box is narrow; format_cost's full precision (up
|
||||
# to 4 decimals for USD) overflowed it, clipping/obscuring the amount.
|
||||
fmt = _fmt_tokens if metric == "tokens" else (lambda v: ut.format_cost_compact(v, pricing))
|
||||
|
||||
# One dashed comparison line that FOLLOWS the filter: the selected period
|
||||
# vs the previous SAME-granularity one — "Last week" in week view,
|
||||
# "Last month" in month view, "Last year" in year view. Drawn at the
|
||||
# previous period's average per point so it sits on-scale; the label shows
|
||||
# the % change of the period totals.
|
||||
cur = ut.period_totals(events, gran, pricing, self._chart_offset)
|
||||
prev = ut.period_totals(events, gran, pricing, self._chart_offset - 1)
|
||||
ref_key = {"week": "dashboard.ref_last_week",
|
||||
"month": "dashboard.ref_last_month",
|
||||
"year": "dashboard.ref_last_year"}.get(gran, "dashboard.ref_last_week")
|
||||
n_points = max(1, len(parts))
|
||||
refs = []
|
||||
if prev[mi] > 0:
|
||||
# Muted on purpose: the comparison line is a reference, not the
|
||||
# series — it must not compete with the accent-coloured spline.
|
||||
refs.append((prev[mi] / n_points,
|
||||
f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}",
|
||||
current_palette().text_muted))
|
||||
self.chart.set_reference_lines(refs)
|
||||
self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}"))
|
||||
self._chart_period_lbl.setText(ut.period_range_label(gran, self._chart_offset))
|
||||
self.chart_next_btn.setEnabled(self._chart_offset < 0)
|
||||
|
||||
# ---- main refresh --------------------------------------------------------
|
||||
def refresh(self) -> None:
|
||||
start, end = self._period_range()
|
||||
events = ut.load_events(start, end)
|
||||
|
||||
s = ut.summarize(events)
|
||||
pricing = self._pricing()
|
||||
costs = ut.cost_usd_events(events, pricing) # honors the per-model price table
|
||||
total_cost = sum(costs.values())
|
||||
|
||||
est_note = (tr("dashboard.estimated_note", pct=int(s["estimated_share"] * 100))
|
||||
if s["estimated_share"] > 0 else "")
|
||||
self.card_total.set(tr("dashboard.card_total"), _fmt_tokens(s["total"]),
|
||||
tr("dashboard.card_turns", n=s["turns"]))
|
||||
self.card_in.set(tr("dashboard.card_in"), _fmt_tokens(s["in"]),
|
||||
ut.format_cost(costs["in"], pricing))
|
||||
self.card_out.set(tr("dashboard.card_out"), _fmt_tokens(s["out"]),
|
||||
ut.format_cost(costs["out"], pricing))
|
||||
self.card_cache.set(tr("dashboard.card_cache"), _fmt_tokens(s["cache"]),
|
||||
ut.format_cost(costs["cache"], pricing))
|
||||
self.card_cost.set(tr("dashboard.card_cost"),
|
||||
ut.format_cost(total_cost, pricing, digits=2), est_note)
|
||||
|
||||
# ---- habits -----------------------------------------------------------
|
||||
lines: List[str] = []
|
||||
if not events:
|
||||
lines.append(f"<i>{tr('dashboard.no_data')}</i>")
|
||||
else:
|
||||
lines.append(f"<b>{tr('dashboard.h_top')}</b>")
|
||||
lines.append("<ol>")
|
||||
for label, tok in s["top_labels"]:
|
||||
pct = int(tok * 100 / s["total"]) if s["total"] else 0
|
||||
lines.append(f"<li>{label[:60]} — {_fmt_tokens(tok)} tokens ({pct}%)</li>")
|
||||
lines.append("</ol>")
|
||||
src_parts = ", ".join(
|
||||
f"{tr(f'app.tab.{k}') if k in ('cowork', 'code') else k}: {_fmt_tokens(v)}"
|
||||
for k, v in s["by_source"])
|
||||
lines.append(f"<b>{tr('dashboard.h_by_source')}</b>: {src_parts}<br>")
|
||||
lines.append(f"<b>{tr('dashboard.h_avg')}</b>: "
|
||||
f"{_fmt_tokens(s['avg_per_turn'])} tokens<br>")
|
||||
if s["busiest_day"]:
|
||||
lines.append(f"<b>{tr('dashboard.h_busiest_day')}</b>: {s['busiest_day']}<br>")
|
||||
if s["busiest_hour"] is not None:
|
||||
lines.append(f"<b>{tr('dashboard.h_busiest_hour')}</b>: "
|
||||
f"{s['busiest_hour']:02d}:00–{s['busiest_hour']:02d}:59<br>")
|
||||
if s["estimated_share"] > 0:
|
||||
lines.append(f"<i>{tr('dashboard.estimated_note', pct=int(s['estimated_share'] * 100))}</i>")
|
||||
self.habits.setHtml("".join(lines))
|
||||
self._refresh_chart()
|
||||
self._refresh_budget()
|
||||
|
||||
def _apply_saving_strategy(self) -> None:
|
||||
"""Apply an AI-suggested cost-saving strategy AFTER the user approves:
|
||||
turn on auto-compress and compress earlier (lower threshold) + compress
|
||||
content before sending it to the agent — cutting tokens on every turn."""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
if QMessageBox.question(self, tr("dashboard.strategy_title"),
|
||||
tr("dashboard.strategy_confirm")) != QMessageBox.Yes:
|
||||
return
|
||||
cx = self.ctx.config.data.setdefault("context", {})
|
||||
cx["auto_compact"] = True
|
||||
cx["compact_threshold"] = 0.6 # compress at 60% of the window (was ~80%)
|
||||
cx["compress_before_send"] = True # digest context before each turn
|
||||
self.ctx.save()
|
||||
self.status_message.emit(tr("dashboard.strategy_applied"))
|
||||
|
||||
# ---- AI habits analysis ----------------------------------------------------
|
||||
def _ai_analyze(self) -> None:
|
||||
"""✨ Send the aggregated numbers (never raw prompt text) to the active
|
||||
provider and show habit feedback + token-saving recommendations."""
|
||||
if getattr(self, "_ai_worker", None) is not None:
|
||||
return
|
||||
start, end = self._period_range()
|
||||
events = ut.load_events(start, end)
|
||||
if not events:
|
||||
self.status_message.emit(tr("dashboard.no_data"))
|
||||
return
|
||||
summary = ut.summarize(events)
|
||||
self.ai_analyze_btn.setEnabled(False)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyzing"))
|
||||
ctx = self.ctx
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from ..i18n import get_language
|
||||
|
||||
prompt = ut.build_ai_analysis_prompt(summary, get_language())
|
||||
provider = ctx.build_active_provider()
|
||||
reply = provider.chat([{"role": "user", "content": prompt}],
|
||||
cancel=worker.stop_event)
|
||||
return {"text": (reply.get("content") or "").strip()}
|
||||
|
||||
def done(result: dict) -> None:
|
||||
self._ai_worker = None
|
||||
self.ai_analyze_btn.setEnabled(True)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
text = result.get("text") or ""
|
||||
if text:
|
||||
self._ai_title.setText(tr("dashboard.ai_advice_title"))
|
||||
self._ai_title.setVisible(True)
|
||||
self.ai_advice.setMarkdown(text)
|
||||
self.ai_advice.setVisible(True)
|
||||
self.apply_strategy_btn.setVisible(True) # offer to apply the saving strategy
|
||||
|
||||
def failed(err: str) -> None:
|
||||
self._ai_worker = None
|
||||
self.ai_analyze_btn.setEnabled(True)
|
||||
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
|
||||
self.status_message.emit(str(err))
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(done)
|
||||
w.failed.connect(failed)
|
||||
self._ai_worker = w
|
||||
w.start()
|
||||
-1586
File diff suppressed because it is too large
Load Diff
@@ -1,794 +0,0 @@
|
||||
"""Schedule Task tab — Kanban board for scheduled/automated tasks.
|
||||
|
||||
Columns: Backlog / Scheduled / Running / Waiting Input / Done / Failed /
|
||||
Paused. Cards drag between columns (dropping = changing status), double-click
|
||||
edits, right-click offers Run now / Edit / Duplicate / Pause / Delete / View
|
||||
logs / Create-next-from-output. Header has search, a type filter, Add Task
|
||||
and AI Create Task (preview first — nothing is created until confirmed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout,
|
||||
QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox,
|
||||
QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget,
|
||||
QTabBar, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..core import tasks as taskrepo
|
||||
from ..core.projects import list_projects
|
||||
from ..core.tasks import STATUSES, chain_error, duplicate_task, new_task
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..state import AppContext
|
||||
from ..theme import current_palette
|
||||
from .calendar_view import CalendarView
|
||||
from .icons import icon
|
||||
from .osutil import open_path
|
||||
|
||||
_VIEWS = ("kanban", "calendar")
|
||||
|
||||
# Priority shown as a plain text tag (no colored-emoji squares). Only the
|
||||
# elevated priorities get a visible marker; low/medium stay unmarked as before.
|
||||
_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"}
|
||||
|
||||
|
||||
class _KanbanColumn(QListWidget):
|
||||
"""One status lane. Accepts drops from sibling columns; a drop means
|
||||
'move this task to my status'."""
|
||||
|
||||
task_dropped = Signal(str, str) # task_id, new_status
|
||||
|
||||
def __init__(self, status: str):
|
||||
super().__init__()
|
||||
self.status = status
|
||||
self.setDragDropMode(QAbstractItemView.DragDrop)
|
||||
self.setDefaultDropAction(Qt.MoveAction)
|
||||
# Shift/Ctrl-click several cards in the SAME column, then right-click
|
||||
# → "Delete N selected" to bulk-remove tasks instead of one at a time.
|
||||
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||
self.setWordWrap(True)
|
||||
# Cards wrap, so there is never anything to reach by scrolling sideways
|
||||
# — but QListWidget's own column hint runs 1-6px past the viewport, and
|
||||
# a lane sprouted a horizontal scrollbar at 36 of 38 window widths I
|
||||
# measured. Which lanes grew one changed with the width, which is why it
|
||||
# looked like it depended on the screen.
|
||||
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self.setResizeMode(QListWidget.Adjust) # re-wrap on every resize
|
||||
# No pixel floor here. A fixed one is always wrong on some screen:
|
||||
# 190 lost the seventh lane, 150 still wanted 1242px where a 1280
|
||||
# window leaves 1091 — so the 1280 monitor scrolled sideways and the
|
||||
# 1920 one did not, same app, same build. The board divides whatever
|
||||
# width it has by seven instead; see _fit_lanes().
|
||||
|
||||
def dropEvent(self, event): # noqa: N802
|
||||
source = event.source()
|
||||
if isinstance(source, _KanbanColumn) and source is not self:
|
||||
item = source.currentItem()
|
||||
tid = item.data(Qt.UserRole) if item else None
|
||||
if tid:
|
||||
event.acceptProposedAction()
|
||||
self.task_dropped.emit(tid, self.status)
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
|
||||
class ScheduleTaskTab(QWidget):
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext, scheduler=None):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self.scheduler = scheduler # TaskScheduler (may be None in tests)
|
||||
self._ai_worker: Optional[AgentWorker] = None
|
||||
self._tasks_dir: Optional[Path] = None # None → default repo dir
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
# ---- header ----------------------------------------------------
|
||||
header = QHBoxLayout()
|
||||
self._title = QLabel()
|
||||
self._title.setStyleSheet("font-weight:700; font-size:15px;")
|
||||
self.counts_lbl = QLabel("")
|
||||
self.counts_lbl.setObjectName("hint")
|
||||
# A one-line summary of every lane's count. Left to size itself it
|
||||
# reported a sizeHint wide enough to set the MINIMUM width of the whole
|
||||
# screen — 1285px at 150% scaling, which then became the window's
|
||||
# minimum and stopped the app fitting a 1280px laptop. It is a summary,
|
||||
# and the same numbers are on each lane header, so it gives way first.
|
||||
self.counts_lbl.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred)
|
||||
self.counts_lbl.setMinimumWidth(0)
|
||||
self.add_btn = QPushButton()
|
||||
self.add_btn.setIcon(icon("plus"))
|
||||
self.add_btn.setObjectName("primary")
|
||||
self.add_btn.clicked.connect(self._add_task)
|
||||
self.ai_btn = QPushButton()
|
||||
self.ai_btn.setIcon(icon("sparkle"))
|
||||
self.ai_btn.clicked.connect(self._ai_create)
|
||||
# Two views of the same tasks, so they read as a pair of tabs rather
|
||||
# than a drop-list you have to open to discover the Calendar exists.
|
||||
self.view_tabs = QTabBar()
|
||||
self.view_tabs.setObjectName("viewTabs")
|
||||
self.view_tabs.setDrawBase(False)
|
||||
self.view_tabs.setExpanding(False)
|
||||
for _v in _VIEWS:
|
||||
self.view_tabs.addTab("")
|
||||
self.view_tabs.currentChanged.connect(self._on_view_changed)
|
||||
header.addWidget(self._title)
|
||||
header.addWidget(self.counts_lbl, 1)
|
||||
header.addWidget(self.view_tabs)
|
||||
header.addWidget(self.add_btn)
|
||||
header.addWidget(self.ai_btn)
|
||||
root.addLayout(header)
|
||||
|
||||
# ---- board / calendar (two views of the SAME tasks) -----------------
|
||||
self._view_stack = QStackedWidget()
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
board = QWidget()
|
||||
scroll.setWidget(board)
|
||||
cols = QHBoxLayout(board)
|
||||
# Gutters wide enough to read as a break between lanes without eating
|
||||
# too much of the seven-way split — they still share the board equally
|
||||
# (see _fit_lanes below), so a wider gutter narrows every lane by the
|
||||
# same share automatically; nothing else to compute here.
|
||||
cols.setSpacing(2)
|
||||
self.columns: Dict[str, _KanbanColumn] = {}
|
||||
self.column_headers: Dict[str, QLabel] = {}
|
||||
for status in STATUSES:
|
||||
box = QVBoxLayout()
|
||||
# The per-lane holder's own margins were the style's default
|
||||
# (~9px a side) on top of the inter-column gap — with seven lanes
|
||||
# that outweighs the gap itself. Zero it out and let the lane's
|
||||
# header/list fill the width _fit_lanes() hands them.
|
||||
box.setContentsMargins(0, 0, 0, 0)
|
||||
box.setSpacing(2)
|
||||
head = QLabel()
|
||||
head.setStyleSheet("font-weight:600;")
|
||||
col = _KanbanColumn(status)
|
||||
col.setObjectName("kanbanLane")
|
||||
col.task_dropped.connect(self._on_task_dropped)
|
||||
col.itemDoubleClicked.connect(self._on_double_click)
|
||||
col.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
col.customContextMenuRequested.connect(
|
||||
lambda pos, c=col: self._context_menu(c, pos))
|
||||
box.addWidget(head)
|
||||
box.addWidget(col, 1)
|
||||
holder = QWidget()
|
||||
holder.setLayout(box)
|
||||
cols.addWidget(holder)
|
||||
self.columns[status] = col
|
||||
self.column_headers[status] = head
|
||||
self._board_scroll = scroll
|
||||
self._board_gap = cols.spacing()
|
||||
scroll.viewport().installEventFilter(self)
|
||||
self._view_stack.addWidget(scroll)
|
||||
self.calendar = CalendarView()
|
||||
self.calendar.edit_task.connect(self._edit_task)
|
||||
self.calendar.add_task_on_date.connect(self._add_task_on_date)
|
||||
self._view_stack.addWidget(self.calendar)
|
||||
root.addWidget(self._view_stack, 1)
|
||||
|
||||
if self.scheduler is not None:
|
||||
self.scheduler.tasks_changed.connect(self.refresh)
|
||||
self.scheduler.task_started.connect(lambda _tid: self.refresh())
|
||||
self.scheduler.task_finished.connect(lambda _tid, _ok: self.refresh())
|
||||
|
||||
# Belt-and-braces: also re-read the board every 10s so a card's lane
|
||||
# ALWAYS reflects reality (Scheduled → Running → Done) even if some
|
||||
# change slipped past the signals (e.g. task files edited externally).
|
||||
from PySide6.QtCore import QTimer
|
||||
self._refresh_timer = QTimer(self)
|
||||
self._refresh_timer.setInterval(10_000)
|
||||
self._refresh_timer.timeout.connect(self.refresh)
|
||||
self._refresh_timer.start()
|
||||
|
||||
self.refresh()
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
# ---- i18n ------------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
self._title.setText(tr("schedtask.title"))
|
||||
self.add_btn.setText(tr("schedtask.add_btn"))
|
||||
self.add_btn.setToolTip(tr("schedtask.add_tooltip"))
|
||||
self.ai_btn.setText(tr("schedtask.ai_btn"))
|
||||
self.ai_btn.setToolTip(tr("schedtask.ai_tooltip"))
|
||||
for i, v in enumerate(_VIEWS):
|
||||
self.view_tabs.setTabText(i, tr(f"schedtask.view.{v}"))
|
||||
for status, col in self.columns.items():
|
||||
col.setToolTip(tr(f"schedtask.col_tip.{status}"))
|
||||
self.refresh()
|
||||
|
||||
# ---- Kanban / Calendar view switch --------------------------------
|
||||
def _on_view_changed(self) -> None:
|
||||
self._view_stack.setCurrentIndex(self.view_tabs.currentIndex())
|
||||
|
||||
def _add_task_on_date(self, date_str: str) -> None:
|
||||
"""Create a task pre-filled with the clicked calendar date (default
|
||||
09:00) — same editor Add Task opens, nothing is saved until confirmed."""
|
||||
from .task_editor_dialog import TaskEditorDialog
|
||||
|
||||
t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"})
|
||||
dlg = TaskEditorDialog(t, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
self.status_message.emit(tr("schedtask.msg_created"))
|
||||
|
||||
# ---- lane widths ------------------------------------------------------
|
||||
#
|
||||
# The seven lanes share the board equally — that is the layout's stretch
|
||||
# doing the work, so the split is a proportion of whatever width there is,
|
||||
# on any monitor. The only pixel question left is how narrow a lane may get
|
||||
# before scrolling sideways beats squeezing, and that is a question about
|
||||
# TEXT: roughly eight characters of a task title plus its padding. Reading
|
||||
# it off the font keeps it right at 125%/150% scaling and at a user's own
|
||||
# font size, where a constant would not be.
|
||||
_LANE_FLOOR_CH = 8
|
||||
|
||||
def eventFilter(self, obj, event): # noqa: N802
|
||||
from PySide6.QtCore import QEvent
|
||||
|
||||
if obj is self._board_scroll.viewport() and event.type() == QEvent.Resize:
|
||||
self._fit_lanes()
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def _fit_lanes(self) -> None:
|
||||
floor = self.fontMetrics().averageCharWidth() * self._LANE_FLOOR_CH + 24
|
||||
for col in self.columns.values():
|
||||
if col.minimumWidth() != floor:
|
||||
col.setMinimumWidth(floor)
|
||||
|
||||
# ---- board rendering ---------------------------------------------------
|
||||
def _card_text(self, t: dict) -> str:
|
||||
prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "")
|
||||
ai = "[AI] " if t.get("is_ai_generated") else ""
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else None
|
||||
when_line = when or tr("schedtask.no_schedule")
|
||||
chain = ""
|
||||
if t.get("dependency", {}).get("next_task_id") or t.get("dependency", {}).get("previous_task_id"):
|
||||
chain = " (linked)"
|
||||
last = t.get("logs", {}).get("last_status")
|
||||
last_line = {"success": tr("schedtask.last_success"),
|
||||
"failed": tr("schedtask.last_failed")}.get(last, tr("schedtask.last_never"))
|
||||
# Card shows ONLY the task's own title (plus the [AI] marker and chain
|
||||
# note) — no "[Cowork]"/"[Code]" task-type tag cluttering it.
|
||||
return (f"{ai}{t.get('title', '')}{chain}\n"
|
||||
f"{when_line} {prio}\n{last_line}")
|
||||
|
||||
def refresh(self) -> None:
|
||||
all_tasks = taskrepo.list_tasks(self._tasks_dir)
|
||||
counts = {s: 0 for s in STATUSES}
|
||||
for col in self.columns.values():
|
||||
col.clear()
|
||||
for t in all_tasks:
|
||||
status = t.get("status", "backlog")
|
||||
if status not in self.columns:
|
||||
continue
|
||||
counts[status] += 1
|
||||
item = QListWidgetItem(self._card_text(t))
|
||||
item.setData(Qt.UserRole, t["task_id"])
|
||||
self.columns[status].addItem(item)
|
||||
pal = current_palette()
|
||||
for status, col in self.columns.items():
|
||||
self.column_headers[status].setText(
|
||||
f"{tr(f'schedtask.status.{status}')} ({counts[status]})")
|
||||
# Dropping a card into Running STARTS the task for real, so that
|
||||
# lane is outlined while it holds anything — the one column here
|
||||
# with a side effect should not look like the other six.
|
||||
if status == "running" and counts[status]:
|
||||
col.setStyleSheet(
|
||||
f"border: 1px solid {pal.warning}; border-radius: {pal.radius}px;")
|
||||
self.column_headers[status].setStyleSheet(
|
||||
f"font-weight:600; color: {pal.warning};")
|
||||
else:
|
||||
col.setStyleSheet("")
|
||||
self.column_headers[status].setStyleSheet("font-weight:600;")
|
||||
if col.count() == 0:
|
||||
empty = QListWidgetItem(tr("schedtask.no_tasks"))
|
||||
empty.setFlags(Qt.NoItemFlags)
|
||||
col.addItem(empty)
|
||||
summary = " ".join(
|
||||
f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s])
|
||||
self.counts_lbl.setText(summary)
|
||||
self.counts_lbl.setToolTip(summary) # full text stays reachable if clipped
|
||||
self.calendar.set_tasks(all_tasks)
|
||||
|
||||
# ---- actions --------------------------------------------------------
|
||||
def _save_and_refresh(self, task: dict) -> None:
|
||||
taskrepo.save_task(task, self._tasks_dir)
|
||||
self.refresh()
|
||||
|
||||
def _add_task(self) -> None:
|
||||
from .task_editor_dialog import TaskEditorDialog
|
||||
|
||||
dlg = TaskEditorDialog(None, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
self.status_message.emit(tr("schedtask.msg_created"))
|
||||
|
||||
def _edit_task(self, task_id: str) -> None:
|
||||
from .task_editor_dialog import TaskEditorDialog
|
||||
|
||||
task = taskrepo.load_task(task_id, self._tasks_dir)
|
||||
if not task:
|
||||
return
|
||||
dlg = TaskEditorDialog(task, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx)
|
||||
if dlg.exec() and dlg.edited_task:
|
||||
self._save_and_refresh(dlg.edited_task)
|
||||
|
||||
def _on_double_click(self, item: QListWidgetItem) -> None:
|
||||
tid = item.data(Qt.UserRole)
|
||||
if tid:
|
||||
self._edit_task(tid)
|
||||
|
||||
def _on_task_dropped(self, task_id: str, new_status: str) -> None:
|
||||
"""Dropping a card into a lane ACTS on the task, not just relabels it:
|
||||
→ Running actually runs it now; → Done marks it completed; → Scheduled
|
||||
puts it on the calendar (opening the editor if no time is set yet)."""
|
||||
task = taskrepo.load_task(task_id, self._tasks_dir)
|
||||
if not task:
|
||||
return
|
||||
if task.get("status") == "running":
|
||||
self.refresh() # can't drag a running task
|
||||
return
|
||||
if new_status == "running":
|
||||
# Dropping into Running = "run it now" (counts as manual approval).
|
||||
self.refresh()
|
||||
self._run_now(task)
|
||||
return
|
||||
if new_status == "done":
|
||||
task["status"] = "done"
|
||||
task["schedule"]["enabled"] = False # done by hand → don't re-fire
|
||||
self._save_and_refresh(task)
|
||||
return
|
||||
task["status"] = new_status
|
||||
if new_status == "scheduled" and not task["schedule"].get("enabled"):
|
||||
if task["schedule"].get("run_at"):
|
||||
task["schedule"]["enabled"] = True
|
||||
else:
|
||||
# No time set yet — a silently-disabled "Scheduled" card would
|
||||
# never run and look broken. Open the editor so the user sets
|
||||
# the schedule right away.
|
||||
self._save_and_refresh(task)
|
||||
self.status_message.emit(tr("schedtask.msg_set_schedule"))
|
||||
self._edit_task(task_id)
|
||||
return
|
||||
self._save_and_refresh(task)
|
||||
|
||||
@staticmethod
|
||||
def _is_multi_selection(item, selected) -> bool:
|
||||
"""True when the right-clicked card is part of an existing multi-item
|
||||
selection — pure boolean, kept separate from _context_menu so it's
|
||||
testable without ever invoking Qt's (modal, event-loop-blocking) menu."""
|
||||
return len(selected) > 1 and item in selected
|
||||
|
||||
def _context_menu(self, col: _KanbanColumn, pos) -> None:
|
||||
item = col.itemAt(pos)
|
||||
if item is None or not item.data(Qt.UserRole):
|
||||
return
|
||||
selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)]
|
||||
if self._is_multi_selection(item, selected):
|
||||
self._bulk_delete_menu(col, pos, selected)
|
||||
return
|
||||
tid = item.data(Qt.UserRole)
|
||||
task = taskrepo.load_task(tid, self._tasks_dir)
|
||||
if not task:
|
||||
return
|
||||
menu = QMenu(col)
|
||||
run_act = menu.addAction(tr("schedtask.menu_run"))
|
||||
edit_act = menu.addAction(tr("schedtask.menu_edit"))
|
||||
dup_act = menu.addAction(tr("schedtask.menu_duplicate"))
|
||||
paused = task.get("status") == "paused"
|
||||
pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause"))
|
||||
logs_act = menu.addAction(tr("schedtask.menu_logs"))
|
||||
hist_act = menu.addAction(tr("schedtask.menu_history"))
|
||||
next_act = menu.addAction(tr("schedtask.menu_create_next"))
|
||||
menu.addSeparator()
|
||||
del_act = menu.addAction(tr("schedtask.menu_delete"))
|
||||
chosen = menu.exec(col.viewport().mapToGlobal(pos))
|
||||
if chosen == run_act:
|
||||
self._run_now(task)
|
||||
elif chosen == edit_act:
|
||||
self._edit_task(tid)
|
||||
elif chosen == dup_act:
|
||||
self._save_and_refresh(duplicate_task(task))
|
||||
elif chosen == pause_act:
|
||||
task["status"] = "backlog" if paused else "paused"
|
||||
self._save_and_refresh(task)
|
||||
elif chosen == logs_act:
|
||||
self._view_logs(task)
|
||||
elif chosen == hist_act:
|
||||
_RunHistoryDialog(task, self).exec()
|
||||
elif chosen == next_act:
|
||||
self._create_next_from_output(task)
|
||||
elif chosen == del_act:
|
||||
if QMessageBox.question(self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_confirm", title=task.get("title", ""))
|
||||
) == QMessageBox.Yes:
|
||||
taskrepo.delete_task(tid, self._tasks_dir)
|
||||
self.refresh()
|
||||
|
||||
def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None:
|
||||
"""Right-click on a multi-selection within one column (Shift/Ctrl-click
|
||||
several cards first): one action deletes every selected task. The
|
||||
popup itself is a thin wrapper — see _confirm_and_delete_selected for
|
||||
the actual (independently testable) confirm+delete logic."""
|
||||
menu = QMenu(col)
|
||||
del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected)))
|
||||
chosen = menu.exec(col.viewport().mapToGlobal(pos))
|
||||
if chosen == del_act:
|
||||
self._confirm_and_delete_selected(selected)
|
||||
|
||||
def _confirm_and_delete_selected(self, selected) -> bool:
|
||||
"""Confirm, then delete every task in ``selected``. Split out of
|
||||
_bulk_delete_menu so tests can drive it directly without having to
|
||||
fake a real (modal, event-loop-blocking) QMenu popup."""
|
||||
if QMessageBox.question(
|
||||
self, tr("schedtask.menu_delete"),
|
||||
tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
|
||||
return False
|
||||
for item in selected:
|
||||
tid = item.data(Qt.UserRole)
|
||||
if tid:
|
||||
taskrepo.delete_task(tid, self._tasks_dir)
|
||||
self.refresh()
|
||||
return True
|
||||
|
||||
def _run_now(self, task: dict) -> None:
|
||||
if task.get("task_type") == "manual":
|
||||
self.status_message.emit(tr("schedtask.msg_manual_norun"))
|
||||
return
|
||||
if self.scheduler is None:
|
||||
self.status_message.emit(tr("schedtask.msg_no_scheduler"))
|
||||
return
|
||||
if self.scheduler.run_now(task["task_id"]):
|
||||
self.status_message.emit(tr("schedtask.msg_running", title=task.get("title", "")))
|
||||
self.refresh()
|
||||
|
||||
def _view_logs(self, task: dict) -> None:
|
||||
run_id = task.get("logs", {}).get("last_run_id")
|
||||
if not run_id:
|
||||
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
|
||||
return
|
||||
folder = taskrepo.ARTIFACTS_DIR / task["task_id"] / run_id
|
||||
if folder.exists():
|
||||
open_path(str(folder))
|
||||
else:
|
||||
QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet"))
|
||||
|
||||
def _create_next_from_output(self, task: dict) -> None:
|
||||
"""Scaffold a follow-up task pre-wired to consume this task's output."""
|
||||
nxt = new_task(tr("schedtask.next_of", title=task.get("title", "")))
|
||||
nxt["task_type"] = "cowork"
|
||||
nxt["input"]["mode"] = "previous_task_output"
|
||||
nxt["input"]["previous_task_id"] = task["task_id"]
|
||||
nxt["dependency"]["previous_task_id"] = task["task_id"]
|
||||
err = chain_error(taskrepo.list_tasks(self._tasks_dir) + [nxt],
|
||||
task["task_id"], nxt["task_id"])
|
||||
if err:
|
||||
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
|
||||
return
|
||||
taskrepo.save_task(nxt, self._tasks_dir)
|
||||
task["dependency"]["next_task_id"] = nxt["task_id"]
|
||||
task["dependency"]["pass_output_to_next"] = True
|
||||
if task["dependency"].get("run_next_mode", "none") == "none":
|
||||
task["dependency"]["run_next_mode"] = "run_after_success"
|
||||
taskrepo.save_task(task, self._tasks_dir)
|
||||
self.refresh()
|
||||
self._edit_task(nxt["task_id"])
|
||||
|
||||
# ---- AI create ----------------------------------------------------------
|
||||
def _ai_create(self) -> None:
|
||||
dlg = _AiCreateDialog(self.ctx, self)
|
||||
if dlg.exec() and dlg.created_tasks:
|
||||
for t in dlg.created_tasks:
|
||||
taskrepo.save_task(t, self._tasks_dir)
|
||||
self.refresh()
|
||||
self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks)))
|
||||
|
||||
|
||||
class _RunHistoryDialog(QDialog):
|
||||
"""Run history of one task as a table (newest first): time, status, error;
|
||||
double-click a row to open that run's artifact folder."""
|
||||
|
||||
def __init__(self, task: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self._task = task
|
||||
self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}")
|
||||
self.resize(620, 380)
|
||||
root = QVBoxLayout(self)
|
||||
hint = QLabel(tr("schedtask.hist_hint"))
|
||||
hint.setObjectName("hint")
|
||||
root.addWidget(hint)
|
||||
|
||||
runs = list(reversed(task.get("runs", []) or []))
|
||||
self.table = QTableWidget(len(runs), 4)
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"),
|
||||
tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"),
|
||||
])
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
for row, run in enumerate(runs):
|
||||
ok = run.get("status") == "success"
|
||||
cells = (
|
||||
run.get("finished_at", ""),
|
||||
str(run.get("status", "")),
|
||||
run.get("run_id", ""),
|
||||
(run.get("error") or "")[:200],
|
||||
)
|
||||
for col, text in enumerate(cells):
|
||||
item = QTableWidgetItem(str(text))
|
||||
if col == 0:
|
||||
item.setData(Qt.UserRole, run.get("run_id", ""))
|
||||
self.table.setItem(row, col, item)
|
||||
self.table.resizeColumnsToContents()
|
||||
self.table.horizontalHeader().setStretchLastSection(True)
|
||||
self.table.itemDoubleClicked.connect(self._open_artifact)
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
buttons.rejected.connect(self.reject)
|
||||
buttons.accepted.connect(self.accept)
|
||||
root.addWidget(buttons)
|
||||
|
||||
def _open_artifact(self, item: QTableWidgetItem) -> None:
|
||||
first = self.table.item(item.row(), 0)
|
||||
run_id = first.data(Qt.UserRole) if first else ""
|
||||
if not run_id:
|
||||
return
|
||||
folder = taskrepo.ARTIFACTS_DIR / self._task["task_id"] / run_id
|
||||
if folder.exists():
|
||||
open_path(str(folder))
|
||||
|
||||
|
||||
class _DropZone(QLabel):
|
||||
"""Drag-an-.xlsx-here area for the Import tab."""
|
||||
|
||||
file_dropped = Signal(str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setAlignment(Qt.AlignCenter)
|
||||
self.setMinimumHeight(70)
|
||||
_p = current_palette()
|
||||
self.setStyleSheet(
|
||||
f"QLabel {{ border: 1px dashed {_p.border_strong};"
|
||||
f" border-radius: {_p.radius_lg}px;"
|
||||
f" color: {_p.text_muted}; padding: 10px; }}")
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
def dragEnterEvent(self, event): # noqa: N802
|
||||
urls = event.mimeData().urls()
|
||||
if urls and urls[0].toLocalFile().lower().endswith(
|
||||
(".xlsx", ".xlsm", ".xls", ".csv", ".json")):
|
||||
event.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, event): # noqa: N802
|
||||
urls = event.mimeData().urls()
|
||||
if urls:
|
||||
self.file_dropped.emit(urls[0].toLocalFile())
|
||||
|
||||
|
||||
class _AiCreateDialog(QDialog):
|
||||
"""Create tasks two ways, one tab each (both preview first — nothing is
|
||||
saved until the user confirms): ✨ AI gen from a natural-language
|
||||
description, or 📥 Import from a filled Excel template (pick or drag)."""
|
||||
|
||||
def __init__(self, ctx: AppContext, parent=None):
|
||||
super().__init__(parent)
|
||||
from PySide6.QtWidgets import QTabWidget
|
||||
|
||||
self.ctx = ctx
|
||||
self.created_tasks: List[dict] = []
|
||||
self._planned: List[dict] = []
|
||||
self._worker: Optional[AgentWorker] = None
|
||||
self.setWindowTitle(tr("schedtask.ai_btn"))
|
||||
self.resize(600, 520)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
ws_row = QHBoxLayout()
|
||||
ws_row.addWidget(QLabel(tr("schedtask.f_workspace")))
|
||||
self.workspace_combo = QComboBox()
|
||||
self.workspace_combo.addItem(tr("schedtask.no_workspace"), "")
|
||||
for p in list_projects():
|
||||
self.workspace_combo.addItem(p.name, p.project_id)
|
||||
self.workspace_combo.setToolTip(tr("schedtask.hint_workspace"))
|
||||
ws_row.addWidget(self.workspace_combo, 1)
|
||||
root.addLayout(ws_row)
|
||||
self.tabs = QTabWidget()
|
||||
root.addWidget(self.tabs, 1)
|
||||
|
||||
# ---- tab 1: AI gen ------------------------------------------------
|
||||
ai_page = QWidget()
|
||||
al = QVBoxLayout(ai_page)
|
||||
al.addWidget(QLabel(tr("schedtask.ai_desc_label")))
|
||||
self.desc_edit = QPlainTextEdit()
|
||||
self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph"))
|
||||
self.desc_edit.setMaximumHeight(110)
|
||||
al.addWidget(self.desc_edit)
|
||||
# Attachments (files + links) — merged into every task this generates,
|
||||
# AND into the planning prompt so the AI knows they exist.
|
||||
attach_row = QHBoxLayout()
|
||||
self.ai_files_edit = QLineEdit()
|
||||
self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder"))
|
||||
ai_pick_btn = QPushButton(tr("schedtask.pick_files"))
|
||||
ai_pick_btn.setIcon(icon("folder"))
|
||||
ai_pick_btn.clicked.connect(self._ai_pick_files)
|
||||
attach_row.addWidget(self.ai_files_edit, 1)
|
||||
attach_row.addWidget(ai_pick_btn)
|
||||
al.addWidget(QLabel(tr("schedtask.f_files")))
|
||||
al.addLayout(attach_row)
|
||||
self.ai_links_edit = QLineEdit()
|
||||
self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder"))
|
||||
al.addWidget(QLabel(tr("schedtask.f_links")))
|
||||
al.addWidget(self.ai_links_edit)
|
||||
self.gen_btn = QPushButton(tr("schedtask.ai_generate"))
|
||||
self.gen_btn.setIcon(icon("sparkle"))
|
||||
self.gen_btn.setObjectName("primary")
|
||||
self.gen_btn.clicked.connect(self._generate)
|
||||
al.addWidget(self.gen_btn)
|
||||
al.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
||||
self.preview = QPlainTextEdit()
|
||||
self.preview.setReadOnly(True)
|
||||
al.addWidget(self.preview, 1)
|
||||
self.tabs.addTab(ai_page, tr("schedtask.tab_ai"))
|
||||
|
||||
# ---- tab 2: Import from Excel --------------------------------------
|
||||
imp_page = QWidget()
|
||||
il = QVBoxLayout(imp_page)
|
||||
tpl_btn = QPushButton(tr("schedtask.export_template_btn"))
|
||||
tpl_btn.setIcon(icon("upload"))
|
||||
tpl_btn.clicked.connect(self._export_template)
|
||||
il.addWidget(tpl_btn)
|
||||
pick_row = QHBoxLayout()
|
||||
pick_btn = QPushButton(tr("schedtask.import_pick_btn"))
|
||||
pick_btn.setIcon(icon("folder"))
|
||||
pick_btn.clicked.connect(self._pick_import_file)
|
||||
pick_row.addWidget(pick_btn)
|
||||
pick_row.addStretch(1)
|
||||
il.addLayout(pick_row)
|
||||
self.drop_zone = _DropZone()
|
||||
self.drop_zone.setText(tr("schedtask.drop_hint"))
|
||||
self.drop_zone.file_dropped.connect(self._load_import_file)
|
||||
il.addWidget(self.drop_zone)
|
||||
il.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
||||
self.import_preview = QPlainTextEdit()
|
||||
self.import_preview.setReadOnly(True)
|
||||
il.addWidget(self.import_preview, 1)
|
||||
self.tabs.addTab(imp_page, tr("schedtask.tab_import"))
|
||||
|
||||
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm"))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||
self.buttons.accepted.connect(self._confirm)
|
||||
self.buttons.rejected.connect(self.reject)
|
||||
root.addWidget(self.buttons)
|
||||
|
||||
# ---- Import tab ------------------------------------------------------
|
||||
def _export_template(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from ..core.task_excel import export_template
|
||||
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, tr("schedtask.export_template_btn"),
|
||||
"cowork_tasks_template.xlsx", "Excel (*.xlsx)")
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
export_template(path)
|
||||
open_path(str(Path(path).parent))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc))
|
||||
|
||||
def _pick_import_file(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
from ..core.task_import import IMPORT_FILTER
|
||||
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, tr("schedtask.import_pick_btn"), "", IMPORT_FILTER)
|
||||
if path:
|
||||
self._load_import_file(path)
|
||||
|
||||
def _load_import_file(self, path: str) -> None:
|
||||
from ..core.task_import import import_tasks
|
||||
|
||||
try:
|
||||
self._planned = import_tasks(path)
|
||||
except ValueError as exc:
|
||||
self.import_preview.setPlainText(str(exc))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
|
||||
return
|
||||
by_id = {t["task_id"]: t["title"] for t in self._planned}
|
||||
lines = []
|
||||
for i, t in enumerate(self._planned, 1):
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
||||
deps = t.get("dependency", {}).get("depends_on") or []
|
||||
dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else ""
|
||||
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
||||
f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}")
|
||||
self.import_preview.setPlainText("\n\n".join(lines))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned))
|
||||
|
||||
def _ai_pick_files(self) -> None:
|
||||
from PySide6.QtWidgets import QFileDialog
|
||||
|
||||
files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files"))
|
||||
if files:
|
||||
existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()]
|
||||
self.ai_files_edit.setText("; ".join(existing + files))
|
||||
|
||||
def _attached_files(self) -> List[str]:
|
||||
return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()]
|
||||
|
||||
def _attached_links(self) -> List[str]:
|
||||
return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()]
|
||||
|
||||
def _generate(self) -> None:
|
||||
description = self.desc_edit.toPlainText().strip()
|
||||
if not description or self._worker is not None:
|
||||
return
|
||||
files, links = self._attached_files(), self._attached_links()
|
||||
self.gen_btn.setEnabled(False)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generating"))
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
from ..core.ai_task_planner import plan_tasks
|
||||
|
||||
provider = self.ctx.build_active_provider()
|
||||
full_desc = description
|
||||
if files or links:
|
||||
attach_note = "; ".join(files + links)
|
||||
full_desc += f"\n\n(Attached references available: {attach_note})"
|
||||
planned = plan_tasks(provider, full_desc, cancel=worker.is_cancelled)
|
||||
# Attachments apply to every generated task so they're available
|
||||
# at RUN time too, not just visible to the planner.
|
||||
for t in planned:
|
||||
t["input"]["file_paths"] = list(files)
|
||||
t["input"]["links"] = list(links)
|
||||
return {"tasks": planned}
|
||||
|
||||
w = AgentWorker(job)
|
||||
w.finished_ok.connect(self._on_planned)
|
||||
w.failed.connect(self._on_failed)
|
||||
self._worker = w
|
||||
w.start()
|
||||
|
||||
def _on_planned(self, result: dict) -> None:
|
||||
self._worker = None
|
||||
self.gen_btn.setEnabled(True)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
||||
self._planned = result.get("tasks") or []
|
||||
lines = []
|
||||
for i, t in enumerate(self._planned, 1):
|
||||
sched = t.get("schedule", {})
|
||||
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
||||
dep = t.get("dependency", {})
|
||||
chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else ""
|
||||
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
||||
f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n"
|
||||
f" {t.get('description', '')[:150]}")
|
||||
self.preview.setPlainText("\n\n".join(lines))
|
||||
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned))
|
||||
|
||||
def _on_failed(self, err: str) -> None:
|
||||
self._worker = None
|
||||
self.gen_btn.setEnabled(True)
|
||||
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
||||
self.preview.setPlainText(str(err))
|
||||
|
||||
def _confirm(self) -> None:
|
||||
project_id = self.workspace_combo.currentData() or ""
|
||||
for t in self._planned:
|
||||
t["project_id"] = project_id
|
||||
self.created_tasks = self._planned
|
||||
self.accept()
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -220,7 +220,7 @@ class WorkspaceTab(QWidget):
|
||||
# Folder — a two-pane file explorer (tree + view/edit) placed right below
|
||||
# Co4E. Always available (not project-gated); its root follows the
|
||||
# selected project's workspace folder when one is chosen.
|
||||
from .folder_tab import FolderTab
|
||||
from ..presentation.folder.folder_tab import FolderTab
|
||||
|
||||
self._folder = FolderTab(self.ctx, cowork=self._cowork)
|
||||
self._folder.status_message.connect(self.status_message)
|
||||
|
||||
Reference in New Issue
Block a user