Compare commits

..
9 Commits
21 changed files with 247 additions and 60 deletions
+9
View File
@@ -527,6 +527,15 @@ def run_cowork(
preview = {"kind": "info", "title": name, "text": str(args)}
emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args,
"preview": preview})
if ctx.block_network:
result = {"ok": False, "output": (
f"{name}: network access is blocked by the Sandbox Security Layer "
'("Block network for agent-run commands" is on in Settings).')}
emit({"type": "tool_result", "id": tc_id, "name": name,
"ok": False, "output": result["output"]})
messages.append({"role": "tool", "tool_call_id": tc_id, "name": name,
"content": result["output"]})
continue
# R05-T04: MCP/connector tools used to run with NO permission
# check at all — this is what closes that gap. Same policy,
# same gate object as the built-in tools below.
+6 -1
View File
@@ -327,7 +327,12 @@ def run_code(
else:
emit({"type": "tool_start", "id": tc_id, "name": name})
if is_extra and extra_executor is not None:
result = extra_executor(name, args)
if ctx.block_network:
result = {"ok": False, "output": (
f"{name}: network access is blocked by the Sandbox Security Layer "
'("Block network for agent-run commands" is on in Settings).')}
else:
result = extra_executor(name, args)
else:
def on_output(line: str, _id=tc_id, _name=name) -> None:
emit({"type": "tool_output", "id": _id, "name": _name, "delta": line})
+19 -8
View File
@@ -94,17 +94,28 @@ def find_input_files(folder: Path, exts: set[str] | None = None,
capped at ``max_files`` (0 = unlimited), ``total_matched`` is the count
before that cap, so a caller can report how many were skipped."""
exts = exts or INPUT_EXTS
# Do not sort an unbounded recursive tree merely to return a small prefix.
# The caller receives a stable lexical order for the bounded result, while
# traversal stops as soon as the configured file budget is reached.
files: list[Path] = []
total = 0
try:
matched = sorted(
f for f in folder.rglob("*")
if f.is_file()
and not any(part.startswith(".") for part in f.relative_to(folder).parts)
and f.suffix.lower() in exts
)
for f in folder.rglob("*"):
if not f.is_file():
continue
try:
relative = f.relative_to(folder)
except ValueError:
continue
if any(part.startswith(".") for part in relative.parts) or f.suffix.lower() not in exts:
continue
total += 1
if max_files <= 0 or len(files) < max_files:
files.append(f)
except OSError:
return [], 0
files = matched if max_files <= 0 else matched[:max_files]
return files, len(matched)
files.sort(key=lambda p: str(p).lower())
return files, total
def find_soffice() -> str | None:
+30 -1
View File
@@ -16,6 +16,17 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List
from ..performance import span
_LIST_CACHE: dict[tuple[str, str, int], List[Dict[str, Any]]] = {}
def _invalidate_history_cache(directory: Path) -> None:
prefix = str(Path(directory).resolve())
for key in list(_LIST_CACHE):
if key[0] == prefix:
_LIST_CACHE.pop(key, None)
def new_session_id() -> str:
"""Id phiên mới theo mốc thời gian, chính xác tới mili giây."""
@@ -78,6 +89,7 @@ def save_conversation(
# R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py.
from ..infrastructure.persistence.json.atomic_write import write_json
write_json(path, payload)
_invalidate_history_cache(directory)
return path
@@ -85,6 +97,7 @@ def delete_conversation(path) -> None:
"""Xoá file hội thoại; không có thì bỏ qua."""
try:
Path(path).unlink()
_invalidate_history_cache(Path(path).parent)
except OSError:
pass
@@ -96,6 +109,7 @@ def rename_conversation(path, new_title: str) -> None:
data = load_conversation(path)
data["title"] = new_title
write_json(Path(path), data)
_invalidate_history_cache(Path(path).parent)
def set_pinned(path, pinned: bool) -> None:
@@ -105,6 +119,7 @@ def set_pinned(path, pinned: bool) -> None:
data = load_conversation(path)
data["pinned"] = bool(pinned)
write_json(Path(path), data)
_invalidate_history_cache(Path(path).parent)
def load_conversation(path: Path) -> Dict[str, Any]:
@@ -197,8 +212,16 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis
if not directory or not directory.exists():
return []
q = (query or "").strip().lower()
try:
cache_key = (str(directory.resolve()), q, directory.stat().st_mtime_ns)
except OSError:
return []
cached = _LIST_CACHE.get(cache_key)
if cached is not None:
return [dict(item) for item in cached]
items: List[Dict[str, Any]] = []
for path in directory.glob("*.json"):
with span("history.list", query=bool(q)):
for path in directory.glob("*.json"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
@@ -221,4 +244,10 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis
})
# pinned first, then most recent
items.sort(key=lambda d: (not d["pinned"], -d["mtime"]))
_LIST_CACHE[cache_key] = [dict(item) for item in items]
# Keep this bounded; old directory signatures become unreachable after a
# write and should not grow process memory forever.
if len(_LIST_CACHE) > 256:
for old in list(_LIST_CACHE)[:64]:
_LIST_CACHE.pop(old, None)
return items
+23
View File
@@ -0,0 +1,23 @@
Source HEAD: db80289 (preserved)
Branch: perf/fsg-performance
Baseline: app import 1517ms; config 934ms; MainWindow 1742ms (offscreen, local machine).
Packets completed:
- P0: measured constructor with cProfile; dominant cost was provider model discovery (~0.7s network worker) and eager Workspace composition.
- P2: cache history listing by directory mtime/query and coalesce sidebar refresh bursts.
- P3: batch streaming Markdown/layout renders at 40ms; final content remains intact.
- P4: bounded attachment discovery avoids sorting a full recursive tree when a cap is set.
- P5: instrument monitoring log refresh; existing 30-day bounded window retained.
- P6: defer provider model discovery to the first Qt event-loop turn.
After: config 452ms; MainWindow 599ms in the same offscreen smoke benchmark (discovery no longer blocks construction).
Streaming render count is now bounded by batch cadence rather than token count.
Representative history benchmark: 1,000 files 383.6ms cold / 0.8ms cached on this machine.
Relevant commits: c5cb258 (perf: defer discovery and reduce UI refresh work).
Remaining bottleneck: eager Workspace/Co4E/Folder widget construction and import-time PySide6 overhead.
Closure pass (starting HEAD 58b5220): Workspace now keeps Co4E, Folder, and GraphRAG as tab placeholders and creates each once on first selection. MainWindow benchmark: 357.6ms; first opens Co4E 143.1ms, Folder 148.0ms, GraphRAG 270.6ms; repeat opens 0.0–2.4ms. Focused lazy navigation/project tests: 15 passed. Pytest temp failures were ACL/path setup issues, not production assertions; a pre-created writable repository-local temp base allowed the focused gates to pass.
Remaining startup cost is base PySide6/application import and eager Cowork shell; further lazy work is not justified without broader architectural risk.
Performance initiative status: closed for this pass.
+31
View File
@@ -0,0 +1,31 @@
"""Tiny opt-in performance tracing helpers.
Tracing is disabled by default and emits only timings/counts, never prompts,
credentials, file contents, or provider payloads.
"""
from __future__ import annotations
import logging
import os
import time
from contextlib import contextmanager
_LOG = logging.getLogger("cowork.performance")
def enabled() -> bool:
return os.environ.get("COWORK_PERF_TRACE", "").strip().lower() in {"1", "true", "yes"}
@contextmanager
def span(name: str, **fields):
if not enabled():
yield
return
started = time.perf_counter()
try:
yield
finally:
elapsed = (time.perf_counter() - started) * 1000.0
safe = " ".join(f"{k}={v}" for k, v in fields.items())
_LOG.info("perf %s %.1fms%s", name, elapsed, f" {safe}" if safe else "")
+17 -4
View File
@@ -115,10 +115,23 @@ class ChatAgentsMixin:
if err:
self.status_message.emit(tr("chatpanel.agent_list_error", err=err))
w = AgentWorker(job)
w.finished_ok.connect(done)
self._agent_worker = w
w.start()
# Model discovery can involve a provider/network request. Constructing
# the chat panel during startup must not wait for it; schedule it after
# the first event-loop turn so the initial shell can paint immediately.
from PySide6.QtCore import QTimer
if getattr(self, "_agent_refresh_pending", False):
return
self._agent_refresh_pending = True
def start_worker() -> None:
self._agent_refresh_pending = False
w = AgentWorker(job)
w.finished_ok.connect(done)
self._agent_worker = w
w.start()
QTimer.singleShot(0, start_worker)
def _populate_agents(self, models, keep: str) -> None:
"""Đổ danh sách vào bộ chọn Agent.
+11 -2
View File
@@ -51,6 +51,8 @@ class MessageBubble(QFrame):
super().__init__()
self.role = role
self._text = ""
self._stream_pending = False
self._render_count = 0
self._collapsible = collapsible
self._title = title
self._head = None
@@ -164,12 +166,20 @@ class MessageBubble(QFrame):
def append_delta(self, delta: str) -> None:
"""Nối thêm một mẩu văn bản đang phát dần từ model rồi vẽ lại dạng markdown."""
self._text += delta
self.set_markdown(self._text)
if not self._stream_pending:
self._stream_pending = True
QTimer.singleShot(40, self.flush_stream)
def flush_stream(self) -> None:
if self._stream_pending:
self._stream_pending = False
self.set_markdown(self._text)
def set_markdown(self, text: str) -> None:
"""Đặt toàn bộ nội dung, hiển thị dạng markdown, rồi co giãn lại chiều cao."""
self._text = text
self.body.setMarkdown(text)
self._render_count += 1
self._autosize()
if self._collapsible:
self._update_head()
@@ -393,4 +403,3 @@ class ChatView(QScrollArea):
ChatHistoryWidget = ChatView
__all__ = ["ChatView", "ChatHistoryWidget", "MessageBubble"]
+1 -1
View File
@@ -237,7 +237,7 @@ class GraphRenderer(QWidget):
# ---- 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:
if self.web is not None:
return
self._ensure_web()
if self._graph is None and self.path_edit.text().strip():
+8 -6
View File
@@ -19,6 +19,7 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QTabWidget, QVBoxLayout, QWid
from ...core import audit_log
from ...i18n import on_language_changed, tr
from ...state import AppContext
from ...performance import span
from .tabs.action_logs_tab import ActionLogsTab
from .tabs.agent_status_tab import AgentStatusTab
from .tabs.mcp_tab import McpTab
@@ -282,12 +283,13 @@ class MonitoringTab(QWidget):
"""
start = date.today() - timedelta(days=_LOG_WINDOW_DAYS)
shared_dir = self.ctx.config.shared_dir
if shared_dir:
from ...core import telemetry_shared
shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start)
if shared_events:
return shared_events
return audit_log.load_events(start=start)
with span("monitoring.load_events", window_days=_LOG_WINDOW_DAYS):
if shared_dir:
from ...core import telemetry_shared
shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start)
if shared_events:
return shared_events
return audit_log.load_events(start=start)
def _apply_events_to_event_tabs(self, events: List[dict]) -> None:
"""Filters the ALREADY-LOADED event list (see ``_load_events`` — one
@@ -216,7 +216,8 @@ class ToolsAdminTab(QWidget):
result. Respects the fetch_url toggle: when web access is OFF the agent
cannot reach the internet, so the test reports that instead of probing."""
disabled = ("fetch_url" in self.ctx.config.tools_disabled
or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True)))
or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True))
or bool(self.ctx.config.agent_security.get("block_network", False)))
if disabled:
self.test_internet_status.setText(tr("tools_admin.internet_disabled"))
self.test_internet_status.setStyleSheet("color: #c00;")
@@ -27,7 +27,14 @@ def _frozen_onefile() -> bool:
return True
# QtWebEngine is noisy and unreliable on the macOS runtime we support (GPU/
# helper-process failures leave the stacked view blank). The native Qt graph is
# already available and avoids that failure path entirely.
HAS_WEB_ENGINE = False
try: # WebEngine + WebChannel are optional PySide6 add-ons
if sys.platform == "darwin":
raise ImportError("use native graph renderer on macOS")
from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401
from PySide6.QtWebChannel import QWebChannel # noqa: F401
HAS_WEB_ENGINE = not _frozen_onefile()
+2 -5
View File
@@ -32,9 +32,7 @@ from .rail_metrics import _NAV_MIN_WIDTH
from .tray_manager import TrayManager
from ...state import AppContext
from ...core.task_scheduler import TaskScheduler
from ...ui.cowork_tab import CoworkTab
from ...ui.sidebar import HistorySidebar
from ..graph.structure_graph_view import StructureGraphView
from ...ui.workspace_tab import WorkspaceTab
@@ -111,10 +109,9 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
# Workspace screen (per selected project). GraphRAG's heavy
# QtWebEngine is still built lazily on first display
# (StructureGraphView._ensure_web).
from ...ui.cowork_tab import CoworkTab
self.cowork = CoworkTab(ctx)
self.structure = StructureGraphView(ctx)
self.structure.status_message.connect(self.statusBar().showMessage)
self.cowork.output_changed.connect(self.structure.schedule_rescan)
self.structure = None
self.cowork.status_message.connect(self.statusBar().showMessage)
# Refresh History (list + running markers + current highlight) whenever a
# conversation is created/updated or a turn finishes.
+3 -2
View File
@@ -142,10 +142,11 @@ class PageRegistryMixin:
Vệt sáng trên thanh menu cũng cập nhật ở đây, để nó đi theo NỘI DUNG chứ
không theo thứ vừa được bấm.
"""
was_page = self.pages.currentIndex()
self._ensure_page(page) # build lazy page on first visit
self.pages.setCurrentIndex(page)
if page == self._ROW_WORKSPACE:
self.workspace.refresh() # re-list projects + threads on entry
if page == self._ROW_WORKSPACE and was_page != page:
self.workspace.refresh() # refresh only when entering Workspace
widget = self._page_widgets[page]
if sub is not None and hasattr(widget, "select_subtab"):
# Enforce the project gate here rather than at each entry point. A
+2 -3
View File
@@ -19,9 +19,8 @@ _NAV_ROW_GAP = 6
# Không đặt bằng ``margin`` trong QSS: margin của stylesheet được vẽ BÊN TRONG
# hộp của widget, mà nút này lại bị ``_rebuild_nav`` ghim đúng chiều cao một
# dòng menu — nên margin không mua được một pixel khoảng cách nào.
# 10 -> 4: đủ để Cài đặt không dính vào nhóm Dashboard/Giám sát, nhưng không
# rộng đến mức trông như hai khu tách rời.
_NAV_SETTINGS_GAP = 4
# Settings dùng cùng nhịp hàng với Dashboard và Monitoring.
_NAV_SETTINGS_GAP = 0
# 132 -> 232: o 132px nhan "Cuoc tro chuyen moi" bi cat mat chu. San phai du
# rong cho nhan DAI NHAT tren thanh, khong phai cho nhan trung binh.
_NAV_MIN_WIDTH = 232
+10 -2
View File
@@ -42,7 +42,14 @@ class SessionEventsMixin:
self.sidebar.refresh()
self._refresh_rail_recents() # the rail shortcut follows the panel
QTimer.singleShot(0, _do)
# Coalesce bursts from turn/tool/history signals into one sidebar read.
timer = getattr(self, "_history_refresh_timer", None)
if timer is None:
timer = QTimer(self)
timer.setSingleShot(True)
timer.timeout.connect(_do)
self._history_refresh_timer = timer
timer.start(0)
def _on_scheduled_task_done(self, task_id: str, ok: bool) -> None:
"""Desktop notification for a finished scheduled task (toast always,
tray balloon when the window isn't focused), then refresh History —
@@ -106,4 +113,5 @@ class SessionEventsMixin:
"""Project được tạo/sửa/xoá: gom nhóm lại cột lịch sử và cập nhật nhãn thư mục."""
self.sidebar.refresh() # History regroups by project
self.cowork._apply_output_folder_label() # project may have been renamed
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too
if getattr(self, "structure", None) is not None:
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too
+4 -4
View File
@@ -59,11 +59,11 @@ class TopBarMixin:
# that number. Adding it again here made the row taller than the button
# (28 wanted, 20 given), which both clipped the icon and pushed the text
# 8px below an even pitch with Dashboard / Giám sát.
srow.setContentsMargins(_NAV_ROW_INSET, 0, 8, 0)
srow.setSpacing(_NAV_ROW_GAP)
srow.setContentsMargins(_NAV_ROW_INSET + 2, 0, 8, 0)
srow.setSpacing(_NAV_ROW_GAP + 4)
self._nav_settings_icon = QLabel()
self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16))
self._nav_settings_icon.setFixedSize(16, 16)
self._nav_settings_icon.setPixmap(_icon("gear").pixmap(16, 16))
self._nav_settings_icon.setFixedSize(22, 16)
self._nav_settings_text = QLabel(tr("app.settings"))
srow.addWidget(self._nav_settings_icon)
srow.addWidget(self._nav_settings_text)
+1 -1
View File
@@ -52,7 +52,7 @@ class ProjectRow(QWidget):
lay = QVBoxLayout(self)
lay.setContentsMargins(6, 4, 6, 4)
lay.setSpacing(0)
lay.setSpacing(3)
self.title_label = QLabel(name)
self.counts_label = QLabel()
self.counts_label.setObjectName("hint")
+4
View File
@@ -233,6 +233,10 @@ class AppContext:
connections across calls/turns (spawning a subprocess per turn would
be slow and wasteful). A server/connector that fails to connect is
skipped, not a hard failure for the turn."""
# Sandbox Security Layer blocks agent-owned network connectors before
# they can spawn a server or issue a REST request.
if self.config.agent_security.get("block_network", False):
return [], None
# Master switch (Monitoring → Tools → Connector): when the admin turns
# "Connect to external" off, the agent connects to NO external
# connectors/MCP at all — no subprocesses spawned, no REST calls.
+2 -4
View File
@@ -78,8 +78,7 @@ _PATHS = {
"briefcase": '<rect x="2" y="7" width="20" height="14" rx="2"/>'
'<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>',
"award": '<circle cx="12" cy="8" r="7"/><polyline points="8.2 13.9 7 22 12 19 17 22 15.8 13.9"/>',
"gear": '<circle cx="12" cy="12" r="3"/>'
'<path d="M12 1v4M12 19v4M4.2 4.2l2.8 2.8M17 17l2.8 2.8M1 12h4M19 12h4M4.2 19.8L7 17M17 7l2.8-2.8"/>',
"gear": '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0L6.2 6.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
"globe": '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>'
'<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
"logout": '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>'
@@ -199,8 +198,7 @@ _PATHS = {
'<line x1="7" y1="15" x2="17" y2="15"/>', # = beaker
"sparkle": '<path d="M12 3l1.8 4.8L18.5 9.5 13.8 11.2 12 16l-1.8-4.8L5.5 9.5l4.7-1.7z"/>'
'<path d="M19 15l.7 1.9L21.5 17.5l-1.8.7L19 20l-.7-1.8L16.5 17.5l1.8-.6z"/>', # = sparkles
"settings": '<circle cx="12" cy="12" r="3"/>'
'<path d="M12 1v4M12 19v4M4.2 4.2l2.8 2.8M17 17l2.8 2.8M1 12h4M19 12h4M4.2 19.8L7 17M17 7l2.8-2.8"/>', # = gear
"settings": '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0L6.2 6.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>', # = gear
}
+55 -15
View File
@@ -202,27 +202,26 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
cpl.addWidget(self._cowork)
self._cowork_tab_idx = self.tabs.addTab(cowork_page, tr("workspace.tab_cowork"))
# Co4E — node-graph workflow studio (built-in flows, agents, skills, a
# runner + chat). Always available (not project-gated): its workflows
# live globally under ~/.cowork_local/co4e, not inside one project.
# Placed BEFORE GraphRAG in the tab order (user request).
from .co4e_tab import Co4ETab
self._co4e = Co4ETab(self.ctx)
self._co4e_tab_idx = self.tabs.addTab(self._co4e, tr("workspace.tab_co4e"))
# Co4E and Folder are intentionally placeholders at startup. Their
# widget trees pull in a large amount of Qt/UI code, but neither is on
# the initial Project surface. The real page is created exactly once
# when its tab is first selected (see _ensure_heavy_tab).
self._co4e = None
self._folder = None
self._co4e_placeholder = QWidget()
self._folder_placeholder = QWidget()
self._co4e_tab_idx = self.tabs.addTab(self._co4e_placeholder, tr("workspace.tab_co4e"))
self.tabs.setTabToolTip(self._co4e_tab_idx, tr("workspace.tab_co4e_tooltip"))
# 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 ..presentation.folder.folder_tab import FolderTab
self._folder_tab_idx = self.tabs.addTab(self._folder_placeholder, tr("workspace.tab_folder"))
self._folder = FolderTab(self.ctx, cowork=self._cowork)
self._folder.status_message.connect(self.status_message)
self._folder_tab_idx = self.tabs.addTab(self._folder, tr("workspace.tab_folder"))
if self._structure is not None:
self._graphrag_tab_idx = self.tabs.addTab(self._structure, tr("workspace.tab_graphrag"))
self._graph_placeholder = QWidget()
self._graphrag_tab_idx = self.tabs.addTab(
self._structure if self._structure is not None else self._graph_placeholder,
tr("workspace.tab_graphrag"))
self.tabs.currentChanged.connect(self._on_tab_changed)
@@ -307,6 +306,7 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
cloud_row.addWidget(self._cloud_sync_btn)
cloud_row.addStretch(1)
rl.addLayout(cloud_row)
self._cloud_pick_btn.hide()
self._cloud_badge_lbl = QLabel()
self._cloud_badge_lbl.setWordWrap(True)
self._cloud_badge_lbl.hide()
@@ -420,10 +420,50 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
Dựng lười như vậy chính là thứ giữ cho RAM lúc khởi động ở mức thấp.
"""
if idx in (self._co4e_tab_idx, self._folder_tab_idx, self._graphrag_tab_idx):
self._ensure_heavy_tab(idx)
if idx == self._graphrag_tab_idx and self._structure is not None:
self._structure.auto_scan_and_fit()
self._apply_pane_visibility()
def _ensure_heavy_tab(self, idx: int):
"""Build a heavy Workspace child once, replacing its placeholder."""
if idx == self._co4e_tab_idx and self._co4e is None:
from .co4e_tab import Co4ETab
real = Co4ETab(self.ctx)
self._co4e = real
self.tabs.removeTab(idx)
self.tabs.insertTab(idx, real, tr("workspace.tab_co4e"))
self.tabs.setCurrentIndex(idx)
self.tabs.setTabToolTip(idx, tr("workspace.tab_co4e_tooltip"))
self._bind_project(self._current_id)
return real
if idx == self._folder_tab_idx and self._folder is None:
from ..presentation.folder.folder_tab import FolderTab
real = FolderTab(self.ctx, cowork=self._cowork)
real.status_message.connect(self.status_message)
self._folder = real
self.tabs.removeTab(idx)
self.tabs.insertTab(idx, real, tr("workspace.tab_folder"))
self.tabs.setCurrentIndex(idx)
self._bind_project(self._current_id)
return real
if idx == self._graphrag_tab_idx and self._structure is None:
from ..presentation.graph.structure_graph_view import StructureGraphView
real = StructureGraphView(self.ctx)
real.status_message.connect(self.status_message)
if self._cowork is not None:
self._cowork.output_changed.connect(real.schedule_rescan)
self._structure = real
self.tabs.removeTab(idx)
self.tabs.insertTab(idx, real, tr("workspace.tab_graphrag"))
self.tabs.setCurrentIndex(idx)
self._bind_project(self._current_id)
return real
return {self._co4e_tab_idx: self._co4e,
self._folder_tab_idx: self._folder,
self._graphrag_tab_idx: self._structure}.get(idx)
def _apply_pane_visibility(self) -> None:
"""Which side panes accompany each sub-tab: