Files
cowork-local/presentation/folder/ai_file_editor_dialog.py
T
vudt15andClaude Sonnet 5 0e51356a7d 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>
2026-08-27 20:55:32 +09:00

238 lines
10 KiB
Python

"""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"]