Compare commits

..
Author SHA1 Message Date
thanhnv 2759ed94ba perf: avoid refreshing workspace on cowork tab switch
CI / test (pull_request) Canceled after 0s
2026-09-16 01:05:52 +09:00
thanhnv 8548c1e923 fix: enforce sandbox network block for agent tools 2026-09-16 01:00:31 +09:00
thanhnv caf3b74931 fix: use native GraphRAG renderer on macOS 2026-09-16 00:38:56 +09:00
thanhnv c00b83cd1a fix: add spacing to project row metadata 2026-09-16 00:31:33 +09:00
thanhnv a04f8a928d fix: hide cloud workspace picker 2026-09-16 00:30:07 +09:00
thanhnv cc8d5c8c0a fix: keep navigation row spacing uniform 2026-09-16 00:28:32 +09:00
thanhnv 2e3e719259 fix: separate settings from navigation group 2026-09-16 00:25:37 +09:00
thanhnv c699beb6fd fix: align settings navigation item 2026-09-16 00:23:03 +09:00
thanhnv 7607f44030 chore: checkpoint current performance and UI changes 2026-09-16 00:05:34 +09:00
cbae2604db Fix/qa defects df002 df003 df006 (#12)
CI / test (push) Canceled after 0s
## Summary

What changed and why?

## Change Type

- [ ] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
Reviewed-on: #12
2026-09-14 13:15:53 +00:00
duylh19andgitea-admin b71a622227 Delta team/fix comment ui v2 (#11)
CI / test (push) Canceled after 0s
## Summary

What changed and why?

## Change Type

- [ ] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

Reviewed-on: #11
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-09-14 13:15:40 +00:00
33 changed files with 522 additions and 115 deletions
Binary file not shown.
+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.
+6
View File
@@ -234,6 +234,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
"monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"},
"monitoring.page_size_label": {
"en": "Rows/page:", "ja": "1ページの行数:", "vi": "Số dòng/trang:"},
"monitoring.page_indicator": {
"en": "Page {page}/{total}", "ja": "{page}/{total} ページ", "vi": "Trang {page}/{total}"},
"monitoring.page_prev": {
"en": "Previous page", "ja": "前のページ", "vi": "Trang trước"},
"monitoring.page_next": {
"en": "Next page", "ja": "次のページ", "vi": "Trang sau"},
"monitoring.pricing_title": {
"en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)",
"vi": "Bảng giá model (USD / 1 triệu token)"},
+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"]
+4 -4
View File
@@ -10,13 +10,13 @@ from typing import List, Optional
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QMenu
from ...core import co4e
from ...core.co4e import STEP_DONE, STEP_RUNNING
from ...core.co4e import STEP_RUNNING
from ...i18n import tr
from ...ui.dialog_buttons import ask_text
from ...ui.icons import icon
from ...presentation.co4e.co4e_chat_view import _skill_names
_LOCKED_NODE_STATUSES = (STEP_RUNNING, STEP_DONE)
_LOCKED_NODE_STATUSES = (STEP_RUNNING,)
class Co4EWorkflowCrudMixin:
@@ -169,8 +169,8 @@ class Co4EWorkflowCrudMixin:
def _on_node_selected(self, node_id: str) -> None:
"""Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập.
Bước đang chạy hoặc đã chạy xong thì khoá ô nhập liệu ngay khi nạp —
tránh sửa nhầm cấu hình của lần chạy đang xem kết quả.
Chỉ khoá ô nhập liệu khi bước ĐANG chạy (DF-002) — chạy xong rồi thì
vẫn sửa lại được bình thường.
"""
for n in self.canvas.nodes():
if n.id == node_id:
+5 -5
View File
@@ -294,11 +294,11 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
def set_locked(self, locked: bool) -> None:
"""Khoá/mở khoá các trường chỉnh sửa theo trạng thái chạy của bước.
Bước đang chạy hoặc đã chạy xong thì khoá lại — tránh sửa nhầm cấu
hình trong lúc đang xem kết quả của chính lần chạy đó (sửa xong
không rõ là áp dụng cho lần chạy đã xong hay lần chạy tiếp theo).
Nút Chạy/Chạy từ đây/Xoá bước vẫn hoạt động bình thường khi khoá —
chỉ ô nhập liệu bị khoá, không phải cả panel.
Chỉ khoá khi bước ĐANG chạy — tránh sửa nhầm cấu hình trong lúc chưa
biết kết quả (DF-002: trước đây còn khoá cả bước đã chạy xong, khiến
không sửa lại được sau khi run xong). Nút Chạy/Chạy từ đây/Xoá bước
vẫn hoạt động bình thường khi khoá — chỉ ô nhập liệu bị khoá, không
phải cả panel.
"""
self._locked = locked
editable = not locked
+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():
+11 -8
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
@@ -39,8 +40,9 @@ _UNBOUNDED_PAGE_SIZE = 100_000
# day-sharded JSONL — unbounded start/end means EVERY day file ever written
# gets re-read and re-parsed on EVERY tick, which is what actually made
# Monitoring "gây nặng khi log lớn" (see DF-006): the slowness was never in
# rendering (EventTable already caps display at 300 rows — see
# shared/event_table.py::_MAX_ROWS), it was this repeated full-history read.
# rendering (EventTable paginates client-side, 5-100 rows/page — see
# shared/event_table.py::_DEFAULT_PAGE_SIZE), it was this repeated full-history
# read.
# 30 days is a live-monitoring window, not a hard retention limit — nothing
# is deleted, older days are simply not re-read on every 3s tick.
_LOG_WINDOW_DAYS = 30
@@ -281,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
+55 -11
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
from typing import List, Optional
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt, Signal
from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
@@ -17,8 +17,8 @@ from ....ui.icons import DOT_GREEN, DOT_RED, icon
from .badges import action_label
from .formatters import agent_avatar_icon, fmt_event_time
_MAX_ROWS = 300
PAGE_SIZE_OPTIONS = (50, 100, 300, 500, 1000)
_DEFAULT_PAGE_SIZE = 20
PAGE_SIZE_OPTIONS = (5, 10, 20, 50, 100)
class _TimeItem(QTableWidgetItem):
@@ -63,6 +63,12 @@ class EventTable(QTableWidget):
"secret_in_output": "warning",
}
# Emitted whenever the rendered page changes (new data, page-size change,
# or prev/next navigation) — args are (current_page, page_count), both
# 1-based-friendly in that current_page is 0-indexed but page_count is a
# plain count. filter_scaffold.py's pager label/buttons listen to this.
page_changed = Signal(int, int)
def __init__(self, show_result: bool = True):
# Security Events drops the result column entirely (see _ACTION_TINTS).
"""Bảng sự kiện dùng chung của các tab Giám sát.
@@ -71,8 +77,10 @@ class EventTable(QTableWidget):
là thất bại nên cột ấy chỉ tốn chỗ.
"""
self._show_result = show_result
self._page_size = _MAX_ROWS
self._page_size = _DEFAULT_PAGE_SIZE
self._current_page = 0
self._last_events: List[dict] = []
self._sorted_events: List[dict] = []
super().__init__(0, 7 if show_result else 6)
self.setEditTriggers(QTableWidget.NoEditTriggers)
self.setSelectionBehavior(QTableWidget.SelectRows)
@@ -105,21 +113,56 @@ class EventTable(QTableWidget):
"""Số dòng đang hiển thị mỗi trang."""
return self._page_size
def page_count(self) -> int:
"""Tổng số trang với dữ liệu và số dòng/trang hiện tại (tối thiểu 1)."""
if not self._sorted_events:
return 1
return -(-len(self._sorted_events) // self._page_size) # ceil div
def current_page(self) -> int:
"""Trang đang hiển thị, đánh số từ 0."""
return self._current_page
def go_to_page(self, page: int) -> None:
"""Nhảy tới một trang cụ thể (đánh số từ 0), tự kẹp trong khoảng hợp lệ."""
self._current_page = page
self._render_current_page()
def next_page(self) -> None:
"""Sang trang kế — không làm gì nếu đã ở trang cuối."""
self.go_to_page(self._current_page + 1)
def prev_page(self) -> None:
"""Về trang trước — không làm gì nếu đã ở trang đầu."""
self.go_to_page(self._current_page - 1)
def set_page_size(self, n: int) -> None:
"""Đổi số dòng hiển thị mỗi trang rồi vẽ lại với dữ liệu đã có sẵn
(không cần refresh lại từ nguồn — set_events() đã lưu lại lần đổ gần nhất)."""
"""Đổi số dòng hiển thị mỗi trang, quay về trang đầu, rồi vẽ lại với dữ
liệu đã có sẵn (không cần refresh lại từ nguồn)."""
self._page_size = n
self.set_events(self._last_events)
self._current_page = 0
self._render_current_page()
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, cắt ở ``self._page_size``
(đổi được qua ``set_page_size`` — control "Số dòng/trang" ở filter_scaffold.py).
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, chia trang theo
``self._page_size`` — xem qua trang khác bằng ``next_page``/``prev_page``
(nút tiến/lùi ở filter_scaffold.py), không còn bị cắt bỏ vĩnh viễn như
trước (DF-006)."""
self._last_events = events
self._sorted_events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)
self._current_page = 0
self._render_current_page()
def _render_current_page(self) -> None:
"""Vẽ đúng một trang (theo ``self._current_page``/``self._page_size``)
từ ``self._sorted_events`` đã sắp sẵn.
Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc
nạp chậm đi theo bậc hai.
"""
self._last_events = events
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:self._page_size]
self._current_page = max(0, min(self._current_page, self.page_count() - 1))
start = self._current_page * self._page_size
events = self._sorted_events[start:start + self._page_size]
self.setSortingEnabled(False)
self.setRowCount(len(events))
for row, ev in enumerate(events):
@@ -164,6 +207,7 @@ class EventTable(QTableWidget):
self.setItem(row, col, item)
self.setSortingEnabled(True)
self.apply_filter(getattr(self, "_filter_needle", ""))
self.page_changed.emit(self._current_page, self.page_count())
def apply_filter(self, needle: str) -> None:
"""Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường)."""
@@ -95,20 +95,52 @@ def build_filter_scaffold(
if with_page_size and isinstance(table, EventTable):
# DF-006: the item-per-page count was never surfaced anywhere in
# the UI (design called for it) — EventTable already trims to a
# page size internally (default 300), this just makes that
# number visible AND user-choosable instead of a fixed constant.
# page size internally, this just makes that number visible AND
# user-choosable instead of a fixed constant.
page_size_lbl = QLabel(tr("monitoring.page_size_label"))
page_size_combo = QComboBox()
for n in PAGE_SIZE_OPTIONS:
page_size_combo.addItem(str(n), n)
current = table.page_size()
page_size_combo.setCurrentIndex(
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 2)
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 0)
page_size_combo.currentIndexChanged.connect(
lambda i: table.set_page_size(page_size_combo.itemData(i)))
row.addWidget(page_size_lbl)
row.addWidget(page_size_combo)
parts.update(page_size_label=page_size_lbl, page_size_combo=page_size_combo)
# DF-006 follow-up: trimming to a page size alone silently dropped
# every row past it with no way back to see them — prev/next
# buttons plus a "trang X/Y" indicator make the rest reachable.
page_prev_btn = QPushButton()
page_prev_btn.setIcon(icon("chevron-left"))
page_prev_btn.setCursor(Qt.PointingHandCursor)
bind_tip(page_prev_btn, "monitoring.page_prev")
page_next_btn = QPushButton()
page_next_btn.setIcon(icon("chevron-right"))
page_next_btn.setCursor(Qt.PointingHandCursor)
bind_tip(page_next_btn, "monitoring.page_next")
page_indicator_lbl = QLabel()
def _refresh_pager(cur: int = None, total: int = None) -> None:
if cur is None or total is None:
cur, total = table.current_page(), table.page_count()
page_indicator_lbl.setText(tr("monitoring.page_indicator", page=cur + 1, total=total))
page_prev_btn.setEnabled(cur > 0)
page_next_btn.setEnabled(cur < total - 1)
page_prev_btn.clicked.connect(table.prev_page)
page_next_btn.clicked.connect(table.next_page)
table.page_changed.connect(_refresh_pager)
_refresh_pager()
row.addWidget(page_prev_btn)
row.addWidget(page_indicator_lbl)
row.addWidget(page_next_btn)
parts.update(
page_size_label=page_size_lbl, page_size_combo=page_size_combo,
page_prev_btn=page_prev_btn, page_next_btn=page_next_btn,
page_indicator_label=page_indicator_lbl, page_pager_refresh=_refresh_pager)
lay.addLayout(row)
parts.update(filter_edit=search, ai_filter_btn=ai_btn)
@@ -34,6 +34,7 @@ class ActionLogsTab(QWidget):
self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"]
self.page_size_label = parts["page_size_label"]
self.page_pager_refresh = parts["page_pager_refresh"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
@@ -47,6 +48,7 @@ class ActionLogsTab(QWidget):
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label"))
self.page_pager_refresh()
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
+2
View File
@@ -34,6 +34,7 @@ class McpTab(QWidget):
self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"]
self.page_size_label = parts["page_size_label"]
self.page_pager_refresh = parts["page_pager_refresh"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
@@ -47,6 +48,7 @@ class McpTab(QWidget):
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label"))
self.page_pager_refresh()
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
@@ -40,6 +40,7 @@ class SecurityEventsTab(QWidget):
self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"]
self.page_size_label = parts["page_size_label"]
self.page_pager_refresh = parts["page_pager_refresh"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
@@ -53,6 +54,7 @@ class SecurityEventsTab(QWidget):
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label"))
self.page_pager_refresh()
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
@@ -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")
+16 -10
View File
@@ -19,6 +19,14 @@ set "APPHOME=%LOCALAPPDATA%\CoworkLocal"
set "VENV=%APPHOME%\venv"
set "LAUNCHER=%APPHOME%\launcher"
rem An cua so console NGAY TU DAU, ke ca trong luc kiem tra ben duoi — khong
rem chi truoc luc chay app. Moi cho bao loi (echo + pause) ben duoi tu hien
rem lai cua so truoc khi in, de thong bao van doc duoc.
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
if exist "%CONSOLE_VIS%" (
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
)
rem --------------------------------------------------------------------------
rem 1. Chon trinh thong dich
rem
@@ -38,6 +46,7 @@ if exist "%VENV%\Scripts\python.exe" (
)
if not defined RUNPY (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo.
echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã.
echo.
@@ -51,6 +60,7 @@ rem biet la phai chay install.bat.
if not exist "%VENV%\Scripts\python.exe" (
!RUNPY! -c "import PySide6" >nul 2>&1
if errorlevel 1 (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo.
echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã.
echo.
@@ -94,6 +104,7 @@ if /I "%REPO_NAME%"=="cowork_local" (
if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1
mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1
if errorlevel 1 (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo.
echo [LOI] Khong tao duoc lien ket thu muc:
echo "!PKGPATH!\cowork_local" -> "%REPO%"
@@ -112,6 +123,7 @@ rem Chot lai: goi phai THAT SU nhin thay duoc qua duong dan vua dung. Khong co
rem buoc nay thi mot junction hong chi hien ra duoi dang loi Python kho hieu
rem ("'cowork_local' is a package and cannot be directly executed").
if not exist "!PKGPATH!\cowork_local\__main__.py" (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo.
echo [LOI] Khong tim thay cowork_local\__main__.py qua duong dan:
echo "!PKGPATH!"
@@ -139,18 +151,12 @@ set "PYTHONIOENCODING=utf-8"
cd /d "%REPO%"
rem --------------------------------------------------------------------------
rem 4. An cua so console trong luc chay
rem 4. Chay app
rem
rem App la GUI (Qt), khong can console — nhung no chia se console cua chinh
rem cmd nay (khong tu mo cua so rieng), nen cua so den cua run.bat cu the
rem hien suot phien lam viec neu khong lam gi. An no ngay truoc khi chay, roi
rem chi hien lai NEU app thoat loi, de thong bao loi ben duoi van doc duoc.
rem App la GUI (Qt), khong can console — cua so console da bi an tu dau file
rem roi (xem khoi CONSOLE_VIS phia tren), chi hien lai NEU app thoat loi, de
rem thong bao loi ben duoi van doc duoc.
rem --------------------------------------------------------------------------
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
if exist "%CONSOLE_VIS%" (
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
)
!RUNPY! -m cowork_local %*
set "RC=%ERRORLEVEL%"
+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.
+55
View File
@@ -0,0 +1,55 @@
"""DF-002 (phần b) — node đang chạy HOẶC đã chạy xong không cho edit thông
tin trong Node. Trước khi sửa, ``_LOCKED_NODE_STATUSES`` khoá cả STEP_RUNNING
lẫn STEP_DONE, nên một bước đã chạy xong không bao giờ sửa lại được nữa.
Fix: chỉ khoá khi bước ĐANG chạy (STEP_RUNNING) — chạy xong rồi thì mở khoá
trở lại. Test này chốt cả nguồn sự thật (tuple
``co4e_workflow_crud._LOCKED_NODE_STATUSES``) lẫn hành vi ở widget
(``StepConfigPanel.set_locked``), để không bị hồi quy về hành vi cũ.
"""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.core.co4e import (
STEP_DONE, STEP_ERROR, STEP_IDLE, STEP_PLANNED, STEP_RUNNING,
)
from cowork_local.presentation.co4e.co4e_workflow_crud import _LOCKED_NODE_STATUSES
from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
def test_only_running_status_is_locked() -> None:
"""Một bước đã chạy xong (STEP_DONE) phải sửa lại được — chỉ bước đang
thực sự chạy (STEP_RUNNING) mới bị khoá."""
assert _LOCKED_NODE_STATUSES == (STEP_RUNNING,)
assert STEP_DONE not in _LOCKED_NODE_STATUSES
assert STEP_IDLE not in _LOCKED_NODE_STATUSES
assert STEP_ERROR not in _LOCKED_NODE_STATUSES
assert STEP_PLANNED not in _LOCKED_NODE_STATUSES
def test_set_locked_disables_then_reenables_edit_fields(qapp) -> None:
panel = StepConfigPanel()
panel.setEnabled(True) # panel starts disabled until a step is loaded
panel.set_locked(True)
assert not panel.label_edit.isEnabled()
assert not panel.instructions_edit.isEnabled()
assert not panel.model_combo.isEnabled()
panel.set_locked(False)
assert panel.label_edit.isEnabled()
assert panel.instructions_edit.isEnabled()
assert panel.model_combo.isEnabled()
+89 -19
View File
@@ -1,9 +1,11 @@
"""DF-006 — the "Số dòng/trang" (rows per page) control: EventTable's
page-size state (presentation/monitoring/shared/event_table.py) and its
QComboBox wiring in build_filter_scaffold (.../shared/filter_scaffold.py).
No dedicated test existed for this before — the design called for a
user-visible/choosable item-per-page control, and this exercises it end to
end (combo selection -> EventTable actually re-trimming its rows)."""
"""DF-006 — the "Số dòng/trang" (rows per page) control plus real prev/next
pagination: EventTable's page-size/page-index state
(presentation/monitoring/shared/event_table.py) and its QComboBox + pager
button wiring in build_filter_scaffold (.../shared/filter_scaffold.py).
Options are 5/10/20/50/100 with a next/prev pager, per the QA follow-up on
DF-006 — the earlier fix only trimmed to a page size (dropping every row past
it with no way back); this exercises the real paging end to end."""
from __future__ import annotations
import os
@@ -32,19 +34,62 @@ def _events(n: int):
"name": f"e{i}", "ok": True, "detail": ""} for i in range(n)]
def test_default_page_size_matches_old_max_rows(qapp) -> None:
table = EventTable()
assert table.page_size() == 300
table.set_events(_events(500))
assert table.rowCount() == 300
def test_page_size_options_are_5_10_20_50_100() -> None:
assert PAGE_SIZE_OPTIONS == (5, 10, 20, 50, 100)
def test_set_page_size_retrims_without_reloading(qapp) -> None:
def test_default_page_size(qapp) -> None:
table = EventTable()
table.set_events(_events(500))
assert table.page_size() == 20
table.set_events(_events(45))
assert table.rowCount() == 20
assert table.page_count() == 3
assert table.current_page() == 0
def test_set_page_size_resets_to_first_page(qapp) -> None:
table = EventTable()
table.set_events(_events(45))
table.next_page()
assert table.current_page() == 1
table.set_page_size(50)
assert table.page_size() == 50
assert table.rowCount() == 50
assert table.current_page() == 0
assert table.rowCount() == 45 # only 45 events total, fits in one page of 50
def test_next_prev_page_navigate_without_dropping_rows(qapp) -> None:
table = EventTable()
table.set_events(_events(45))
table.set_page_size(20)
assert table.rowCount() == 20
table.next_page()
assert table.current_page() == 1
assert table.rowCount() == 20
table.next_page()
assert table.current_page() == 2
assert table.rowCount() == 5 # last page: remainder
table.next_page() # already on last page — stays put
assert table.current_page() == 2
table.prev_page()
assert table.current_page() == 1
table.prev_page()
table.prev_page() # already on first page — stays put
assert table.current_page() == 0
def test_page_changed_signal_reports_current_and_total(qapp) -> None:
table = EventTable()
seen = []
table.page_changed.connect(lambda cur, total: seen.append((cur, total)))
table.set_events(_events(45))
table.set_page_size(20)
table.next_page()
assert seen[-1] == (1, 3)
def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
@@ -52,19 +97,44 @@ def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
table = EventTable()
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=False)
assert "page_size_combo" not in parts
assert "page_prev_btn" not in parts
def test_page_size_combo_changes_the_table(qapp) -> None:
page = QWidget()
table = EventTable()
table.set_events(_events(500))
table.set_events(_events(45))
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
combo = parts["page_size_combo"]
assert combo.count() == len(PAGE_SIZE_OPTIONS)
assert combo.currentData() == 300 # matches EventTable's current page_size
assert combo.currentData() == 20 # matches EventTable's current page_size
idx = PAGE_SIZE_OPTIONS.index(50)
idx = PAGE_SIZE_OPTIONS.index(10)
combo.setCurrentIndex(idx)
assert table.page_size() == 50
assert table.rowCount() == 50
assert table.page_size() == 10
assert table.rowCount() == 10
def test_pager_buttons_disable_at_bounds_and_indicator_updates(qapp) -> None:
page = QWidget()
table = EventTable()
table.set_events(_events(45))
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
table.set_page_size(20)
prev_btn, next_btn = parts["page_prev_btn"], parts["page_next_btn"]
indicator = parts["page_indicator_label"]
assert not prev_btn.isEnabled()
assert next_btn.isEnabled()
assert indicator.text() == "Trang 1/3"
next_btn.click()
assert prev_btn.isEnabled()
assert next_btn.isEnabled()
assert indicator.text() == "Trang 2/3"
next_btn.click()
assert prev_btn.isEnabled()
assert not next_btn.isEnabled()
assert indicator.text() == "Trang 3/3"
+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: