The Gitea repo was initialised from an earlier snapshot, so main and the machine this runs on had drifted apart in 153 files before any UI work started. This commit brings the branch up to the local tree as it stood on 2026-08-15 21:31 (from cowork_local.7z), so the redesign that follows shows up as its own reviewable diff instead of being mixed in with the pre-existing divergence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
874 lines
41 KiB
Python
874 lines
41 KiB
Python
"""
|
|
pages (Dashboard / Schedule / Workspace / Cowork / Structure) and top bar."""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import List
|
|
|
|
from PySide6.QtCore import Qt, QTimer
|
|
from PySide6.QtGui import QGuiApplication, QIcon
|
|
from PySide6.QtWidgets import (
|
|
QApplication, QComboBox, QHBoxLayout, QLabel, QMainWindow, QMenu,
|
|
QPushButton, QSizePolicy, QSplitter, QStackedWidget, QSystemTrayIcon,
|
|
QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,
|
|
)
|
|
|
|
from . import APP_NAME, DISPLAY_NAME, __version__
|
|
from .config import PROVIDER_LABELS, AppConfig
|
|
from .i18n import LANGUAGE_SHORT, LANGUAGES, get_language, on_language_changed, set_language, tr
|
|
from .state import AppContext
|
|
from .theme import current_palette, set_active_theme, stylesheet
|
|
from .core.task_scheduler import TaskScheduler
|
|
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"
|
|
|
|
# Nav rail (sidebar navigation) widths — expanded shows icon+label, collapsed
|
|
# shows icon-only (still fully clickable, just narrower).
|
|
_NAV_EXPANDED_WIDTH = 150
|
|
_NAV_COLLAPSED_WIDTH = 54
|
|
|
|
|
|
def app_icon() -> QIcon:
|
|
"""The buffalo app icon, used everywhere (window title bar, Windows taskbar and
|
|
the tray). The multi-size ``.ico`` is loaded FIRST so Windows has the right
|
|
pixmap for the taskbar; the high-res ``.png`` is added so the icon stays crisp
|
|
at large sizes. This keeps the taskbar icon identical to the app's icon."""
|
|
icon = QIcon()
|
|
for name in ("icon.ico", "icon.png"):
|
|
path = ASSETS / name
|
|
if path.exists():
|
|
icon.addFile(str(path))
|
|
return icon
|
|
|
|
|
|
class _Toast(QLabel):
|
|
"""A small auto-hiding notification shown at the window's top-left."""
|
|
|
|
def __init__(self, parent):
|
|
super().__init__(parent)
|
|
self.setObjectName("toast")
|
|
self.setWordWrap(True)
|
|
self.setMaximumWidth(380)
|
|
self.setVisible(False)
|
|
self._timer = QTimer(self)
|
|
self._timer.setSingleShot(True)
|
|
self._timer.timeout.connect(self.hide)
|
|
|
|
def show_message(self, text: str, ok: bool = True, ms: int = 4500) -> None:
|
|
p = current_palette()
|
|
bg = p.success_soft if ok else p.danger_soft
|
|
fg = p.success if ok else p.danger
|
|
self.setStyleSheet(
|
|
f"#toast {{ background:{bg}; color:{fg}; border:1px solid {fg};"
|
|
f" border-radius:{p.radius}px; padding:10px 16px; font-weight:600; }}")
|
|
self.setText(text)
|
|
self.adjustSize()
|
|
self.move(14, 14) # top-left of the window
|
|
self.raise_()
|
|
self.setVisible(True)
|
|
self._timer.start(ms)
|
|
|
|
|
|
class MainWindow(QMainWindow):
|
|
# Nav rows (Dashboard/Schedule/Monitoring are lazy; Workspace is the eager home page).
|
|
_ROW_DASHBOARD, _ROW_SCHEDULE, _ROW_WORKSPACE, _ROW_MONITORING = 0, 1, 2, 3
|
|
|
|
def __init__(self, ctx: AppContext, user_name: str = ""):
|
|
super().__init__()
|
|
self.ctx = ctx
|
|
self._user_name = user_name
|
|
self._really_quit = False
|
|
self.tray = None
|
|
self._nav_collapsed = False # icon-only nav rail toggle (Task: collapsible nav)
|
|
self._history_collapsed = False # remembers History's own collapse-to-strip state
|
|
self.setWindowTitle(f"{DISPLAY_NAME} v{__version__}")
|
|
self.setWindowIcon(app_icon())
|
|
# Fit to the available screen so the window never opens larger than the
|
|
# monitor (auto-fit). Keep a modest minimum that still fits small laptops.
|
|
self._fit_to_screen(1180, 760)
|
|
|
|
self.sidebar = HistorySidebar(ctx)
|
|
# Task scheduler ENGINE runs in the background whether or not its Kanban
|
|
# UI (built lazily) is on screen — scheduled tasks must fire regardless.
|
|
self.task_scheduler = TaskScheduler(ctx, parent=self)
|
|
# Desktop notification when a scheduled task finishes; also refresh
|
|
# History — a cowork/code task run saves itself as a new session there.
|
|
self.task_scheduler.task_finished.connect(self._on_scheduled_task_done)
|
|
# NOTE: task_started fires BEFORE the worker thread even begins, so its
|
|
# session doesn't exist on disk yet — refreshing History here would
|
|
# find nothing. history_ready fires once the session is actually
|
|
# saved (right as the run starts, then again after each turn), which
|
|
# is what really makes a Running task's session show up live.
|
|
self.task_scheduler.history_ready.connect(lambda _tid: self._refresh_history())
|
|
|
|
# Cowork chat + GraphRAG view are embedded as sub-tabs INSIDE the
|
|
# Workspace screen (per selected project). GraphRAG's heavy
|
|
# QtWebEngine is still built lazily on first display
|
|
# (StructureGraphView._ensure_web).
|
|
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.cowork.status_message.connect(self.statusBar().showMessage)
|
|
# Refresh History (list + running markers + current highlight) whenever a
|
|
# conversation is created/updated or a turn finishes.
|
|
self.cowork.turn_finished.connect(lambda *_: self._refresh_history())
|
|
self.cowork.history_changed.connect(self._refresh_history)
|
|
self.cowork.turn_finished.connect(
|
|
lambda result: self._notify_task(self.cowork, "cowork", result))
|
|
|
|
# Workspace screen — the app HOME: project management + the per-project
|
|
# Cowork / GraphRAG sub-tabs and History.
|
|
self.workspace = WorkspaceTab(ctx, cowork=self.cowork, structure=self.structure,
|
|
sidebar=self.sidebar)
|
|
self.workspace.status_message.connect(self.statusBar().showMessage)
|
|
self.workspace.projects_changed.connect(self._on_projects_changed)
|
|
self.workspace.open_chat.connect(lambda *_: self._refresh_history())
|
|
self.workspace.new_chat.connect(lambda *_: self._refresh_history())
|
|
|
|
# Dashboard + Schedule pages are built lazily on first visit (lazy page
|
|
# creation — keeps startup light); None until then.
|
|
self.dashboard = None
|
|
self.schedule = None
|
|
self.monitoring = None
|
|
|
|
# --- right side: top bar + pages (nav rail drives the stack) ---
|
|
right = QWidget()
|
|
right.setObjectName("contentArea")
|
|
rlay = QVBoxLayout(right)
|
|
rlay.setContentsMargins(10, 10, 10, 10)
|
|
rlay.setSpacing(10)
|
|
rlay.addWidget(self._build_topbar())
|
|
|
|
self.pages = QStackedWidget()
|
|
# (i18n key, icon, builder-or-None, eager-widget-or-None) — page index == list index
|
|
self._nav_defs = [
|
|
("app.tab.dashboard", "dashboard", self._build_dashboard, None),
|
|
("app.tab.schedule", "schedule", self._build_schedule, None),
|
|
("app.tab.workspace", "workspaces", None, self.workspace),
|
|
("app.tab.monitoring", "monitoring", self._build_monitoring, None),
|
|
]
|
|
# Container pages whose sub-tabs become expandable nav children.
|
|
self._nav_parents = {self._ROW_WORKSPACE, self._ROW_MONITORING}
|
|
self._page_widgets = [] # page index → widget (placeholder until lazily built)
|
|
self._built = []
|
|
for _key, _icon_name, _builder, widget in self._nav_defs:
|
|
page = widget if widget is not None else QWidget()
|
|
self.pages.addWidget(page)
|
|
self._page_widgets.append(page)
|
|
self._built.append(widget is not None)
|
|
|
|
# Left nav rail as a parent→child accordion (Claude-style): the container
|
|
# pages (Workspaces, Monitoring) expand to list their sub-views as
|
|
# children, and their in-content tab strips are hidden — so the content
|
|
# area is as large as possible.
|
|
from .ui.icons import icon as _icon
|
|
self.nav = QTreeWidget()
|
|
self.nav.setObjectName("navrail")
|
|
self.nav.setHeaderHidden(True)
|
|
self.nav.setIndentation(14)
|
|
self.nav.setRootIsDecorated(True)
|
|
self.nav.setExpandsOnDoubleClick(False)
|
|
self._nav_items = [] # page index → top-level QTreeWidgetItem
|
|
for page, (key, icon_name, _b, _w) in enumerate(self._nav_defs):
|
|
it = QTreeWidgetItem([tr(key)])
|
|
it.setIcon(0, _icon(icon_name))
|
|
it.setData(0, Qt.UserRole, {"page": page, "sub": None,
|
|
"parent": page in self._nav_parents, "key": key})
|
|
# A container (Monitoring/Workspace) always shows the dropdown arrow —
|
|
# even before its page/children are lazily built — so it's obvious it
|
|
# holds multiple sub-views. It stays collapsed until first expanded.
|
|
if page in self._nav_parents:
|
|
it.setChildIndicatorPolicy(QTreeWidgetItem.ShowIndicator)
|
|
self.nav.addTopLevelItem(it)
|
|
self._nav_items.append(it)
|
|
self.workspace.hide_tab_bar()
|
|
self._reload_nav_children(self._ROW_WORKSPACE) # Workspace is eager
|
|
self.workspace.subtabs_changed.connect(
|
|
lambda: self._reload_nav_children(self._ROW_WORKSPACE))
|
|
self.nav.currentItemChanged.connect(lambda cur, _prev: self._navigate(cur))
|
|
self.nav.itemClicked.connect(self._on_nav_click)
|
|
self.nav.itemExpanded.connect(self._on_nav_expanded) # build children lazily
|
|
rlay.addWidget(self.pages, 1)
|
|
|
|
# Nav rail wrapper: a small toggle button ABOVE the page list so the
|
|
# whole rail can collapse to icon-only (still fully clickable). Same
|
|
# collapse/expand chevron iconography as every other collapsible panel.
|
|
from .ui.icons import collapse_left_icon, collapse_right_icon
|
|
self._collapse_left_icon = collapse_left_icon
|
|
self._collapse_right_icon = collapse_right_icon
|
|
self._nav_wrap = QWidget()
|
|
self._nav_wrap.setObjectName("navWrap")
|
|
self._nav_wrap.setFixedWidth(_NAV_EXPANDED_WIDTH)
|
|
nvl = QVBoxLayout(self._nav_wrap)
|
|
nvl.setContentsMargins(0, 0, 0, 0)
|
|
nvl.setSpacing(0)
|
|
# Small, left-aligned "MENU" button (icon + label) instead of a
|
|
# full-width centered icon — sits flush with the rail's left edge,
|
|
# matching how the nav items themselves align their icon+label.
|
|
self._nav_toggle_btn = QPushButton(tr("app.nav.menu_label"))
|
|
self._nav_toggle_btn.setIcon(collapse_left_icon())
|
|
self._nav_toggle_btn.setObjectName("navMenuBtn")
|
|
self._nav_toggle_btn.setFlat(True)
|
|
self._nav_toggle_btn.setCursor(Qt.PointingHandCursor)
|
|
self._nav_toggle_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
|
self._nav_toggle_btn.clicked.connect(self._toggle_nav)
|
|
# Zero left margin: the button's own QSS padding (6px) then lines its
|
|
# 16px icon up with the nav items' icons below (1px list frame + item
|
|
# padding) — same indent level, same icon size as e.g. Dashboard.
|
|
toggle_row = QHBoxLayout()
|
|
toggle_row.setContentsMargins(0, 8, 10, 8)
|
|
toggle_row.addWidget(self._nav_toggle_btn, 0, Qt.AlignLeft)
|
|
toggle_row.addStretch(1)
|
|
nvl.addLayout(toggle_row)
|
|
nvl.addWidget(self.nav, 1)
|
|
|
|
self.split = QSplitter(Qt.Horizontal)
|
|
self.split.addWidget(self._nav_wrap)
|
|
self.split.addWidget(right)
|
|
self.split.setStretchFactor(0, 0)
|
|
self.split.setStretchFactor(1, 1)
|
|
self.split.setSizes([_NAV_EXPANDED_WIDTH, 1000])
|
|
self.setCentralWidget(self.split)
|
|
# Workspace = landing/home (expand it and select its first sub-view).
|
|
self._nav_items[self._ROW_WORKSPACE].setExpanded(True)
|
|
self.nav.setCurrentItem(self._nav_items[self._ROW_WORKSPACE])
|
|
self.toast = _Toast(self) # top-left "task done" popup
|
|
# Floating in-app Help assistant — a robot icon pinned bottom-right on
|
|
# every screen; expands into a small help-only chat (see
|
|
# ui/help_agent_widget.py). Managed in Monitoring → Agents Admin.
|
|
from .ui.help_agent_widget import HelpAgentWidget
|
|
self.help_agent = HelpAgentWidget(ctx, self, user_name=self._user_name)
|
|
self.help_agent.status_message.connect(self.statusBar().showMessage)
|
|
|
|
self.statusBar().showMessage(tr("app.status.ready"))
|
|
# Author credit, pinned to the bottom-right corner. A permanent status-bar
|
|
# widget sits at the right end and is never cleared by showMessage (which
|
|
# writes on the left).
|
|
self._credit = QLabel(tr("app.credit"))
|
|
self._credit.setObjectName("faint")
|
|
self._credit.setStyleSheet("padding: 0 10px;")
|
|
self.statusBar().addPermanentWidget(self._credit)
|
|
self._restore_sessions()
|
|
self._setup_tray()
|
|
# Start the task scheduler last, once the whole window exists — it
|
|
# catches up any overdue tasks right away (first tick runs inline).
|
|
self.task_scheduler.start()
|
|
# Auto Model Routing: periodic reassess + pending-switch expiry. Runs
|
|
# background probes only when genuinely due (never a burst at launch).
|
|
try:
|
|
from .core.routing.scheduler import RoutingScheduler
|
|
self.routing_scheduler = RoutingScheduler(self.ctx, self.ctx.routing(), parent=self)
|
|
self.routing_scheduler.start()
|
|
except Exception: # noqa: BLE001 — routing must never block app startup
|
|
self.routing_scheduler = None
|
|
on_language_changed(self._retranslate)
|
|
|
|
def resizeEvent(self, event): # noqa: N802 - Qt override
|
|
super().resizeEvent(event)
|
|
# Keep the floating Help assistant pinned to the bottom-right corner.
|
|
if getattr(self, "help_agent", None) is not None:
|
|
self.help_agent.reposition()
|
|
|
|
def showEvent(self, event): # noqa: N802 - Qt override
|
|
super().showEvent(event)
|
|
if getattr(self, "help_agent", None) is not None:
|
|
self.help_agent.reposition()
|
|
self.help_agent.raise_()
|
|
|
|
# ---- i18n ----------------------------------------------------------
|
|
def _retranslate(self) -> None:
|
|
"""Re-apply the current language to this window's own static chrome
|
|
(tabs are the only long-lived text here; the tabs/dialogs retranslate
|
|
themselves)."""
|
|
self._apply_nav_labels()
|
|
self._nav_toggle_btn.setText("" if self._nav_collapsed else tr("app.nav.menu_label"))
|
|
self._nav_toggle_btn.setToolTip(
|
|
tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip"))
|
|
self._credit.setText(tr("app.credit"))
|
|
if hasattr(self, "provider_lbl"):
|
|
self.provider_lbl.setText(tr("app.provider"))
|
|
if hasattr(self, "settings_btn"):
|
|
self.settings_btn.setText(tr("app.settings"))
|
|
if hasattr(self, "theme_btn"):
|
|
self.theme_btn.setToolTip(tr("settings.theme"))
|
|
for value, act in self._theme_actions.items():
|
|
act.setText(tr(f"settings.theme_{value}"))
|
|
if hasattr(self, "logo_lbl"):
|
|
self.logo_lbl.setText(tr("app.logo"))
|
|
if getattr(self, "help_agent", None) is not None:
|
|
self.help_agent.retranslate()
|
|
if self.tray is not None:
|
|
self.tray.setToolTip(DISPLAY_NAME)
|
|
if hasattr(self, "_tray_open_act"):
|
|
self._tray_open_act.setText(tr("app.tray.open"))
|
|
self._tray_quit_act.setText(tr("app.tray.quit"))
|
|
|
|
# ---- system tray (run in background when the window is closed) ---
|
|
def _setup_tray(self) -> None:
|
|
from PySide6.QtGui import QAction
|
|
|
|
if not QSystemTrayIcon.isSystemTrayAvailable():
|
|
return
|
|
self.tray = QSystemTrayIcon(app_icon(), self)
|
|
self.tray.setToolTip(DISPLAY_NAME)
|
|
menu = QMenu()
|
|
self._tray_open_act = QAction(tr("app.tray.open"), self)
|
|
self._tray_open_act.triggered.connect(self._show_window)
|
|
self._tray_quit_act = QAction(tr("app.tray.quit"), self)
|
|
self._tray_quit_act.triggered.connect(self._quit_app)
|
|
menu.addAction(self._tray_open_act)
|
|
menu.addAction(self._tray_quit_act)
|
|
self.tray.setContextMenu(menu)
|
|
self.tray.activated.connect(
|
|
lambda reason: self._show_window() if reason == QSystemTrayIcon.Trigger else None)
|
|
self.tray.show()
|
|
|
|
def _page_index(self, widget) -> int:
|
|
return self.pages.indexOf(widget)
|
|
|
|
# ---- lazy page building -------------------------------------------
|
|
def _build_dashboard(self):
|
|
d = DashboardTab(self.ctx)
|
|
d.status_message.connect(self.statusBar().showMessage)
|
|
self.dashboard = d
|
|
return d
|
|
|
|
def _build_schedule(self):
|
|
s = ScheduleTaskTab(self.ctx, self.task_scheduler)
|
|
s.status_message.connect(self.statusBar().showMessage)
|
|
self.schedule = s
|
|
return s
|
|
|
|
def _build_monitoring(self):
|
|
m = MonitoringTab(self.ctx, cowork=self.cowork, structure=self.structure,
|
|
task_scheduler=self.task_scheduler)
|
|
m.status_message.connect(self.statusBar().showMessage)
|
|
self.monitoring = m
|
|
return m
|
|
|
|
def _ensure_page(self, row: int) -> None:
|
|
"""Build a lazy nav page on first visit and swap it in for its placeholder."""
|
|
if not (0 <= row < len(self._built)) or self._built[row]:
|
|
return
|
|
builder = self._nav_defs[row][2]
|
|
if builder is None:
|
|
return
|
|
real = builder()
|
|
placeholder = self._page_widgets[row]
|
|
self.pages.insertWidget(row, real) # placeholder shifts to row+1
|
|
self.pages.removeWidget(placeholder)
|
|
placeholder.deleteLater()
|
|
self._page_widgets[row] = real
|
|
self._built[row] = True
|
|
# Container pages: hide their in-content tab strip + list their sub-views
|
|
# as children in the nav rail now that the real widget exists.
|
|
if hasattr(real, "hide_tab_bar"):
|
|
real.hide_tab_bar()
|
|
if row in self._nav_parents:
|
|
self._reload_nav_children(row)
|
|
|
|
def _page_index(self, widget) -> int:
|
|
if widget is self.workspace:
|
|
return self._ROW_WORKSPACE
|
|
if self.dashboard is not None and widget is self.dashboard:
|
|
return self._ROW_DASHBOARD
|
|
if self.schedule is not None and widget is self.schedule:
|
|
return self._ROW_SCHEDULE
|
|
if self.monitoring is not None and widget is self.monitoring:
|
|
return self._ROW_MONITORING
|
|
return self.pages.indexOf(widget)
|
|
|
|
# ---- nav rail collapse (icon-only) --------------------------------
|
|
def _apply_nav_labels(self) -> None:
|
|
"""Set each top-level nav item's text for the current language AND
|
|
collapse state: collapsed shows icon-only (label → tooltip) and folds
|
|
the accordion so only the top-level icons show."""
|
|
for page, item in enumerate(self._nav_items):
|
|
key = self._nav_defs[page][0]
|
|
label = tr(key)
|
|
item.setText(0, "" if self._nav_collapsed else label)
|
|
item.setToolTip(0, label if self._nav_collapsed else "")
|
|
if self._nav_collapsed:
|
|
item.setExpanded(False)
|
|
# Refresh child labels (language-aware, from each container's tabText).
|
|
if not self._nav_collapsed:
|
|
for page in self._nav_parents:
|
|
if self._built[page]:
|
|
self._reload_nav_children(page)
|
|
|
|
def _toggle_nav(self) -> None:
|
|
self._nav_collapsed = not self._nav_collapsed
|
|
width = _NAV_COLLAPSED_WIDTH if self._nav_collapsed else _NAV_EXPANDED_WIDTH
|
|
self._nav_wrap.setFixedWidth(width)
|
|
self._apply_nav_labels()
|
|
# Same chevron convention as every other collapsible panel: right-
|
|
# pointing (fill-right) means "click to expand", left means "collapse".
|
|
self._nav_toggle_btn.setIcon(
|
|
self._collapse_right_icon() if self._nav_collapsed else self._collapse_left_icon())
|
|
# Collapsed rail is icon-only (54px) — the "MENU" label wouldn't fit
|
|
# next to the icon, same rule the nav items themselves follow.
|
|
self._nav_toggle_btn.setText("" if self._nav_collapsed else tr("app.nav.menu_label"))
|
|
self._nav_toggle_btn.setToolTip(
|
|
tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip"))
|
|
# Give/reclaim the width difference to the main content pane.
|
|
sizes = self.split.sizes()
|
|
if len(sizes) == 2:
|
|
diff = sizes[0] - width
|
|
sizes[0] = width
|
|
sizes[1] = max(1, sizes[1] + diff)
|
|
self.split.setSizes(sizes)
|
|
|
|
def _running_session_ids(self):
|
|
"""All conversation ids currently running — interactive Cowork/Code
|
|
chat tab AgentWorkers, plus Schedule Task runs (their own session,
|
|
tracked by the scheduler), so a task's live run gets the same
|
|
"running" marker in History an interactive chat gets."""
|
|
return set(self.cowork.running_session_ids()) | self.task_scheduler.running_session_ids()
|
|
|
|
def _refresh_history(self) -> None:
|
|
"""Rebuild the History list with the current conversation highlighted and
|
|
the running ones marked. Deferred to the next event-loop tick: this is often
|
|
triggered (via load_conversation) from inside the sidebar's own item-click
|
|
handler, and clearing the tree there would delete the item mid-click."""
|
|
from PySide6.QtCore import QTimer
|
|
|
|
def _do() -> None:
|
|
current = self.cowork.session_id
|
|
self.sidebar.set_view_state(current, self._running_session_ids())
|
|
self.sidebar.refresh()
|
|
|
|
QTimer.singleShot(0, _do)
|
|
|
|
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 —
|
|
cowork/co4e task runs just saved themselves as new sessions there."""
|
|
from .core.tasks import load_task
|
|
|
|
task = load_task(task_id) or {}
|
|
title = task.get("title", "")
|
|
msg = (tr("app.toast.task_done", title=title) if ok
|
|
else tr("app.toast.task_failed", title=title))
|
|
self.toast.show_message(msg, ok=ok)
|
|
if (self.tray is not None
|
|
and self.ctx.config.data.get("tray", {}).get("notify_on_done", True)
|
|
and not self.isActiveWindow()):
|
|
try:
|
|
self.tray.showMessage(
|
|
DISPLAY_NAME, msg,
|
|
QSystemTrayIcon.Information if ok else QSystemTrayIcon.Warning, 5000)
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
self._refresh_history()
|
|
|
|
def _notify_task(self, tab, kind: str, result: dict) -> None:
|
|
"""Notify when a task finishes/fails (skip if more stages queued)."""
|
|
if tab.composer.has_queue():
|
|
return # a flow / queue is still running — notify only at the end
|
|
name = tr(f"app.tab.{kind}")
|
|
err = (result or {}).get("error")
|
|
# In-app popup at the top-left (shown whether or not the window is focused).
|
|
self.toast.show_message(
|
|
tr("app.toast.error", name=name) if err else tr("app.toast.done", name=name), ok=not err)
|
|
# System-tray balloon only when the window isn't the active one.
|
|
if self.tray is None:
|
|
return
|
|
if not self.ctx.config.data.get("tray", {}).get("notify_on_done", True):
|
|
return
|
|
if self.isActiveWindow():
|
|
return # user is looking at the window already
|
|
err = (result or {}).get("error")
|
|
title = tr("app.toast.error", name=name) if err else tr("app.toast.done", name=name)
|
|
body = (err if err else (tab._last_assistant_text() or "Task completed."))[:140]
|
|
icon = QSystemTrayIcon.Critical if err else QSystemTrayIcon.Information
|
|
try:
|
|
self.tray.showMessage(title, body, icon, 5000)
|
|
except Exception:
|
|
pass
|
|
|
|
def _show_window(self) -> None:
|
|
self.showNormal()
|
|
self.raise_()
|
|
self.activateWindow()
|
|
|
|
def _quit_app(self) -> None:
|
|
self._really_quit = True
|
|
self.close()
|
|
|
|
def _restore_sessions(self) -> None:
|
|
"""Reopen the last conversation per tab (recover after a crash/abrupt exit)."""
|
|
from pathlib import Path
|
|
|
|
from .core.history import load_conversation
|
|
|
|
last = self.ctx.config.data.get("last_session", {})
|
|
path = last.get("cowork", "")
|
|
if path and Path(path).exists():
|
|
try:
|
|
self.cowork.load_conversation(load_conversation(path))
|
|
# Reflect the restored thread's project in the Workspace home
|
|
# (selecting the matching row won't wipe it — the project id
|
|
# already matches, so _bind_project starts no new session).
|
|
# Skip forcing the Cowork tab open for a project that no
|
|
# longer exists (deleted since this session was saved) — that
|
|
# would show the Cowork page while the tab strip still says
|
|
# "no project selected" (see WorkspaceTab._on_sidebar_open).
|
|
pid = self.cowork.project_id
|
|
if pid in ("", "default") or self.workspace._select_project_row(pid):
|
|
self.workspace._show_cowork_tab()
|
|
except Exception:
|
|
pass
|
|
|
|
# ---- top bar -----------------------------------------------------
|
|
def _build_topbar(self) -> QWidget:
|
|
bar = QWidget()
|
|
bar.setObjectName("topbar")
|
|
# Styled centrally (see theme._TEMPLATE): flat, with a single hairline
|
|
# separating it from the content below — no card box behind it.
|
|
h = QHBoxLayout(bar)
|
|
h.setContentsMargins(16, 10, 12, 10)
|
|
h.setSpacing(10)
|
|
# FPT logo slot in front of the brand text: shown only when a logo
|
|
# image has been dropped into assets/ (see _brand_logo_pixmap) — the
|
|
# brand works text-only until the real artwork is supplied.
|
|
self.logo_img = QLabel()
|
|
logo_pm = self._brand_logo_pixmap()
|
|
if logo_pm is not None:
|
|
self.logo_img.setPixmap(logo_pm)
|
|
else:
|
|
self.logo_img.setVisible(False)
|
|
h.addWidget(self.logo_img)
|
|
self.logo_lbl = QLabel(tr("app.logo"))
|
|
self.logo_lbl.setObjectName("brand") # styled centrally — see theme._TEMPLATE
|
|
h.addWidget(self.logo_lbl)
|
|
h.addStretch(1)
|
|
|
|
self.provider_lbl = QLabel(tr("app.provider"))
|
|
self.provider_lbl.setObjectName("hint")
|
|
h.addWidget(self.provider_lbl)
|
|
self.provider_combo = QComboBox()
|
|
for key, label in PROVIDER_LABELS.items():
|
|
self.provider_combo.addItem(label, key)
|
|
idx = self.provider_combo.findData(self.ctx.config.active_provider)
|
|
if idx >= 0:
|
|
self.provider_combo.setCurrentIndex(idx)
|
|
self.provider_combo.currentIndexChanged.connect(self._on_provider_changed)
|
|
h.addWidget(self.provider_combo)
|
|
|
|
self.language_combo = QComboBox()
|
|
for key in LANGUAGES:
|
|
self.language_combo.addItem(LANGUAGE_SHORT.get(key, key.upper()), key)
|
|
self.language_combo.setItemData(
|
|
self.language_combo.count() - 1, LANGUAGES[key], Qt.ToolTipRole)
|
|
idx = self.language_combo.findData(get_language())
|
|
if idx >= 0:
|
|
self.language_combo.setCurrentIndex(idx)
|
|
self.language_combo.currentIndexChanged.connect(self._on_language_changed)
|
|
h.addWidget(self.language_combo)
|
|
|
|
self.theme_btn = self._build_theme_button()
|
|
h.addWidget(self.theme_btn)
|
|
|
|
if self._user_name:
|
|
user_lbl = QLabel(f"👤 {self._user_name}")
|
|
user_lbl.setObjectName("hint")
|
|
h.addWidget(user_lbl)
|
|
self.settings_btn = QPushButton(tr("app.settings"))
|
|
from .ui.icons import icon as _icon
|
|
self.settings_btn.setIcon(_icon("settings"))
|
|
self.settings_btn.clicked.connect(self._open_settings)
|
|
h.addWidget(self.settings_btn)
|
|
return bar
|
|
|
|
_BRAND_LOGO_NAMES = ("fpt_logo.png", "fpt-logo.png", "logo_fpt.png", "fpt_logo.jpg")
|
|
_BRAND_LOGO_HEIGHT = 22
|
|
|
|
def _brand_logo_pixmap(self):
|
|
"""The FPT logo scaled to top-bar height, or None while no logo file
|
|
exists yet — drop the artwork into src/cowork_local/assets/ under one
|
|
of the _BRAND_LOGO_NAMES and it appears on next launch."""
|
|
from PySide6.QtGui import QPixmap
|
|
|
|
for name in self._BRAND_LOGO_NAMES:
|
|
path = ASSETS / name
|
|
if not path.exists():
|
|
continue
|
|
pm = QPixmap(str(path))
|
|
if pm.isNull():
|
|
continue
|
|
return pm.scaledToHeight(self._BRAND_LOGO_HEIGHT, Qt.SmoothTransformation)
|
|
return None
|
|
|
|
_THEME_ICONS = {"system": "monitor", "dark": "moon", "light": "sun"}
|
|
|
|
def _build_theme_button(self) -> QToolButton:
|
|
"""A single icon button (System/Dark/Light) replacing the old
|
|
Settings-only theme dropdown — one click applies the choice
|
|
immediately via the existing _apply_theme(), no dialog round-trip."""
|
|
from .ui.icons import icon as _icon
|
|
|
|
btn = QToolButton()
|
|
btn.setPopupMode(QToolButton.InstantPopup)
|
|
menu = QMenu(btn)
|
|
self._theme_actions = {}
|
|
for value, icon_name in self._THEME_ICONS.items():
|
|
act = menu.addAction(_icon(icon_name), tr(f"settings.theme_{value}"))
|
|
act.triggered.connect(lambda _checked=False, v=value: self._set_theme(v))
|
|
self._theme_actions[value] = act
|
|
btn.setMenu(menu)
|
|
btn.setIcon(_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor")))
|
|
return btn
|
|
|
|
def _set_theme(self, value: str) -> None:
|
|
from .ui.icons import icon as _icon
|
|
|
|
self.ctx.config.theme = value
|
|
self.ctx.save()
|
|
self._apply_theme()
|
|
self.theme_btn.setIcon(_icon(self._THEME_ICONS.get(value, "monitor")))
|
|
|
|
# ---- handlers ----------------------------------------------------
|
|
def _on_provider_changed(self, _idx: int) -> None:
|
|
self.ctx.config.active_provider = self.provider_combo.currentData()
|
|
self.ctx.save()
|
|
self.cowork.refresh_header()
|
|
# Reload the Cowork tab's Agent (Model) list for the newly selected provider.
|
|
self.cowork.refresh_agents()
|
|
self.workspace.refresh_ai_models() # + the Folder AI-edit model picker
|
|
self.statusBar().showMessage(
|
|
tr("app.status.using_provider",
|
|
label=PROVIDER_LABELS.get(self.ctx.config.active_provider))
|
|
)
|
|
|
|
def _on_language_changed(self, _idx: int) -> None:
|
|
lang = self.language_combo.currentData()
|
|
if not lang or lang == get_language():
|
|
return
|
|
self.ctx.config.language = lang
|
|
self.ctx.save()
|
|
set_language(lang) # notifies every registered persistent widget
|
|
|
|
def _open_settings(self) -> None:
|
|
dlg = SettingsDialog(self.ctx, self)
|
|
if dlg.exec():
|
|
self._apply_theme()
|
|
set_language(self.ctx.config.language) # apply if changed in Settings
|
|
# reflect provider/theme/language changes
|
|
i = self.provider_combo.findData(self.ctx.config.active_provider)
|
|
if i >= 0:
|
|
self.provider_combo.setCurrentIndex(i)
|
|
li = self.language_combo.findData(get_language())
|
|
if li >= 0:
|
|
self.language_combo.blockSignals(True)
|
|
self.language_combo.setCurrentIndex(li)
|
|
self.language_combo.blockSignals(False)
|
|
self.cowork.refresh_header()
|
|
self.cowork.refresh_agents()
|
|
self.workspace.refresh_ai_models() # + the Folder AI-edit model picker
|
|
max_files = int(self.ctx.config.data.get("attachments", {}).get("max_files", 10) or 0)
|
|
self.cowork.composer.set_max_attachments(max_files)
|
|
self.sidebar.refresh()
|
|
self.statusBar().showMessage(tr("app.status.settings_saved"))
|
|
|
|
def _reload_nav_children(self, page: int) -> None:
|
|
"""(Re)build the nav children of a container page from its current
|
|
sub-views. Called when the page is built, when Workspace sub-tab
|
|
visibility changes, and on language change."""
|
|
if not (0 <= page < len(self._nav_items)):
|
|
return
|
|
item = self._nav_items[page]
|
|
expanded = item.isExpanded()
|
|
item.takeChildren()
|
|
widget = self._page_widgets[page]
|
|
if not hasattr(widget, "nav_subtabs"):
|
|
return
|
|
from .ui.icons import icon as _icon
|
|
for label, sub, icon_name in widget.nav_subtabs():
|
|
child = QTreeWidgetItem([label])
|
|
child.setIcon(0, _icon(icon_name))
|
|
child.setData(0, Qt.UserRole, {"page": page, "sub": sub, "parent": False})
|
|
item.addChild(child)
|
|
item.setExpanded(True)
|
|
|
|
def _on_nav_click(self, item, _col: int = 0) -> None:
|
|
# Clicking a parent toggles its expansion (its page is still shown).
|
|
data = item.data(0, Qt.UserRole) or {}
|
|
if data.get("parent"):
|
|
item.setExpanded(not item.isExpanded())
|
|
|
|
def _on_nav_expanded(self, item) -> None:
|
|
# Expanding a container whose children aren't built yet (e.g. Monitoring
|
|
# on first open, shown via its always-on dropdown arrow) builds its page
|
|
# so the sub-views appear.
|
|
data = item.data(0, Qt.UserRole) or {}
|
|
if data.get("parent") and item.childCount() == 0:
|
|
self._ensure_page(data.get("page", 0))
|
|
|
|
def _navigate(self, item) -> None:
|
|
if item is None:
|
|
return
|
|
data = item.data(0, Qt.UserRole) or {}
|
|
self._goto(data.get("page", 0), data.get("sub"))
|
|
|
|
def _goto(self, page: int, sub) -> None:
|
|
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
|
|
widget = self._page_widgets[page]
|
|
if sub is not None and hasattr(widget, "select_subtab"):
|
|
widget.select_subtab(sub)
|
|
# Switching pages updates which conversation is "current".
|
|
self._refresh_history()
|
|
|
|
def _on_projects_changed(self) -> None:
|
|
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
|
|
|
|
def _apply_theme(self) -> None:
|
|
app = QApplication.instance()
|
|
if app:
|
|
set_active_theme(self.ctx.config.theme)
|
|
app.setStyleSheet(stylesheet(self.ctx.config.theme))
|
|
# Re-apply theme styles to chat bubbles so they adapt to the new theme.
|
|
self.cowork.apply_theme()
|
|
if getattr(self, "help_agent", None) is not None:
|
|
self.help_agent.apply_theme() # chat body follows theme (header stays fixed)
|
|
|
|
# ---- sizing ------------------------------------------------------
|
|
def _fit_to_screen(self, want_w: int, want_h: int) -> None:
|
|
screen = self.screen() or QGuiApplication.primaryScreen()
|
|
avail = screen.availableGeometry() if screen else None
|
|
if avail is None:
|
|
self.resize(want_w, want_h)
|
|
return
|
|
margin = 60
|
|
w = min(want_w, avail.width() - margin)
|
|
h = min(want_h, avail.height() - margin)
|
|
# minimum must never exceed what the screen can show
|
|
self.setMinimumSize(min(820, avail.width() - margin), min(520, avail.height() - margin))
|
|
self.resize(max(w, 1), max(h, 1))
|
|
frame = self.frameGeometry()
|
|
frame.moveCenter(avail.center())
|
|
self.move(frame.topLeft())
|
|
|
|
# ---- lifecycle ---------------------------------------------------
|
|
def closeEvent(self, event) -> None: # noqa: N802
|
|
keep = (self.tray is not None
|
|
and self.ctx.config.data.get("tray", {}).get("minimize_on_close", True))
|
|
if keep and not self._really_quit:
|
|
# Keep running in the background; tasks continue and autosave.
|
|
event.ignore()
|
|
self.hide()
|
|
try:
|
|
self.tray.showMessage(
|
|
DISPLAY_NAME, tr("app.tray.running_body"),
|
|
QSystemTrayIcon.Information, 4000)
|
|
except Exception:
|
|
pass
|
|
return
|
|
# Real quit: stop every running turn (a tab may have several), then close.
|
|
self.task_scheduler.stop() # also stops any scheduled tasks
|
|
if getattr(self, "routing_scheduler", None) is not None:
|
|
self.routing_scheduler.stop()
|
|
for tab in (self.cowork,):
|
|
for w in tab.active_workers():
|
|
if w.isRunning():
|
|
w.request_stop()
|
|
w.wait(1500)
|
|
# Safely stop codebase-memory UI if the method exists
|
|
if hasattr(self.structure, 'stop_cmem_ui'):
|
|
self.structure.stop_cmem_ui()
|
|
self.ctx.stop_mcp_connections() # never leave a connected MCP server subprocess behind
|
|
if self.tray is not None:
|
|
self.tray.hide()
|
|
super().closeEvent(event)
|
|
|
|
|
|
def _set_windows_app_id() -> None:
|
|
"""Make Windows use our window icon on the taskbar (not python.exe's)."""
|
|
if sys.platform != "win32":
|
|
return
|
|
try:
|
|
import ctypes
|
|
|
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("FPT.CoworkLocal.2.0")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def run(argv: List[str] | None = None) -> int:
|
|
argv = argv if argv is not None else sys.argv
|
|
_set_windows_app_id()
|
|
app = QApplication.instance() or QApplication(argv)
|
|
app.setApplicationName(APP_NAME)
|
|
app.setWindowIcon(app_icon())
|
|
ctx = AppContext(AppConfig.load())
|
|
set_language(ctx.config.language)
|
|
# Built-in default skills (if any are bundled) are always-on and loaded
|
|
# straight from the package; tidy away any copy seeded by older versions so they
|
|
# stay hidden from the Skills manager.
|
|
try:
|
|
from .core.skills import prune_seeded_builtins
|
|
prune_seeded_builtins()
|
|
except Exception: # noqa: BLE001 - housekeeping must never block startup
|
|
pass
|
|
# Seed the bundled built-in skill library + the built-in Co4E flow into the
|
|
# user's editable stores on first run, so they show up in the Skill Manager
|
|
# and the Flow sidebar out-of-the-box (a user-deleted one is not re-seeded).
|
|
try:
|
|
from .core.skills import seed_library_skills
|
|
from .core.co4e_builtins import seed_builtin_flows
|
|
changed = False
|
|
# Content-versioned: returns the full tag list to persist (delivers updates
|
|
# to shipped skills, preserves user edits to unchanged ones, respects deletion).
|
|
skill_tags = seed_library_skills(ctx.config.seeded_library_skills)
|
|
if set(skill_tags) != set(ctx.config.seeded_library_skills):
|
|
ctx.config.seeded_library_skills = skill_tags
|
|
changed = True
|
|
new_flows = seed_builtin_flows(ctx.config.seeded_builtin_flows)
|
|
if new_flows:
|
|
ctx.config.seeded_builtin_flows = ctx.config.seeded_builtin_flows + new_flows
|
|
changed = True
|
|
if changed:
|
|
ctx.config.save()
|
|
except Exception: # noqa: BLE001 - seeding must never block startup
|
|
pass
|
|
set_active_theme(ctx.config.theme)
|
|
app.setStyleSheet(stylesheet(ctx.config.theme))
|
|
|
|
# Follow the OS light/dark scheme live when theme is "Auto (System)".
|
|
import socket
|
|
|
|
from .core import audit_log, usage_tracker
|
|
|
|
machine = socket.gethostname()
|
|
usage_tracker.set_identity("local", machine, "")
|
|
audit_log.set_identity("local", machine, "admin", "")
|
|
|
|
win = MainWindow(ctx, user_name="local")
|
|
|
|
def _reapply_system_theme(*_a):
|
|
if ctx.config.theme == "system":
|
|
set_active_theme("system")
|
|
app.setStyleSheet(stylesheet("system"))
|
|
win.cowork.apply_theme()
|
|
try:
|
|
app.styleHints().colorSchemeChanged.connect(_reapply_system_theme)
|
|
except Exception:
|
|
pass
|
|
|
|
win.show()
|
|
return app.exec()
|