Files
cowork-local/app.py
T
NamPDTandClaude Opus 5 291a611737 chore: sync local working copy as of 2026-08-15
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>
2026-08-17 11:38:18 +09:00

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()