## Summary What changed and why? ## Change Type - [ ] Cowork feature - [ ] Bug fix - [x] 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: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Co-authored-by: NamPDT <minhanhpkpro@gmail.com> Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
+2
-2
@@ -28,7 +28,7 @@ from ..core import usage_tracker as ut
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..state import AppContext
|
||||
from .icons import icon
|
||||
from .icons import DOT_AMBER, icon
|
||||
from .widgets import fmt_tokens
|
||||
|
||||
_PERIODS = ("day", "week", "month", "year")
|
||||
@@ -367,7 +367,7 @@ class AccountsTab(QWidget):
|
||||
label = f"{acc.display_name or acc.username} ({acc.username}) — {tr(f'accounts.role.{acc.role}')}"
|
||||
item = QTreeWidgetItem([label])
|
||||
if is_subadmin: # subadmin badge → star icon instead of a ★ glyph
|
||||
item.setIcon(0, icon("star", color="#f59e0b"))
|
||||
item.setIcon(0, icon("star", color=DOT_AMBER))
|
||||
item.setData(0, Qt.UserRole, ("account", acc.username))
|
||||
if acc.email:
|
||||
item.setToolTip(0, acc.email)
|
||||
|
||||
+213
-76
@@ -8,18 +8,22 @@ accounts folder, so every machine pointed at the same share picks changes up
|
||||
automatically (OneDrive/network sync) — non-admin machines only ever READ the
|
||||
catalog (their pickers in Cowork / Schedule Task list the enabled agents).
|
||||
|
||||
The "Check" button probes each agent's effective provider (``check_agent``)
|
||||
and shows an operational-status column (🟢 reachable / 🔴 error) separate from
|
||||
the Enabled config flag.
|
||||
The header's "Kiểm tra tất cả" icon probes each agent's effective provider
|
||||
(``check_agent``) and shows the result as the Trạng thái pill (OK / error /
|
||||
checking…) — separate from the per-row Kích hoạt switch, which only toggles
|
||||
the config flag.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import QSize, Qt
|
||||
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
|
||||
QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, QTableWidget,
|
||||
QTableWidgetItem, QVBoxLayout, QWidget,
|
||||
QHeaderView, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton,
|
||||
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..config import PROVIDER_LABELS
|
||||
@@ -27,10 +31,62 @@ from ..core import admin_agents, preview_ai
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..state import AppContext
|
||||
from .icons import icon, dot_icon, DOT_GREEN, DOT_RED, DOT_AMBER, DOT_GREY
|
||||
from .icons import icon
|
||||
from .widgets import ToggleSwitch, badge_pill_widget
|
||||
|
||||
_PROVIDER_DEFAULT = "" # "" = each machine's own active provider (unchanged default)
|
||||
|
||||
# Identity colour (avatar circle) + badge tone per task_kind — same "fixed
|
||||
# colour regardless of theme" convention as monitoring_tab.py's per-agent
|
||||
# avatars, plus a badge object name (theme.py) for the Vai trò pill. Seven
|
||||
# kinds, seven distinct tones — no two kinds share a badge colour.
|
||||
_KIND_COLOUR = {
|
||||
"search": "#8A8886", "monitor": "#FFB900", "cowork": "#0078D4",
|
||||
"graphrag": "#8764B8", "schedule": "#107C10", "security": "#D13438",
|
||||
"help": "#E3008C",
|
||||
}
|
||||
_KIND_BADGE = {
|
||||
"search": "badgeNeutral", "monitor": "badgeWarn", "cowork": "badge",
|
||||
"graphrag": "badgePurple", "schedule": "badgeSuccess", "security": "badgeDanger",
|
||||
"help": "badgePink",
|
||||
}
|
||||
_STATUS_BADGE = {
|
||||
"unchecked": "badgeNeutral", "checking": "badgeWarn",
|
||||
"ok": "badgeSuccess", "bad": "badgeDanger",
|
||||
}
|
||||
|
||||
|
||||
def _initials(name: str) -> str:
|
||||
return "".join(w[0] for w in name.split() if w)[:2].upper()
|
||||
|
||||
|
||||
def _fmt_updated(ts: str) -> str:
|
||||
""""dd/MM hh:mm" — same Cập nhật/Thời gian format as Monitoring's
|
||||
Bảo mật/MCP/Hành động tables (``_fmt_event_time`` in monitoring_tab.py)."""
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts)
|
||||
except (TypeError, ValueError):
|
||||
return ts
|
||||
return dt.strftime("%d/%m %H:%M")
|
||||
|
||||
|
||||
def _kind_avatar_icon(kind: str, name: str, size: int = 20) -> QIcon:
|
||||
pm = QPixmap(size, size)
|
||||
pm.fill(Qt.transparent)
|
||||
p = QPainter(pm)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor(_KIND_COLOUR.get(kind, "#0078D4")))
|
||||
p.drawEllipse(0, 0, size, size)
|
||||
font = QFont()
|
||||
font.setPixelSize(max(7, size // 2))
|
||||
font.setBold(True)
|
||||
p.setFont(font)
|
||||
p.setPen(QColor("#FFFFFF"))
|
||||
p.drawText(pm.rect(), Qt.AlignCenter, _initials(name))
|
||||
p.end()
|
||||
return QIcon(pm)
|
||||
|
||||
|
||||
class AgentEditDialog(QDialog):
|
||||
"""Add/Edit one admin agent. The provider/model pickers are drop-lists,
|
||||
@@ -161,41 +217,79 @@ class AgentsAdminTab(QWidget):
|
||||
self._check_workers: List[AgentWorker] = []
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
hdr = QHBoxLayout()
|
||||
self._title_lbl = QLabel()
|
||||
self._title_lbl.setStyleSheet("font-weight:700; font-size:14px;")
|
||||
hdr.addWidget(self._title_lbl)
|
||||
hdr.addStretch(1)
|
||||
# "Kiểm tra tất cả" keeps the real _check_all action reachable without
|
||||
# competing with the 2 primary header buttons (Làm mới / + Thêm) — a
|
||||
# flat, secondary-styled button rather than a 3rd primary one, but
|
||||
# still labelled: an icon-only button here was a mystery button.
|
||||
self.check_btn = QPushButton()
|
||||
self.check_btn.setIcon(icon("check"))
|
||||
self.check_btn.setFlat(True)
|
||||
self.check_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.check_btn.clicked.connect(self._check_all)
|
||||
hdr.addWidget(self.check_btn)
|
||||
self.refresh_btn = QPushButton()
|
||||
self.refresh_btn.setIcon(icon("refresh"))
|
||||
self.refresh_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
hdr.addWidget(self.refresh_btn)
|
||||
self.add_btn = QPushButton()
|
||||
self.add_btn.setIcon(icon("plus"))
|
||||
self.add_btn.setObjectName("primary")
|
||||
self.add_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.add_btn.clicked.connect(self._add)
|
||||
hdr.addWidget(self.add_btn)
|
||||
root.addLayout(hdr)
|
||||
|
||||
self._hint = QLabel("")
|
||||
self._hint.setObjectName("hint")
|
||||
self._hint.setWordWrap(True)
|
||||
root.addWidget(self._hint)
|
||||
|
||||
self.table = QTableWidget(0, 6)
|
||||
self.table = QTableWidget(0, 7)
|
||||
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
# Sửa/Xoá/Kích hoạt are now per-row widgets (button/switch), and Vai
|
||||
# trò/Trạng thái are pill cell widgets — none of those track a row
|
||||
# across a re-sort (a cell widget stays pinned to its screen position,
|
||||
# not to the item that moves — see monitoring_tab.py's _EventTable for
|
||||
# the same lesson learned the hard way), so this table doesn't sort.
|
||||
self.table.setSelectionMode(QTableWidget.NoSelection)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.horizontalHeader().setStretchLastSection(True)
|
||||
self.table.setSortingEnabled(True)
|
||||
# Fixed row height — letting Qt auto-size rows from content fights
|
||||
# with the toggle switch / badge cell widgets: their layout settles on
|
||||
# a stale, oversized geometry from an intermediate sizing pass, which
|
||||
# then overlaps neighbouring rows (same bug _EventTable hit for its
|
||||
# Hành động pill, fixed there the same way).
|
||||
self.table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
|
||||
self.table.verticalHeader().setDefaultSectionSize(32)
|
||||
self.table.setIconSize(QSize(20, 20))
|
||||
header = self.table.horizontalHeader()
|
||||
header.setStretchLastSection(False)
|
||||
for col in (0, 6):
|
||||
header.setSectionResizeMode(col, QHeaderView.ResizeToContents)
|
||||
# Vai trò/Trạng thái (1, 4) are pill cell widgets — ResizeToContents
|
||||
# only measures QTableWidgetItem content, so it kept fighting refresh()'s
|
||||
# manual sizeHint()-based setColumnWidth() and clipping the pill text.
|
||||
# Interactive leaves whatever width refresh() sets alone.
|
||||
for col in (1, 4):
|
||||
header.setSectionResizeMode(col, QHeaderView.Interactive)
|
||||
header.setSectionResizeMode(2, QHeaderView.Stretch) # Model
|
||||
header.setSectionResizeMode(5, QHeaderView.ResizeToContents)
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
btns = QHBoxLayout()
|
||||
self.add_btn = QPushButton()
|
||||
self.add_btn.setIcon(icon("plus"))
|
||||
self.add_btn.setObjectName("primary")
|
||||
self.add_btn.clicked.connect(self._add)
|
||||
self.edit_btn = QPushButton()
|
||||
self.edit_btn.setIcon(icon("edit"))
|
||||
self.edit_btn.clicked.connect(self._edit)
|
||||
self.del_btn = QPushButton()
|
||||
self.del_btn.setIcon(icon("trash"))
|
||||
self.del_btn.clicked.connect(self._delete)
|
||||
self.check_btn = QPushButton()
|
||||
self.check_btn.setIcon(icon("check"))
|
||||
self.check_btn.clicked.connect(self._check_all)
|
||||
for b in (self.add_btn, self.edit_btn, self.del_btn):
|
||||
btns.addWidget(b)
|
||||
btns.addStretch(1)
|
||||
btns.addWidget(self.check_btn)
|
||||
root.addLayout(btns)
|
||||
|
||||
# on_language_changed() already invokes _retranslate() once immediately
|
||||
# (see i18n.py) — calling it again here was a harmless no-op back when
|
||||
# every column was a plain QTableWidgetItem, but now refresh() also
|
||||
# populates cell WIDGETS (toggle switch, pills, row actions): running
|
||||
# it twice back-to-back with no event-loop turn in between left the
|
||||
# first pass's widgets replaced but not yet deleted, so they briefly
|
||||
# painted overlapping the second pass's row 0.
|
||||
on_language_changed(self._retranslate)
|
||||
self._retranslate()
|
||||
|
||||
# ---- storage ---------------------------------------------------------
|
||||
def _dir(self):
|
||||
@@ -205,17 +299,6 @@ class AgentsAdminTab(QWidget):
|
||||
conf = self.ctx.config.provider_conf(self.ctx.config.active_provider)
|
||||
return conf.get("model", "")
|
||||
|
||||
def _selected_agent(self) -> Optional[admin_agents.AdminAgent]:
|
||||
row = self.table.currentRow()
|
||||
if row < 0:
|
||||
return None
|
||||
item = self.table.item(row, 0)
|
||||
if item is None:
|
||||
return None
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
return admin_agents.load_agent(item.data(Qt.UserRole), self._dir())
|
||||
|
||||
# ---- CRUD -------------------------------------------------------------
|
||||
def _add(self) -> None:
|
||||
dlg = AgentEditDialog(self, ctx=self.ctx, default_model_hint=self._default_model_hint())
|
||||
@@ -232,8 +315,8 @@ class AgentsAdminTab(QWidget):
|
||||
admin_agents.save_agent(agent, self._dir())
|
||||
self.refresh()
|
||||
|
||||
def _edit(self) -> None:
|
||||
agent = self._selected_agent()
|
||||
def _edit_agent(self, agent_id: str) -> None:
|
||||
agent = admin_agents.load_agent(agent_id, self._dir())
|
||||
if agent is None:
|
||||
return
|
||||
dlg = AgentEditDialog(self, ctx=self.ctx, agent=agent,
|
||||
@@ -243,7 +326,6 @@ class AgentsAdminTab(QWidget):
|
||||
fields = dlg.result_fields()
|
||||
if not fields["name"]:
|
||||
return
|
||||
from datetime import datetime
|
||||
|
||||
agent.name = fields["name"]
|
||||
agent.task_kind = fields["task_kind"]
|
||||
@@ -256,8 +338,8 @@ class AgentsAdminTab(QWidget):
|
||||
admin_agents.save_agent(agent, self._dir())
|
||||
self.refresh()
|
||||
|
||||
def _delete(self) -> None:
|
||||
agent = self._selected_agent()
|
||||
def _delete_agent(self, agent_id: str) -> None:
|
||||
agent = admin_agents.load_agent(agent_id, self._dir())
|
||||
if agent is None:
|
||||
return
|
||||
if QMessageBox.question(
|
||||
@@ -267,27 +349,69 @@ class AgentsAdminTab(QWidget):
|
||||
admin_agents.delete_agent(agent.agent_id, self._dir())
|
||||
self.refresh()
|
||||
|
||||
def _set_enabled(self, agent_id: str, enabled: bool) -> None:
|
||||
agent = admin_agents.load_agent(agent_id, self._dir())
|
||||
if agent is None or agent.enabled == enabled:
|
||||
return
|
||||
|
||||
agent.enabled = enabled
|
||||
agent.updated = datetime.now().isoformat(timespec="seconds")
|
||||
agent.updated_by = (getattr(self.ctx, "account", None).username if getattr(self.ctx, "account", None) else "")
|
||||
admin_agents.save_agent(agent, self._dir())
|
||||
self.refresh()
|
||||
|
||||
# ---- view --------------------------------------------------------------
|
||||
def _status_cell(self, agent_id: str) -> tuple:
|
||||
"""(status_icon | None, display_text, tooltip) for the operational-status
|
||||
column — a colored LED dot instead of the old 🟢/🔴 emoji."""
|
||||
"""(state_key, display_text, tooltip) for the Trạng thái pill —
|
||||
state_key indexes _STATUS_BADGE for the badge's colour tone."""
|
||||
res = self._status.get(agent_id)
|
||||
if res is None:
|
||||
return None, tr("agents_admin.status_unchecked"), tr("agents_admin.status_unchecked_tip")
|
||||
return ("unchecked", tr("agents_admin.status_unchecked").lstrip("— ").strip(),
|
||||
tr("agents_admin.status_unchecked_tip"))
|
||||
ok, msg = res
|
||||
if msg == "checking":
|
||||
return dot_icon(DOT_AMBER), tr("agents_admin.status_checking"), ""
|
||||
ic = dot_icon(DOT_GREEN if ok else DOT_RED)
|
||||
return ic, (tr("agents_admin.status_ok") if ok else tr("agents_admin.status_bad")), msg
|
||||
return "checking", tr("agents_admin.status_checking"), ""
|
||||
return ("ok" if ok else "bad"), (tr("agents_admin.status_ok") if ok else tr("agents_admin.status_bad")), msg
|
||||
|
||||
def _toggle_widget(self, agent_id: str, enabled: bool) -> QWidget:
|
||||
container = QWidget()
|
||||
container.setStyleSheet("background: transparent;")
|
||||
lay = QHBoxLayout(container)
|
||||
lay.setContentsMargins(6, 0, 0, 0)
|
||||
sw = ToggleSwitch()
|
||||
sw.setChecked(enabled)
|
||||
sw.toggled.connect(lambda checked, aid=agent_id: self._set_enabled(aid, checked))
|
||||
lay.addWidget(sw, 0, Qt.AlignVCenter)
|
||||
lay.addStretch(1)
|
||||
return container
|
||||
|
||||
def _row_actions_widget(self, agent_id: str) -> QWidget:
|
||||
container = QWidget()
|
||||
container.setStyleSheet("background: transparent;")
|
||||
lay = QHBoxLayout(container)
|
||||
lay.setContentsMargins(2, 0, 2, 0)
|
||||
lay.setSpacing(2)
|
||||
edit_btn = QPushButton()
|
||||
edit_btn.setIcon(icon("edit"))
|
||||
edit_btn.setFlat(True)
|
||||
edit_btn.setCursor(Qt.PointingHandCursor)
|
||||
edit_btn.setToolTip(tr("agents_admin.edit_row_tooltip"))
|
||||
edit_btn.clicked.connect(lambda: self._edit_agent(agent_id))
|
||||
del_btn = QPushButton()
|
||||
del_btn.setIcon(icon("trash"))
|
||||
del_btn.setFlat(True)
|
||||
del_btn.setCursor(Qt.PointingHandCursor)
|
||||
del_btn.setToolTip(tr("agents_admin.delete_row_tooltip"))
|
||||
del_btn.clicked.connect(lambda: self._delete_agent(agent_id))
|
||||
lay.addWidget(edit_btn)
|
||||
lay.addWidget(del_btn)
|
||||
return container
|
||||
|
||||
def refresh(self) -> None:
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
# Make sure the built-in in-app Help assistant exists, so the Admin can
|
||||
# manage its provider/model here (the floating Help widget uses it).
|
||||
admin_agents.ensure_help_agent(self._dir())
|
||||
agents = admin_agents.list_agents(self._dir())
|
||||
self.table.setSortingEnabled(False)
|
||||
self.table.setRowCount(len(agents))
|
||||
default_model = self._default_model_hint()
|
||||
for row, agent in enumerate(agents):
|
||||
@@ -296,23 +420,36 @@ class AgentsAdminTab(QWidget):
|
||||
model = f"{provider_lbl} — {agent.model}" if provider_lbl else agent.model
|
||||
else:
|
||||
model = tr("agents_admin.default_model", model=default_model or "—")
|
||||
status_icon, status_text, status_tip = self._status_cell(agent.agent_id)
|
||||
cells = [agent.name, tr(f"agents_admin.kind.{agent.task_kind}"),
|
||||
model, "", status_text, agent.updated]
|
||||
for col, text in enumerate(cells):
|
||||
item = QTableWidgetItem(str(text))
|
||||
if col == 0:
|
||||
item.setData(Qt.UserRole, agent.agent_id)
|
||||
if col == 3: # Enabled — green check / grey minus icon (no emoji)
|
||||
item.setIcon(icon("check", color=DOT_GREEN) if agent.enabled
|
||||
else icon("minus", color=DOT_GREY))
|
||||
if col == 4:
|
||||
if status_icon:
|
||||
item.setIcon(status_icon)
|
||||
if status_tip:
|
||||
item.setToolTip(status_tip)
|
||||
self.table.setItem(row, col, item)
|
||||
self.table.setSortingEnabled(True)
|
||||
|
||||
name_item = QTableWidgetItem(agent.name)
|
||||
name_item.setIcon(_kind_avatar_icon(agent.task_kind, agent.name))
|
||||
self.table.setItem(row, 0, name_item)
|
||||
|
||||
kind_tone = _KIND_BADGE.get(agent.task_kind, "badge")
|
||||
self.table.setCellWidget(
|
||||
row, 1, badge_pill_widget(tr(f"agents_admin.kind.{agent.task_kind}"), kind_tone))
|
||||
|
||||
self.table.setItem(row, 2, QTableWidgetItem(model))
|
||||
self.table.setCellWidget(row, 3, self._toggle_widget(agent.agent_id, agent.enabled))
|
||||
|
||||
state_key, status_text, status_tip = self._status_cell(agent.agent_id)
|
||||
status_widget = badge_pill_widget(status_text, _STATUS_BADGE[state_key])
|
||||
if status_tip:
|
||||
status_widget.setToolTip(status_tip)
|
||||
self.table.setCellWidget(row, 4, status_widget)
|
||||
|
||||
self.table.setItem(row, 5, QTableWidgetItem(_fmt_updated(agent.updated)))
|
||||
self.table.setCellWidget(row, 6, self._row_actions_widget(agent.agent_id))
|
||||
|
||||
# ResizeToContents doesn't measure a cell WIDGET's real width (only
|
||||
# QTableWidgetItem content) — size the Vai trò/Trạng thái pill columns
|
||||
# by hand, or their text clips against whatever width it guessed.
|
||||
if self.table.rowCount():
|
||||
for col in (1, 4):
|
||||
needed = max(self.table.cellWidget(r, col).sizeHint().width()
|
||||
for r in range(self.table.rowCount()))
|
||||
if needed + 24 > self.table.columnWidth(col):
|
||||
self.table.setColumnWidth(col, needed + 24)
|
||||
|
||||
def _check_all(self) -> None:
|
||||
"""Health-check every agent's effective provider off the UI thread and
|
||||
@@ -347,15 +484,15 @@ class AgentsAdminTab(QWidget):
|
||||
w.start()
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self._title_lbl.setText(tr("agents_admin.page_title"))
|
||||
self._hint.setText(tr("agents_admin.hint"))
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
tr("agents_admin.col_name"), tr("agents_admin.col_kind"),
|
||||
tr("agents_admin.col_model"), tr("agents_admin.col_enabled"),
|
||||
tr("agents_admin.col_status"), tr("agents_admin.col_updated"),
|
||||
tr("agents_admin.col_status"), tr("agents_admin.col_updated"), "",
|
||||
])
|
||||
self.add_btn.setText(tr("agents_admin.add_btn"))
|
||||
self.edit_btn.setText(tr("agents_admin.edit_btn"))
|
||||
self.del_btn.setText(tr("agents_admin.delete_btn"))
|
||||
self.refresh_btn.setText(tr("monitoring.refresh"))
|
||||
self.check_btn.setText(tr("agents_admin.check_btn"))
|
||||
self.check_btn.setToolTip(tr("agents_admin.check_tooltip"))
|
||||
self.refresh()
|
||||
|
||||
+13
-11
@@ -20,6 +20,7 @@ from ..core.calendar_grid import (
|
||||
GRANULARITIES, group_tasks_by_date, month_grid, month_task_counts, shift_period, week_days,
|
||||
)
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..theme import current_palette
|
||||
from .icons import icon
|
||||
|
||||
_WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
||||
@@ -56,20 +57,21 @@ class _DayCell(QFrame):
|
||||
today: bool = False, weekend: bool = False) -> None:
|
||||
self._date_str = d.isoformat()
|
||||
self.date_lbl.setText(str(d.day))
|
||||
num_color = "#0096C7" if today else ("#888" if dim else "")
|
||||
self.date_lbl.setStyleSheet(f"font-weight:700; color:{num_color};")
|
||||
# Today = accent border + stronger tint; weekend (Sat/Sun) = a subtle
|
||||
# darker-blue tint than the base cell. rgba overlays read correctly on
|
||||
# both light and dark themes.
|
||||
base_border = "1px solid rgba(128,128,128,0.35)"
|
||||
p = current_palette()
|
||||
num_color = p.accent if today else (p.text_faint if dim else p.text)
|
||||
self.date_lbl.setStyleSheet(f"font-weight:600; color:{num_color};")
|
||||
# Today is the only cell that gets a filled surface + accent border;
|
||||
# weekends are set apart by a recessed surface alone, so the eye lands
|
||||
# on "today" first and on the weekend block only when scanning.
|
||||
r = p.radius
|
||||
if today:
|
||||
css = ("#dayCell { background: rgba(0,150,199,0.22); "
|
||||
"border: 2px solid #0096C7; border-radius: 6px; }")
|
||||
css = (f"#dayCell {{ background: {p.accent_soft}; "
|
||||
f"border: 1px solid {p.accent}; border-radius: {r}px; }}")
|
||||
elif weekend:
|
||||
css = ("#dayCell { background: rgba(0,120,182,0.13); "
|
||||
f"border: {base_border}; border-radius: 6px; }}")
|
||||
css = (f"#dayCell {{ background: {p.surface}; "
|
||||
f"border: 1px solid {p.border}; border-radius: {r}px; }}")
|
||||
else:
|
||||
css = f"#dayCell {{ border: {base_border}; border-radius: 6px; }}"
|
||||
css = f"#dayCell {{ border: 1px solid {p.border}; border-radius: {r}px; }}"
|
||||
self.setStyleSheet(css)
|
||||
self.list.clear()
|
||||
for t in tasks:
|
||||
|
||||
+73
-16
@@ -24,6 +24,7 @@ from PySide6.QtWidgets import (
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..state import AppContext
|
||||
from ..theme import current_palette
|
||||
from .chat_view import ChatView, ThinkingIndicator
|
||||
from .composer import Composer
|
||||
from .icons import collapse_right_icon, icon as app_icon
|
||||
@@ -83,6 +84,7 @@ class ChatPanel(QWidget):
|
||||
self.session_name = session_name
|
||||
self.session_id = new_session_id()
|
||||
self.title = ""
|
||||
self._notify_title()
|
||||
# Which project (workspace) this conversation belongs to — every new
|
||||
# thread inherits the currently selected project (Claude-Projects style).
|
||||
self.project_id = "default"
|
||||
@@ -140,8 +142,8 @@ class ChatPanel(QWidget):
|
||||
# updated after each turn; cost uses the Monitoring model-price table.
|
||||
self._usage_total_lbl = QLabel("")
|
||||
self._usage_total_lbl.setObjectName("hint")
|
||||
self._usage_total_lbl.setStyleSheet("color: rgba(140,146,152,0.9);")
|
||||
self.composer.add_bottom_left(self._usage_total_lbl)
|
||||
self._usage_total_lbl.setStyleSheet(f"color: {current_palette().text_faint};")
|
||||
|
||||
|
||||
# Per-tab "Agent" = which MODEL this tab uses (e.g. deepseek / gemma /
|
||||
# qwen for the local provider). Cowork and Code pick independently and
|
||||
@@ -165,13 +167,19 @@ class ChatPanel(QWidget):
|
||||
self.agent_combo.setMinimumWidth(150)
|
||||
self.agent_combo.setToolTip(tr("chatpanel.agent_tooltip"))
|
||||
self.agent_combo.currentIndexChanged.connect(self._on_agent_changed)
|
||||
self.composer.add_bottom_right(self._agent_lbl)
|
||||
self.composer.add_bottom_right(self.agent_combo)
|
||||
self.composer.add_bottom_left(self._agent_lbl)
|
||||
self.composer.add_bottom_left(self.agent_combo)
|
||||
# Off/Auto/Manual routing toggle — lets the router pick the best-fit
|
||||
# model per message (see core/routing + _apply_routing).
|
||||
from .routing_toggle import RoutingToggle
|
||||
self.routing_toggle = RoutingToggle(ctx, self.kind)
|
||||
self.composer.add_bottom_right(self.routing_toggle)
|
||||
# The drawing reads the strip left to right as
|
||||
# Agent: … · Định tuyến: … · ↓in ↑out · $cost · 📁 folder
|
||||
# so these sit together on the left, with the folder box the Cowork tab
|
||||
# appends landing after them. Nén and Tự chạy stay on the right, where
|
||||
# the control inventory marks them "giữ nguyên tại chỗ".
|
||||
self.composer.add_bottom_left(self.routing_toggle)
|
||||
self.composer.add_bottom_left(self._usage_total_lbl)
|
||||
# Manual "compress conversation" — trim old history to cut tokens.
|
||||
self.compress_btn = QPushButton(tr("chatpanel.compress_btn"))
|
||||
self.compress_btn.setIcon(app_icon("compress"))
|
||||
@@ -188,20 +196,29 @@ class ChatPanel(QWidget):
|
||||
cc.addWidget(self.chat_view, 1)
|
||||
self.thinking = ThinkingIndicator() # animated "working…" line while we wait
|
||||
cc.addWidget(self.thinking)
|
||||
composer_wrap = QWidget()
|
||||
cwl = QVBoxLayout(composer_wrap)
|
||||
cwl.setContentsMargins(8, 4, 8, 8)
|
||||
cwl.addWidget(self.composer)
|
||||
cc.addWidget(composer_wrap)
|
||||
|
||||
self.center_split = QSplitter(Qt.Horizontal)
|
||||
self.center_split.addWidget(chat_col)
|
||||
root.addWidget(self.center_split, 1)
|
||||
|
||||
# The composer spans the whole screen, under BOTH columns — that is how
|
||||
# the drawing lays it out, and it is the reason the files panel can sit
|
||||
# beside the transcript without narrowing what you type into. Inside the
|
||||
# chat column it stopped at the panel's edge and the input shrank
|
||||
# whenever files appeared.
|
||||
composer_wrap = QWidget()
|
||||
cwl = QVBoxLayout(composer_wrap)
|
||||
cwl.setContentsMargins(8, 4, 8, 8)
|
||||
cwl.addWidget(self.composer)
|
||||
root.addWidget(composer_wrap)
|
||||
|
||||
# Right sidebar: Output files only (see below — Input is tracked but
|
||||
# not shown).
|
||||
self.input_section = CollapsibleSection(tr("widgets.input_files"))
|
||||
self.output_section = CollapsibleSection(tr("widgets.output_files"))
|
||||
# No cap: this section owns the whole right panel (its header is
|
||||
# hoisted into io_hdr below), so the list should fill the space down
|
||||
# to the composer instead of stopping at a fixed height with empty
|
||||
# panel below it.
|
||||
self.output_section = CollapsibleSection(tr("widgets.output_files").upper(), max_height=None)
|
||||
# Input files are NOT shown in Cowork's UI anymore — but they're still
|
||||
# fully tracked (add/remove/paths()) exactly as before, since that list
|
||||
# is what gets written into the conversation's own "inputs" field on
|
||||
@@ -241,8 +258,15 @@ class ChatPanel(QWidget):
|
||||
self._io_collapse_btn.clicked.connect(lambda: self._set_io_collapsed(True))
|
||||
self._files_header = QLabel()
|
||||
self._files_header.setStyleSheet("font-weight:600;")
|
||||
# The drawing gives this panel ONE heading — "TỆP ĐẦU RA (3) ›" — and
|
||||
# the section already draws exactly that, count included. A separate
|
||||
# "Files" label above it was the same thing said twice, so the section's
|
||||
# own header moves onto this row and the collapse chevron sits at its
|
||||
# right, where the drawing puts it. _files_header stays for the tabs
|
||||
# that still label their panel, just not in this layout.
|
||||
self._files_header.setVisible(False)
|
||||
io_hdr.addWidget(self.output_section.header, 1)
|
||||
io_hdr.addWidget(self._io_collapse_btn)
|
||||
io_hdr.addWidget(self._files_header, 1)
|
||||
# The plan now shows INLINE in the conversation (an expandable block whose
|
||||
# steps tick off as they complete), not in this right panel — so it's kept
|
||||
# out of the layout here. The object stays (its set_steps/clear calls are
|
||||
@@ -253,8 +277,7 @@ class ChatPanel(QWidget):
|
||||
bl = QVBoxLayout(bl_host)
|
||||
bl.setContentsMargins(0, 0, 0, 0)
|
||||
bl.setSpacing(4)
|
||||
bl.addWidget(self.output_section) # Output only — Input is tracked but hidden
|
||||
bl.addStretch(1)
|
||||
bl.addWidget(self.output_section, 1) # Output only — Input is tracked but hidden; fills down to the composer
|
||||
iol.addWidget(bl_host, 1)
|
||||
|
||||
# Collapsing shrinks the panel to a thin clickable line (not hidden).
|
||||
@@ -284,7 +307,7 @@ class ChatPanel(QWidget):
|
||||
self.compress_btn.setText(tr("chatpanel.compress_btn"))
|
||||
self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip"))
|
||||
self.input_section.set_title(tr("widgets.input_files"))
|
||||
self.output_section.set_title(tr("widgets.output_files"))
|
||||
self.output_section.set_title(tr("widgets.output_files").upper())
|
||||
self.plan_section.set_title(tr("widgets.plan_title"))
|
||||
self._io_collapse_btn.setToolTip(tr("chatpanel.collapse_files_tooltip"))
|
||||
self._files_header.setText(tr("chatpanel.files_header"))
|
||||
@@ -1023,6 +1046,7 @@ class ChatPanel(QWidget):
|
||||
if not self.title:
|
||||
base = text or (Path(attachments[0]).name if attachments else "(attachment)")
|
||||
self.title = (base[:60] + "…") if len(base) > 60 else base
|
||||
self._notify_title()
|
||||
|
||||
# Reset the Plan panel so each message starts from a clean checklist (the
|
||||
# previous message's plan never lingers/flickers into this one).
|
||||
@@ -1398,6 +1422,26 @@ class ChatPanel(QWidget):
|
||||
return [e for e in ut.load_events()
|
||||
if e.get("source") == self.kind and e.get("label") == label]
|
||||
|
||||
def refresh_usage(self) -> None:
|
||||
"""Show what this conversation has already cost.
|
||||
|
||||
The label was written only at the end of a turn, so opening a thread
|
||||
from History left the strip blank however much it had spent.
|
||||
"""
|
||||
from ..core import model_pricing as mp
|
||||
from ..core import usage_tracker as ut
|
||||
|
||||
cur = self._usage_snapshot()
|
||||
if not (cur["in"] or cur["out"] or cur["cache"]):
|
||||
self._usage_total_lbl.setText("")
|
||||
return
|
||||
# same source _show_usage reads, so the two never disagree
|
||||
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
|
||||
self._usage_total_lbl.setText(
|
||||
f"↓{mp.format_tokens(cur['in'])} ↑{mp.format_tokens(cur['out'])} "
|
||||
f"▤{mp.format_tokens(cur['in'] + cur['out'] + cur['cache'])} "
|
||||
f"{ut.format_cost(self._session_cost_usd(), pricing)}")
|
||||
|
||||
def _usage_snapshot(self) -> Dict[str, int]:
|
||||
"""Cumulative in/out/cache tokens for THIS conversation so far."""
|
||||
snap = {"in": 0, "out": 0, "cache": 0}
|
||||
@@ -1668,6 +1712,18 @@ class ChatPanel(QWidget):
|
||||
self._sync_indicators()
|
||||
self.history_changed.emit() # current view changed → refresh History highlight
|
||||
|
||||
def _notify_title(self) -> None:
|
||||
"""Let a screen that heads itself with the thread title follow along.
|
||||
|
||||
The thread also decides what the usage strip should read, so refresh
|
||||
that here rather than at each of the three places the title changes.
|
||||
"""
|
||||
hook = getattr(self, "refresh_title", None)
|
||||
if callable(hook):
|
||||
hook()
|
||||
if getattr(self, "_usage_total_lbl", None) is not None:
|
||||
self.refresh_usage()
|
||||
|
||||
def load_conversation(self, conv: Dict[str, Any]) -> None:
|
||||
"""Switch the view to a stored conversation. Allowed while work is running —
|
||||
the current turns keep going in the background."""
|
||||
@@ -1679,6 +1735,7 @@ class ChatPanel(QWidget):
|
||||
self._detach_live_turns()
|
||||
self.session_id = sid
|
||||
self.title = conv.get("title", "")
|
||||
self._notify_title()
|
||||
self.project_id = conv.get("project_id", "") or "default"
|
||||
# If this conversation still has a turn running in the background, attach to
|
||||
# its LIVE message list (not a stale disk copy) so the two never race on save.
|
||||
|
||||
+50
-53
@@ -12,7 +12,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..theme import ACCENT, resolve_theme
|
||||
from ..theme import palette, resolve_theme
|
||||
from ..config import CONFIG_DIR
|
||||
from .osutil import is_image, open_folder, open_path
|
||||
|
||||
@@ -28,11 +28,18 @@ def _app_theme() -> str:
|
||||
return "dark"
|
||||
|
||||
|
||||
# Timeline dot color per role (reads on both themes — small, saturated).
|
||||
_DOT = {
|
||||
"user": "#48CAE4", "assistant": "#48D9A0", "tool": "#9B8FF7",
|
||||
"error": "#E5484D", "success": "#48D9A0",
|
||||
}
|
||||
def _p():
|
||||
"""Design tokens for the theme in effect right now."""
|
||||
return palette(_app_theme())
|
||||
|
||||
|
||||
def _dot_color(role: str) -> str:
|
||||
"""Timeline dot colour for a message role."""
|
||||
p = _p()
|
||||
return {
|
||||
"user": p.role_user, "assistant": p.role_assistant, "tool": p.role_tool,
|
||||
"error": p.role_error, "success": p.role_result,
|
||||
}.get(role, p.text_faint)
|
||||
|
||||
|
||||
class _TimelineGutter(QWidget):
|
||||
@@ -52,17 +59,17 @@ class _TimelineGutter(QWidget):
|
||||
def paintEvent(self, _e): # noqa: N802
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
dark = _app_theme() == "dark"
|
||||
tok = _p()
|
||||
x = 11.0
|
||||
cy = 15.0
|
||||
# connector line (faint) running the full height → continuous rail
|
||||
p.setPen(QPen(QColor("#243a56" if dark else "#CBDDEC"), 2))
|
||||
p.setPen(QPen(QColor(tok.border), 2))
|
||||
p.drawLine(int(x), 0, int(x), self.height())
|
||||
# a background ring lifts the dot off the line
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor("#0A1628" if dark else "#E8F4FD"))
|
||||
p.setBrush(QColor(tok.bg))
|
||||
p.drawEllipse(QPointF(x, cy), 7.5, 7.5)
|
||||
p.setBrush(QColor(_DOT.get(self._role, "#8FB2D4")))
|
||||
p.setBrush(QColor(_dot_color(self._role)))
|
||||
p.drawEllipse(QPointF(x, cy), 4.5, 4.5)
|
||||
|
||||
|
||||
@@ -72,18 +79,20 @@ def _diff_legend(diff_text: str) -> str:
|
||||
so the before/after distinction is explicit, not just implied by color."""
|
||||
has_add = any(ln.startswith("+") and not ln.startswith("+++") for ln in diff_text.splitlines())
|
||||
has_del = any(ln.startswith("-") and not ln.startswith("---") for ln in diff_text.splitlines())
|
||||
before = (f'<span style="background:#3a1620; color:#ff9aa8; padding:1px 8px; '
|
||||
f'border-radius:4px; font-weight:600;">{html.escape(tr("chat.diff_before"))}</span>')
|
||||
after = (f'<span style="background:#0d3321; color:#7ee2a8; padding:1px 8px; '
|
||||
f'border-radius:4px; font-weight:600;">{html.escape(tr("chat.diff_after"))}</span>')
|
||||
p = _p()
|
||||
|
||||
def pill(bg: str, fg: str, key: str) -> str:
|
||||
return (f'<span style="background:{bg}; color:{fg}; padding:1px 8px; '
|
||||
f'border-radius:4px; font-weight:600;">{html.escape(tr(key))}</span>')
|
||||
|
||||
if has_add and has_del:
|
||||
badge = f'{before}<span style="color:#8b8d98;"> → </span>{after}'
|
||||
badge = (pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_before")
|
||||
+ f'<span style="color:{p.text_muted};"> → </span>'
|
||||
+ pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_after"))
|
||||
elif has_add:
|
||||
badge = (f'<span style="background:#0d3321; color:#7ee2a8; padding:1px 8px; '
|
||||
f'border-radius:4px; font-weight:600;">{html.escape(tr("chat.diff_added"))}</span>')
|
||||
badge = pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_added")
|
||||
elif has_del:
|
||||
badge = (f'<span style="background:#3a1620; color:#ff9aa8; padding:1px 8px; '
|
||||
f'border-radius:4px; font-weight:600;">{html.escape(tr("chat.diff_removed"))}</span>')
|
||||
badge = pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_removed")
|
||||
else:
|
||||
return ""
|
||||
return f'<div style="margin-bottom:6px;">{badge}</div>'
|
||||
@@ -97,21 +106,22 @@ def diff_to_html(diff_text: str) -> str:
|
||||
empty 'before') naturally renders as all-green, which is exactly what
|
||||
``difflib.unified_diff`` already produces for it."""
|
||||
legend = _diff_legend(diff_text)
|
||||
p = _p()
|
||||
rows = []
|
||||
for ln in diff_text.splitlines():
|
||||
esc = html.escape(ln) if ln else " "
|
||||
if ln.startswith(("+++", "---")):
|
||||
rows.append(f'<div style="color:#8b8d98;">{esc}</div>')
|
||||
rows.append(f'<div style="color:{p.text_muted};">{esc}</div>')
|
||||
elif ln.startswith("@@"):
|
||||
rows.append(f'<div style="color:#7c8aff;">{esc}</div>')
|
||||
rows.append(f'<div style="color:{p.accent};">{esc}</div>')
|
||||
elif ln.startswith("+"):
|
||||
rows.append(f'<div style="background:#0d3321; color:#7ee2a8;">{esc}</div>')
|
||||
rows.append(f'<div style="background:{p.diff_add_bg}; color:{p.diff_add_fg};">{esc}</div>')
|
||||
elif ln.startswith("-"):
|
||||
rows.append(f'<div style="background:#3a1620; color:#ff9aa8;">{esc}</div>')
|
||||
rows.append(f'<div style="background:{p.diff_del_bg}; color:{p.diff_del_fg};">{esc}</div>')
|
||||
else:
|
||||
rows.append(f"<div>{esc}</div>")
|
||||
body = "".join(rows) or "(no textual change)"
|
||||
return (f'{legend}<div style="font-family:Consolas,\'Courier New\',monospace; font-size:12.5px; '
|
||||
return (f'{legend}<div style="font-family:{p.font_mono}; font-size:12.5px; '
|
||||
f'white-space:pre-wrap;">{body}</div>')
|
||||
|
||||
|
||||
@@ -219,12 +229,12 @@ class MessageBubble(QFrame):
|
||||
self._head.setCursor(Qt.PointingHandCursor)
|
||||
self._head.setStyleSheet(
|
||||
"QPushButton { text-align:left; border:none; background:transparent;"
|
||||
" font-weight:600; color:#8b8d98; padding:0; }")
|
||||
f" font-weight:600; color:{_p().text_muted}; padding:0; }}")
|
||||
self._head.clicked.connect(self._toggle_body)
|
||||
lay.addWidget(self._head)
|
||||
else:
|
||||
head = QLabel(title)
|
||||
head.setStyleSheet("font-weight:600; color:#8b8d98;")
|
||||
head.setStyleSheet(f"font-weight:600; color:{_p().text_muted};")
|
||||
lay.addWidget(head)
|
||||
|
||||
self.body = QTextBrowser()
|
||||
@@ -265,36 +275,23 @@ class MessageBubble(QFrame):
|
||||
|
||||
def _apply_theme_styles(self, role: str) -> None:
|
||||
"""Apply text color to the body QTextBrowser based on current theme + role."""
|
||||
theme = self._current_theme()
|
||||
if theme == "light":
|
||||
if role == "success":
|
||||
text_color = "#1B7A3D"
|
||||
elif role == "error":
|
||||
text_color = "#C0392B"
|
||||
elif role in ("tool",):
|
||||
text_color = "#5C6B7A" # muted (secondary) like Claude's steps
|
||||
else:
|
||||
text_color = "#1A2332"
|
||||
else:
|
||||
if role == "success":
|
||||
text_color = "#7ee2a8"
|
||||
elif role == "error":
|
||||
text_color = "#ff9aa8"
|
||||
elif role in ("tool",):
|
||||
text_color = "#9aa6b8"
|
||||
else:
|
||||
text_color = "#eceef2"
|
||||
p = _p()
|
||||
text_color = {
|
||||
"success": p.success,
|
||||
"error": p.danger,
|
||||
"tool": p.text_muted, # secondary, like Claude's steps
|
||||
}.get(role, p.text)
|
||||
self.body.setStyleSheet(f"background: transparent; border: none; color: {text_color};")
|
||||
|
||||
def _apply_style(self, role: str) -> None:
|
||||
"""Flat timeline row — no bubble box; the left dot/rail conveys role and
|
||||
structure (Claude-Code style). The user's own message gets a faint tint
|
||||
so questions are easy to pick out when scanning."""
|
||||
theme = self._current_theme()
|
||||
p = _p()
|
||||
if role == "user":
|
||||
tint = "rgba(72,202,228,0.10)" if theme == "dark" else "rgba(72,202,228,0.14)"
|
||||
self.setStyleSheet(
|
||||
f"QFrame {{ background: {tint}; border: none; border-radius: 10px; }}")
|
||||
f"QFrame {{ background: {p.surface}; border: none; "
|
||||
f"border-radius: {p.radius}px; }}")
|
||||
else:
|
||||
self.setStyleSheet("QFrame { background: transparent; border: none; }")
|
||||
|
||||
@@ -353,20 +350,20 @@ class MessageBubble(QFrame):
|
||||
existing.setText(text)
|
||||
return
|
||||
lbl = QLabel(text)
|
||||
lbl.setObjectName("hint")
|
||||
lbl.setStyleSheet("color: rgba(140,146,152,0.9); font-size: 11px;")
|
||||
lbl.setObjectName("faint")
|
||||
lbl.setStyleSheet(f"color: {_p().text_faint}; font-size: 11px;")
|
||||
self._usage_lbl = lbl
|
||||
self._content_layout.addWidget(lbl)
|
||||
|
||||
def add_delete_link(self, callback) -> None:
|
||||
link = QLabel(f'<a href="#del" style="color:#ef6368;">{tr("chat.delete_link")}</a>')
|
||||
link = QLabel(f'<a href="#del" style="color:{_p().danger};">{tr("chat.delete_link")}</a>')
|
||||
link.setToolTip(tr("chat.delete_tooltip"))
|
||||
link.linkActivated.connect(lambda *_: callback())
|
||||
self._content_layout.addWidget(link)
|
||||
|
||||
def add_folder_link(self, folder: str, label: str | None = None) -> None:
|
||||
label = label or tr("chat.open_workspace")
|
||||
link = QLabel(f'<a href="#open" style="color:{ACCENT};">{label}</a>')
|
||||
link = QLabel(f'<a href="#open" style="color:{_p().accent};">{label}</a>')
|
||||
link.setToolTip(str(folder))
|
||||
link.linkActivated.connect(lambda *_: open_folder(folder))
|
||||
self._content_layout.addWidget(link)
|
||||
@@ -385,7 +382,7 @@ class MessageBubble(QFrame):
|
||||
thumb.setCursor(Qt.PointingHandCursor)
|
||||
self._content_layout.addWidget(thumb)
|
||||
continue
|
||||
file_link = QLabel(f'<a href="#open" style="color:{ACCENT};">{name}</a>')
|
||||
file_link = QLabel(f'<a href="#open" style="color:{_p().accent};">{name}</a>')
|
||||
file_link.setToolTip(path)
|
||||
file_link.linkActivated.connect(lambda *_a, fp=path: open_path(fp))
|
||||
self._content_layout.addWidget(file_link)
|
||||
|
||||
+33
-19
@@ -27,13 +27,20 @@ from ..core.co4e import (
|
||||
STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node, Step,
|
||||
compute_waves, new_edge_id, new_node_id,
|
||||
)
|
||||
from ..theme import current_palette
|
||||
|
||||
|
||||
def _status_color(status: str) -> str:
|
||||
"""Accent colour for a step's run status. Resolved per paint so the canvas
|
||||
follows a live theme switch."""
|
||||
p = current_palette()
|
||||
return {
|
||||
"idle": p.text_muted, STEP_RUNNING: p.accent, STEP_DONE: p.success,
|
||||
STEP_ERROR: p.danger, STEP_PLANNED: p.purple, "pending": p.text_faint,
|
||||
}.get(status, p.text_muted)
|
||||
|
||||
CO4E_MIME = "application/x-co4e-step"
|
||||
|
||||
_STATUS_COLOR = {
|
||||
"idle": "#5C8DB8", STEP_RUNNING: "#48CAE4", STEP_DONE: "#48D9A0",
|
||||
STEP_ERROR: "#E5484D", STEP_PLANNED: "#9B8FF7", "pending": "#7A8DA8",
|
||||
}
|
||||
_NODE_W, _NODE_H = 210, 96
|
||||
_PORT_R = 6 # output port radius (the drag-to-connect handle)
|
||||
_PORT_HIT = 15 # click tolerance around a port
|
||||
@@ -63,24 +70,29 @@ class _NodeItem(QGraphicsObject):
|
||||
return QRectF(1, 1, _NODE_W - 2, _NODE_H - 2)
|
||||
|
||||
def paint(self, p, _opt, _widget=None):
|
||||
tok = current_palette()
|
||||
step = self.node.data
|
||||
accent = QColor(_STATUS_COLOR.get(self.status, "#5C8DB8"))
|
||||
body = QColor("#0D1F35")
|
||||
border = QColor("#48CAE4") if self.isSelected() else QColor("#1A2D4A")
|
||||
accent = QColor(_status_color(self.status))
|
||||
body = QColor(tok.surface_raised)
|
||||
border = QColor(tok.accent) if self.isSelected() else QColor(tok.border)
|
||||
p.setRenderHint(p.RenderHint.Antialiasing)
|
||||
rect = self._card_rect()
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(rect, 10, 10)
|
||||
radius = float(tok.radius_lg)
|
||||
path.addRoundedRect(rect, radius, radius)
|
||||
p.fillPath(path, QBrush(body))
|
||||
p.setPen(QPen(border, 2 if self.isSelected() else 1))
|
||||
p.drawPath(path)
|
||||
# header stripe
|
||||
# header stripe — a tint of the status colour, not the status colour
|
||||
# itself, so the card's own text stays the brightest thing on it.
|
||||
hdr = QRectF(rect.left(), rect.top(), rect.width(), 26)
|
||||
hpath = QPainterPath()
|
||||
hpath.addRoundedRect(hdr, 10, 10)
|
||||
p.fillPath(hpath, QBrush(accent.darker(160)))
|
||||
hpath.addRoundedRect(hdr, radius, radius)
|
||||
stripe = QColor(accent)
|
||||
stripe.setAlpha(48)
|
||||
p.fillPath(hpath, QBrush(stripe))
|
||||
# label
|
||||
p.setPen(QColor("#E0F0FF"))
|
||||
p.setPen(QColor(tok.text))
|
||||
f = p.font(); f.setBold(True); f.setPointSize(9); p.setFont(f)
|
||||
p.drawText(QRectF(10, 4, _NODE_W - 20, 20), Qt.AlignVCenter | Qt.AlignLeft,
|
||||
_elide(step.label, 26))
|
||||
@@ -89,7 +101,7 @@ class _NodeItem(QGraphicsObject):
|
||||
p.setPen(accent)
|
||||
p.drawText(QRectF(10, 30, _NODE_W - 20, 16), Qt.AlignLeft, step.role)
|
||||
# body: instructions preview OR sub-agent chips
|
||||
p.setPen(QColor("#8FB2D4"))
|
||||
p.setPen(QColor(tok.text_muted))
|
||||
if step.is_parallel:
|
||||
preview = "⇉ " + ", ".join(s.agent for s in step.sub_agents) if step.sub_agents else "⇉ (no sub-agents)"
|
||||
else:
|
||||
@@ -97,7 +109,7 @@ class _NodeItem(QGraphicsObject):
|
||||
p.drawText(QRectF(10, 46, _NODE_W - 20, 30), Qt.TextWordWrap | Qt.AlignTop,
|
||||
_elide(preview, 66))
|
||||
# footer: model + skills + status dot
|
||||
p.setPen(QColor("#5C8DB8"))
|
||||
p.setPen(QColor(tok.text_faint))
|
||||
foot = []
|
||||
if step.model:
|
||||
foot.append(step.model)
|
||||
@@ -109,7 +121,7 @@ class _NodeItem(QGraphicsObject):
|
||||
# ---- ports ---------------------------------------------------------
|
||||
# input port (top-center): hollow. output port (bottom-center): filled —
|
||||
# the drag handle you pull to wire an edge to another step.
|
||||
port_col = QColor("#48CAE4")
|
||||
port_col = QColor(tok.accent)
|
||||
# input port (left-center): hollow. output port (right-center): filled —
|
||||
# the drag handle you pull to wire an edge to the next step (left→right).
|
||||
p.setBrush(QBrush(body)); p.setPen(QPen(port_col, 1.4))
|
||||
@@ -298,12 +310,13 @@ class _EdgeItem(QGraphicsPathItem):
|
||||
self._apply_pen()
|
||||
|
||||
def _apply_pen(self):
|
||||
tok = current_palette()
|
||||
if self.isSelected():
|
||||
color, w = QColor("#48CAE4"), 3
|
||||
color, w = QColor(tok.accent), 3
|
||||
elif self._hover:
|
||||
color, w = QColor("#6FA8C8"), 3
|
||||
color, w = QColor(tok.text_muted), 3
|
||||
else:
|
||||
color, w = QColor("#3A5A78"), 2
|
||||
color, w = QColor(tok.border_strong), 2
|
||||
self.setPen(QPen(color, w, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
|
||||
|
||||
def update_path(self, points):
|
||||
@@ -491,7 +504,8 @@ class Co4ECanvas(QGraphicsView):
|
||||
self._port_src_pt = scene_pt
|
||||
self._temp_edge = QGraphicsPathItem()
|
||||
self._temp_edge.setZValue(3.5) # above nodes + edges while connecting
|
||||
self._temp_edge.setPen(QPen(QColor("#48CAE4"), 2, Qt.DashLine, Qt.RoundCap))
|
||||
self._temp_edge.setPen(
|
||||
QPen(QColor(current_palette().accent), 2, Qt.DashLine, Qt.RoundCap))
|
||||
self._scene.addItem(self._temp_edge)
|
||||
|
||||
def update_port_drag(self, scene_pt: QPointF) -> None:
|
||||
|
||||
+155
-23
@@ -11,18 +11,124 @@ from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtCore import Qt, QEasingCurve, QPropertyAnimation, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QFormLayout, QHBoxLayout, QInputDialog, QLabel,
|
||||
QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit, QPushButton,
|
||||
QScrollArea, QSpinBox, QVBoxLayout, QWidget,
|
||||
QCheckBox, QComboBox, QFormLayout, QHBoxLayout, QInputDialog,
|
||||
QLabel, QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit,
|
||||
QPushButton, QScrollArea, QSpinBox, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..config import PROVIDER_LABELS
|
||||
from ..core.co4e import PERMISSION_PRESETS, Step, SubAgent
|
||||
from ..i18n import tr
|
||||
from ..theme import current_palette
|
||||
from .icons import icon, icon_picker_combo
|
||||
|
||||
_SECTION_ANIM_MS = 180
|
||||
|
||||
|
||||
class _SectionHeader(QLabel):
|
||||
"""A clickable label — a QPushButton's own style chrome (border, native
|
||||
button margin, focus rect) always leaves a taller minimum height than a
|
||||
plain label, even once its QSS padding is zeroed out, so the header that
|
||||
needs to sit tight against its neighbours is a label, not a button."""
|
||||
|
||||
clicked = Signal()
|
||||
|
||||
def mousePressEvent(self, event) -> None: # noqa: N802
|
||||
if event.button() == Qt.LeftButton:
|
||||
self.clicked.emit()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def showEvent(self, event) -> None: # noqa: N802
|
||||
# fontMetrics() at construction time (before this label is ever part
|
||||
# of a shown top-level window) reflects the QSS font-size only if the
|
||||
# style has fully polished by then — on the very FIRST paint of the
|
||||
# Co4E screen it sometimes hasn't, so the fixed height computed in
|
||||
# _add_section is briefly wrong (too tall) until something else
|
||||
# triggers a relayout. Recomputing here, every time the label
|
||||
# actually becomes visible, means the first paint is never stale.
|
||||
self.setFixedHeight(self.fontMetrics().height())
|
||||
super().showEvent(event)
|
||||
|
||||
|
||||
def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
|
||||
"""One group of fields, collapsed to just its heading by default and
|
||||
independently expandable, so a long step config reads as a short list of
|
||||
group names until you open the one you need. Deliberately bare — no card
|
||||
border/background/box — the ▶/▼ marker and the heading text are the only
|
||||
things separating one group from the next; opening one never closes
|
||||
another (not an accordion, not a tab bar). Returns ``(form, card)``: add
|
||||
the group's rows to ``form``; ``card`` is the whole section (header +
|
||||
body) — hide it to remove the group entirely (e.g. for a section that
|
||||
only applies to some steps), rather than hiding individual rows inside
|
||||
an always-visible header."""
|
||||
p = current_palette()
|
||||
card = QWidget()
|
||||
card_lay = QVBoxLayout(card)
|
||||
card_lay.setContentsMargins(0, 0, 0, 0)
|
||||
card_lay.setSpacing(0)
|
||||
|
||||
header = _SectionHeader()
|
||||
header.setCursor(Qt.PointingHandCursor)
|
||||
header.setStyleSheet(f"font-weight:400; font-size:13px; color:{p.text}; padding:0; margin:0;")
|
||||
header.setContentsMargins(0, 0, 0, 0)
|
||||
# QSS font-size only lands on the widget's actual QFont (and therefore
|
||||
# its fontMetrics()) once the style sheet is polished — ensurePolished()
|
||||
# forces that now, so the fixed height below is computed from the 12px
|
||||
# font just set above, not the default one this label was constructed
|
||||
# with. A label's natural sizeHint still reserves font leading above/
|
||||
# below the glyphs on top of the (now zeroed) QSS padding — pinning the
|
||||
# height to the text's actual cap-to-baseline span is what closes that
|
||||
# last gap without clipping the ▶ glyph, the title, or Vietnamese
|
||||
# diacritics.
|
||||
header.ensurePolished()
|
||||
header.setFixedHeight(header.fontMetrics().height())
|
||||
header.setText(f"▶ {title}")
|
||||
card_lay.addWidget(header)
|
||||
|
||||
body = QWidget()
|
||||
body.setVisible(False)
|
||||
body.setMaximumHeight(0)
|
||||
form = QFormLayout(body)
|
||||
form.setContentsMargins(0, 6, 0, 0)
|
||||
card_lay.addWidget(body)
|
||||
|
||||
anim = QPropertyAnimation(body, b"maximumHeight", body)
|
||||
anim.setDuration(_SECTION_ANIM_MS)
|
||||
anim.setEasingCurve(QEasingCurve.InOutCubic)
|
||||
|
||||
is_open = False
|
||||
|
||||
def _on_finished() -> None:
|
||||
if is_open:
|
||||
# Uncapped once open, so switching to a step whose fields make
|
||||
# this section taller/shorter (e.g. a parallel node's sub-agent
|
||||
# list appearing) is never clipped by the height this animation
|
||||
# last landed on.
|
||||
body.setMaximumHeight(16_777_215)
|
||||
else:
|
||||
body.setVisible(False)
|
||||
anim.finished.connect(_on_finished)
|
||||
|
||||
def _toggle() -> None:
|
||||
nonlocal is_open
|
||||
is_open = not is_open
|
||||
header.setText(f"{'▼' if is_open else '▶'} {title}")
|
||||
anim.stop()
|
||||
if is_open:
|
||||
body.setVisible(True)
|
||||
anim.setStartValue(body.height())
|
||||
anim.setEndValue(body.sizeHint().height())
|
||||
else:
|
||||
anim.setStartValue(body.height())
|
||||
anim.setEndValue(0)
|
||||
anim.start()
|
||||
header.clicked.connect(_toggle)
|
||||
|
||||
outer.addWidget(card)
|
||||
return form, card
|
||||
|
||||
|
||||
class StepConfigPanel(QScrollArea):
|
||||
changed = Signal() # any field edited → repaint node + autosave
|
||||
@@ -39,7 +145,16 @@ class StepConfigPanel(QScrollArea):
|
||||
self.setWidgetResizable(True)
|
||||
host = QWidget()
|
||||
self.setWidget(host)
|
||||
form = QFormLayout(host)
|
||||
outer = QVBoxLayout(host)
|
||||
outer.setSpacing(1)
|
||||
|
||||
# Grouped sections stacked on one scrolling page — same fields as
|
||||
# before, grouped by what they're for: identity, execution
|
||||
# (model/permission), and the extra resources fed to the step
|
||||
# (skills/files/sub-agents). No tabs/accordion: every group's border
|
||||
# and heading are what separate it from its neighbours, and all three
|
||||
# are on screen (or one scroll away) at once.
|
||||
form, _basic_card = _add_section(outer, tr("co4e.tab_basic"))
|
||||
|
||||
self.label_edit = QLineEdit()
|
||||
self.label_edit.textChanged.connect(self._on_edit)
|
||||
@@ -80,6 +195,8 @@ class StepConfigPanel(QScrollArea):
|
||||
self.context_edit.textChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_context"), self.context_edit)
|
||||
|
||||
form2, _model_card = _add_section(outer, tr("co4e.tab_model_perm"))
|
||||
|
||||
model_row = QHBoxLayout()
|
||||
self.model_combo = QComboBox()
|
||||
self.model_combo.setEditable(True)
|
||||
@@ -92,13 +209,13 @@ class StepConfigPanel(QScrollArea):
|
||||
model_row.addWidget(self.model_combo, 1)
|
||||
model_row.addWidget(self.load_models_btn)
|
||||
mrow = QWidget(); mrow.setLayout(model_row)
|
||||
form.addRow(tr("co4e.f_model"), mrow)
|
||||
form2.addRow(tr("co4e.f_model"), mrow)
|
||||
|
||||
self.perm_combo = QComboBox()
|
||||
for preset in PERMISSION_PRESETS:
|
||||
self.perm_combo.addItem(tr(f"co4e.perm.{preset}"), preset)
|
||||
self.perm_combo.currentIndexChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_permission"), self.perm_combo)
|
||||
form2.addRow(tr("co4e.f_permission"), self.perm_combo)
|
||||
|
||||
verify_row = QHBoxLayout()
|
||||
self.verify_chk = QCheckBox(tr("co4e.f_self_verify"))
|
||||
@@ -111,13 +228,15 @@ class StepConfigPanel(QScrollArea):
|
||||
verify_row.addWidget(self.rounds_spin)
|
||||
verify_row.addStretch(1)
|
||||
vrow = QWidget(); vrow.setLayout(verify_row)
|
||||
form.addRow("", vrow)
|
||||
form2.addRow("", vrow)
|
||||
|
||||
form3, _skills_card = _add_section(outer, tr("co4e.tab_skills_files"))
|
||||
|
||||
# Skills checklist (registry skills)
|
||||
self.skills_list = QListWidget()
|
||||
self.skills_list.setMaximumHeight(110)
|
||||
self.skills_list.itemChanged.connect(self._on_edit)
|
||||
form.addRow(tr("co4e.f_skills"), self.skills_list)
|
||||
form3.addRow(tr("co4e.f_skills"), self.skills_list)
|
||||
|
||||
# Attachments — files whose extracted text is fed to this step at run time.
|
||||
self.attach_list = QListWidget()
|
||||
@@ -133,11 +252,15 @@ class StepConfigPanel(QScrollArea):
|
||||
att_btns.addWidget(self.attach_del_btn)
|
||||
att_btns.addStretch(1)
|
||||
abtn = QWidget(); abtn.setLayout(att_btns)
|
||||
form.addRow(tr("co4e.f_attachments"), self.attach_list)
|
||||
form.addRow("", abtn)
|
||||
form3.addRow(tr("co4e.f_attachments"), self.attach_list)
|
||||
form3.addRow("", abtn)
|
||||
|
||||
# Parallel sub-agents (only shown for parallel nodes)
|
||||
self.parallel_label = QLabel(tr("co4e.f_subagents"))
|
||||
# Parallel sub-agents get their OWN section — same header style as
|
||||
# Cơ bản/Model & Quyền/Skills & Tệp — rather than a row buried inside
|
||||
# Skills & Tệp, since it's really a distinct group, just one that
|
||||
# only applies to parallel-variant steps. load_step() hides the whole
|
||||
# card for a non-parallel step (see is_par below).
|
||||
form4, self._parallel_card = _add_section(outer, tr("co4e.f_subagents"))
|
||||
self.sub_list = QListWidget()
|
||||
self.sub_list.setMaximumHeight(90)
|
||||
self.sub_list.itemDoubleClicked.connect(self._edit_subagent) # re-pick agent
|
||||
@@ -152,10 +275,11 @@ class StepConfigPanel(QScrollArea):
|
||||
sub_btns.addWidget(self.sub_del_btn)
|
||||
sub_btns.addStretch(1)
|
||||
sbtn = QWidget(); sbtn.setLayout(sub_btns)
|
||||
form.addRow(self.parallel_label, self.sub_list)
|
||||
form.addRow("", sbtn)
|
||||
form4.addRow(self.sub_list)
|
||||
form4.addRow("", sbtn)
|
||||
|
||||
# Footer actions — one compact row (Run · Run from here · Delete).
|
||||
# Footer actions — one compact row (Run · Run from here · Delete),
|
||||
# kept below every section, not inside one of the cards.
|
||||
self.run_btn = QPushButton(tr("co4e.run"))
|
||||
self.run_btn.setIcon(icon("play"))
|
||||
self.run_btn.setToolTip(tr("co4e.run_this_step"))
|
||||
@@ -170,12 +294,21 @@ class StepConfigPanel(QScrollArea):
|
||||
self.del_btn.setFixedWidth(38)
|
||||
self.del_btn.clicked.connect(lambda: self.delete_node.emit(self._node_id))
|
||||
foot = QHBoxLayout()
|
||||
foot.setContentsMargins(0, 0, 0, 0)
|
||||
foot.addWidget(self.run_btn, 1)
|
||||
foot.addWidget(self.run_from_btn, 1)
|
||||
foot.addWidget(self.del_btn)
|
||||
foot_w = QWidget(); foot_w.setLayout(foot)
|
||||
form.addRow("", foot_w)
|
||||
outer.addWidget(foot_w)
|
||||
# Without this, QVBoxLayout hands every child widget an EQUAL share of
|
||||
# whatever extra height the scroll area's viewport has beyond the
|
||||
# content's own sizeHint (setWidgetResizable(True) stretches `host` to
|
||||
# fill it) — each collapsed header's card was measuring a true
|
||||
# sizeHint of ~17px but rendering over 100px taller, and no amount of
|
||||
# margin/padding/spacing on the header itself could touch that: the
|
||||
# surplus was being spent on the cards, not around them. One trailing
|
||||
# stretch absorbs all of it instead, so every section (and the
|
||||
# footer) renders at exactly its own natural height.
|
||||
outer.addStretch(1)
|
||||
|
||||
self.setEnabled(False)
|
||||
|
||||
@@ -209,12 +342,11 @@ class StepConfigPanel(QScrollArea):
|
||||
item = QListWidgetItem(_P(path).name)
|
||||
item.setToolTip(path)
|
||||
self.attach_list.addItem(item)
|
||||
# parallel sub-agents
|
||||
# parallel sub-agents — the whole "Agent song song" section only
|
||||
# applies to parallel-variant steps, so the entire card (header
|
||||
# included) is hidden for any other step, not just its rows.
|
||||
is_par = step.is_parallel
|
||||
self.parallel_label.setVisible(is_par)
|
||||
self.sub_list.setVisible(is_par)
|
||||
self.sub_add_btn.setVisible(is_par)
|
||||
self.sub_del_btn.setVisible(is_par)
|
||||
self._parallel_card.setVisible(is_par)
|
||||
self.sub_list.clear()
|
||||
if is_par:
|
||||
for sub in step.sub_agents:
|
||||
|
||||
+328
-67
@@ -34,6 +34,7 @@ from ..core.co4e_builtins import BUILTIN_AGENTS
|
||||
from ..core.co4e_run_manager import Co4ERunManager
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..theme import current_palette
|
||||
from .chat_view import ChatView
|
||||
from .co4e_canvas import CO4E_MIME, Co4ECanvas
|
||||
from .co4e_config_panel import StepConfigPanel
|
||||
@@ -260,7 +261,7 @@ class Co4ETab(QWidget):
|
||||
root.addWidget(self._split)
|
||||
|
||||
sidebar = self._build_sidebar()
|
||||
sidebar.setMinimumWidth(210)
|
||||
sidebar.setMinimumWidth(180) # 210 pushed the whole tab past 1214px min
|
||||
self._split.addWidget(sidebar)
|
||||
self._split.addWidget(self._build_center())
|
||||
self.config = StepConfigPanel(ctx)
|
||||
@@ -272,6 +273,19 @@ class Co4ETab(QWidget):
|
||||
self._config_collapsed = False
|
||||
self._config_expanded_w = 360
|
||||
self._split.addWidget(self._wrap_config())
|
||||
from PySide6.QtCore import QTimer
|
||||
|
||||
from .widgets import narrow_guard
|
||||
self._narrow_guard = narrow_guard(self, self._NARROW, self._apply_narrow_layout)
|
||||
# Deferred one tick: the parent chain (and therefore window()) only
|
||||
# exists after whoever is building this has finished adding it.
|
||||
QTimer.singleShot(0, self._narrow_guard.attach)
|
||||
# Start the sidebar wider than its 180px floor — at the floor the
|
||||
# "Chạy nền" button and the flow names are cut off.
|
||||
self._split.setSizes([230, 720, 360])
|
||||
self._split.setStretchFactor(0, 0)
|
||||
self._split.setStretchFactor(1, 1)
|
||||
self._split.setStretchFactor(2, 0)
|
||||
self._split.setStretchFactor(0, 0)
|
||||
self._split.setStretchFactor(1, 1)
|
||||
self._split.setStretchFactor(2, 0)
|
||||
@@ -306,6 +320,11 @@ class Co4ETab(QWidget):
|
||||
self.flow_bar.setCurrentIndex(bar_idx)
|
||||
self._reflect_active_run(wf.id)
|
||||
return
|
||||
# Without the strip there is nowhere to switch between open flows, so
|
||||
# opening one REPLACES the one on the canvas (saved first, as the tab
|
||||
# switch used to do). Runs already in progress are unaffected — they are
|
||||
# tracked per flow id and keep going in the background.
|
||||
self._close_other_flows()
|
||||
self._flows.append(wf)
|
||||
self.flow_bar.blockSignals(True)
|
||||
bar_idx = self.flow_bar.addTab(icon("flow"), wf.name or tr("co4e.untitled"))
|
||||
@@ -317,13 +336,43 @@ class Co4ETab(QWidget):
|
||||
self.flow_bar.setCurrentIndex(bar_idx)
|
||||
self._reflect_active_run(wf.id)
|
||||
|
||||
def _close_other_flows(self) -> None:
|
||||
"""Leave the canvas empty of flows, saving whatever was on it.
|
||||
|
||||
Called before opening a flow, because the tab strip that used to hold
|
||||
several at once is gone. Tab 0 (Runs) is never touched.
|
||||
"""
|
||||
if not self._flows:
|
||||
return
|
||||
if 0 <= self._active_flow_idx < len(self._flows):
|
||||
self._sync_wf_from_canvas()
|
||||
self.flow_bar.blockSignals(True)
|
||||
for idx in range(self.flow_bar.count() - 1, 0, -1):
|
||||
self.flow_bar.removeTab(idx)
|
||||
self.flow_bar.blockSignals(False)
|
||||
self._flows.clear()
|
||||
self._active_flow_idx = -1
|
||||
|
||||
def _show_runs(self, on: bool) -> None:
|
||||
"""Swap the centre between the flow editor and the Runs table.
|
||||
|
||||
This is where the pinned "Runs" tab went when the strip was removed —
|
||||
same page, same table, reached from a toggle in the flow toolbar.
|
||||
"""
|
||||
target = 0 if on else min(1, self.flow_bar.count() - 1)
|
||||
if self.flow_bar.currentIndex() == target:
|
||||
self._on_flow_tab_changed(target) # already there → re-apply
|
||||
else:
|
||||
self.flow_bar.setCurrentIndex(target)
|
||||
|
||||
def _on_flow_tab_changed(self, idx: int) -> None:
|
||||
# save the outgoing flow (active_flow_idx is a FLOWS-list index) first
|
||||
if 0 <= self._active_flow_idx < len(self._flows) and (self._active_flow_idx + 1) != idx:
|
||||
self._sync_wf_from_canvas()
|
||||
if idx <= 0: # the pinned Runs tab
|
||||
if idx <= 0: # the Runs page
|
||||
self._active_flow_idx = -1
|
||||
self.center_stack.setCurrentIndex(0)
|
||||
self._sync_runs_toggle(True)
|
||||
self._refresh_runs()
|
||||
return
|
||||
flow_idx = idx - 1
|
||||
@@ -331,8 +380,18 @@ class Co4ETab(QWidget):
|
||||
return
|
||||
self._active_flow_idx = flow_idx
|
||||
self.center_stack.setCurrentIndex(1)
|
||||
self._sync_runs_toggle(False)
|
||||
self._apply_workflow(self._flows[flow_idx])
|
||||
|
||||
def _sync_runs_toggle(self, on: bool) -> None:
|
||||
"""Keep the Runs toggle showing which page is up, however it got there
|
||||
(a double-click in the runs table also switches pages)."""
|
||||
btn = getattr(self, "runs_btn", None)
|
||||
if btn is not None and btn.isChecked() != on:
|
||||
blocked = btn.blockSignals(True)
|
||||
btn.setChecked(on)
|
||||
btn.blockSignals(blocked)
|
||||
|
||||
def _add_tab_close_button(self, idx: int) -> None:
|
||||
"""Give a flow tab its own close button — a small ✕ placed by QTabBar on
|
||||
the tab's right side, vertically centered and INSIDE the tab (reliable
|
||||
@@ -423,22 +482,47 @@ class Co4ETab(QWidget):
|
||||
|
||||
# ---- sidebar ----------------------------------------------------------
|
||||
def _build_sidebar(self) -> QWidget:
|
||||
self.sidebar = QTabWidget()
|
||||
# Icon-only tabs share the full sidebar width equally (line up with the
|
||||
# list below) via an equal-width tab bar — no left/right scroll. Colours
|
||||
# come from theme.py (QTabBar#co4eSideTabs — transparent tabs + a subtle
|
||||
# translucent selection with the theme text colour, like the app's lists).
|
||||
self.sidebar.setTabBar(_EqualTabBar())
|
||||
_tb = self.sidebar.tabBar()
|
||||
_tb.setObjectName("co4eSideTabs")
|
||||
_tb.setUsesScrollButtons(False)
|
||||
_tb.setElideMode(Qt.ElideNone)
|
||||
# Workflows
|
||||
wf_page = QWidget(); wl = QVBoxLayout(wf_page)
|
||||
wl.setContentsMargins(6, 6, 6, 6)
|
||||
# ONE COLUMN, four named sections — no icon tabs. Every list is on screen
|
||||
# at once, so "what can I drag onto the canvas" is answered by looking
|
||||
# rather than by clicking through three unlabeled tabs.
|
||||
# A vertical splitter, not a fixed stack: on a short window four stacked
|
||||
# lists otherwise squeeze down to one visible row each. The splitter
|
||||
# hands out the available height by weight and lets the user re-balance
|
||||
# it by dragging; each list keeps a small minimum so none disappears.
|
||||
self._sections: dict = {}
|
||||
self.sidebar = QWidget()
|
||||
outer_col = QVBoxLayout(self.sidebar)
|
||||
outer_col.setContentsMargins(6, 6, 6, 6)
|
||||
outer_col.setSpacing(0)
|
||||
self.side_split = QSplitter(Qt.Vertical)
|
||||
self.side_split.setChildrenCollapsible(False)
|
||||
self.side_split.setHandleWidth(8)
|
||||
outer_col.addWidget(self.side_split, 1)
|
||||
|
||||
class _Col:
|
||||
"""Adapter so the section builders below read the same as before."""
|
||||
|
||||
def __init__(self, split):
|
||||
self._split = split
|
||||
|
||||
def addWidget(self, w, stretch=1):
|
||||
self._split.addWidget(w)
|
||||
self._split.setStretchFactor(self._split.count() - 1, stretch)
|
||||
|
||||
col = _Col(self.side_split)
|
||||
|
||||
# --- WORKFLOWS ---------------------------------------------------
|
||||
self.wf_new_btn = QPushButton(tr("co4e.new"))
|
||||
self.wf_new_btn.setIcon(icon("plus"))
|
||||
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
|
||||
self.wf_new_btn.setObjectName("co4eSectionAction")
|
||||
self.wf_new_btn.setFlat(True)
|
||||
self.wf_new_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.wf_new_btn.clicked.connect(self._new_workflow)
|
||||
wf_body = QWidget(); wl = QVBoxLayout(wf_body)
|
||||
wl.setContentsMargins(0, 0, 0, 0); wl.setSpacing(4)
|
||||
# Draggable: drag a flow onto the canvas to merge it in (Nova-style);
|
||||
# double-click loads it into an empty canvas. (The old always-on hint
|
||||
# label was removed to give the flow list more room; it's a tooltip now.)
|
||||
# double-click loads it onto the canvas.
|
||||
self.wf_list = _PaletteList(payload_role=Qt.UserRole + 2)
|
||||
self.wf_list.setToolTip(tr("co4e.drag_hint"))
|
||||
self.wf_list.itemDoubleClicked.connect(self._load_selected_workflow)
|
||||
@@ -446,57 +530,157 @@ class Co4ETab(QWidget):
|
||||
self.wf_list.customContextMenuRequested.connect(self._wf_context_menu)
|
||||
wl.addWidget(self.wf_list, 1)
|
||||
wf_btns = QHBoxLayout(); wf_btns.setSpacing(4)
|
||||
# "New flow" moved to the "+" button on the flow tab strip (browser-style).
|
||||
# "Load selected flow to canvas" button removed — double-click a flow in
|
||||
# the list (or drag it onto the canvas) to open it; the explicit button
|
||||
# was redundant.
|
||||
self.wf_edit_btn = self._icon_btn("edit", "co4e.tt_edit_wf", self._edit_selected_workflow)
|
||||
self.wf_dup_btn = self._icon_btn("branch", "co4e.tt_dup_wf", self._duplicate_selected_workflow)
|
||||
self.wf_del_btn = self._icon_btn("trash", "co4e.tt_del_wf", self._delete_selected_workflow)
|
||||
for b in (self.wf_edit_btn, self.wf_dup_btn, self.wf_del_btn):
|
||||
wf_btns.addWidget(b)
|
||||
wf_btns.addStretch(1)
|
||||
wl.addLayout(wf_btns)
|
||||
# Its own row: sharing one line with the three icon buttons cut "Chạy
|
||||
# nền" down to "Chạ" as soon as the sidebar hit its narrow width.
|
||||
self.wf_runbg_btn = QPushButton(tr("co4e.run_bg")); self.wf_runbg_btn.setIcon(icon("play"))
|
||||
self.wf_runbg_btn.setToolTip(tr("co4e.tt_run_bg"))
|
||||
self.wf_runbg_btn.clicked.connect(self._run_selected_in_background)
|
||||
wf_btns.addWidget(self.wf_runbg_btn, 1)
|
||||
wl.addLayout(wf_btns)
|
||||
# (The "Running flows" list moved out of the sidebar into the pinned
|
||||
# "Runs" tab at the front of the flow tabs — see _build_runs_page.)
|
||||
# Icon-only tabs keep the sidebar narrow; the name is a tooltip.
|
||||
self.sidebar.addTab(wf_page, icon("flow"), "")
|
||||
self.sidebar.setTabToolTip(0, tr("co4e.tab_workflows"))
|
||||
wl.addWidget(self.wf_runbg_btn)
|
||||
col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3)
|
||||
|
||||
# Agents (drag onto canvas; CRUD custom)
|
||||
ag_page = QWidget(); al = QVBoxLayout(ag_page)
|
||||
al.setContentsMargins(6, 6, 6, 6)
|
||||
# --- AGENTS ------------------------------------------------------
|
||||
self.ag_new_btn = QPushButton(tr("co4e.new")); self.ag_new_btn.setIcon(icon("plus"))
|
||||
self.ag_new_btn.setToolTip(tr("co4e.tt_new_agent"))
|
||||
self.ag_new_btn.setObjectName("co4eSectionAction")
|
||||
self.ag_new_btn.setFlat(True)
|
||||
self.ag_new_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.ag_new_btn.clicked.connect(self._new_agent)
|
||||
ag_body = QWidget(); al = QVBoxLayout(ag_body)
|
||||
al.setContentsMargins(0, 0, 0, 0); al.setSpacing(4)
|
||||
self.agent_list = _PaletteList()
|
||||
al.addWidget(self.agent_list, 1)
|
||||
ag_btns = QHBoxLayout(); ag_btns.setSpacing(4)
|
||||
self.ag_new_btn = QPushButton(tr("co4e.new")); self.ag_new_btn.setIcon(icon("plus"))
|
||||
self.ag_new_btn.setToolTip(tr("co4e.tt_new_agent"))
|
||||
self.ag_new_btn.clicked.connect(self._new_agent)
|
||||
# Edit/delete act on the selected row, so they stay with the list.
|
||||
self.ag_edit_btn = self._icon_btn("edit", "co4e.tt_edit_agent", self._edit_agent)
|
||||
self.ag_del_btn = self._icon_btn("trash", "co4e.tt_del_agent", self._delete_agent)
|
||||
ag_btns.addWidget(self.ag_new_btn, 1)
|
||||
ag_btns.addWidget(self.ag_edit_btn)
|
||||
ag_btns.addWidget(self.ag_del_btn)
|
||||
ag_btns.addStretch(1)
|
||||
al.addLayout(ag_btns)
|
||||
self.sidebar.addTab(ag_page, icon("robot"), "")
|
||||
self.sidebar.setTabToolTip(1, tr("co4e.tab_agents"))
|
||||
col.addWidget(self._section("co4e.tab_agents", ag_body, self.ag_new_btn), 3)
|
||||
|
||||
# Skills (drag onto canvas; manage via existing Skills manager button)
|
||||
sk_page = QWidget(); sl = QVBoxLayout(sk_page)
|
||||
sl.setContentsMargins(6, 6, 6, 6)
|
||||
self.skill_list = _PaletteList()
|
||||
sl.addWidget(self.skill_list, 1)
|
||||
# --- SKILLS ------------------------------------------------------
|
||||
self.sk_manage_btn = QPushButton(tr("co4e.manage_skills"))
|
||||
self.sk_manage_btn.setToolTip(tr("co4e.tt_manage_skills"))
|
||||
self.sk_manage_btn.setObjectName("co4eSectionAction")
|
||||
self.sk_manage_btn.setFlat(True)
|
||||
self.sk_manage_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.sk_manage_btn.clicked.connect(self._manage_skills)
|
||||
sl.addWidget(self.sk_manage_btn)
|
||||
self.sidebar.addTab(sk_page, icon("sparkle"), "")
|
||||
self.sidebar.setTabToolTip(2, tr("co4e.tab_skills"))
|
||||
sk_body = QWidget(); sl = QVBoxLayout(sk_body)
|
||||
sl.setContentsMargins(0, 0, 0, 0); sl.setSpacing(4)
|
||||
self.skill_list = _PaletteList()
|
||||
sl.addWidget(self.skill_list, 1)
|
||||
col.addWidget(self._section("co4e.tab_skills", sk_body, self.sk_manage_btn), 2)
|
||||
|
||||
# --- RUNS --------------------------------------------------------
|
||||
# A short, always-visible view of the same runs the Flow Status page
|
||||
# tables in full. Clicking one opens that page with the run selected.
|
||||
# Icon only: the heading beside it already reads FLOW STATUS, and the
|
||||
# label was long enough to be cut in half in a narrow sidebar.
|
||||
self.runs_more_btn = QPushButton()
|
||||
self.runs_more_btn.setIcon(icon("chevron-right"))
|
||||
self.runs_more_btn.setFixedWidth(30)
|
||||
self.runs_more_btn.setFlat(True)
|
||||
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.runs_more_btn.clicked.connect(lambda: self._show_runs(True))
|
||||
runs_body = QWidget(); rl = QVBoxLayout(runs_body)
|
||||
rl.setContentsMargins(0, 0, 0, 0); rl.setSpacing(4)
|
||||
self.runs_side_list = QListWidget()
|
||||
self.runs_side_list.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.runs_side_list.itemClicked.connect(self._on_side_run_clicked)
|
||||
rl.addWidget(self.runs_side_list, 1)
|
||||
col.addWidget(self._section("co4e.runs_tab", runs_body, self.runs_more_btn), 2)
|
||||
# Small enough that all four still fit on a laptop screen, large enough
|
||||
# that each shows more than a single row.
|
||||
for lst in (self.wf_list, self.agent_list, self.skill_list, self.runs_side_list):
|
||||
lst.setMinimumHeight(56)
|
||||
return self.sidebar
|
||||
|
||||
_SIDE_RUNS = 6
|
||||
|
||||
def _refresh_side_runs(self) -> None:
|
||||
"""Mirror the newest runs into the sidebar's short list."""
|
||||
lst = getattr(self, "runs_side_list", None)
|
||||
if lst is None:
|
||||
return
|
||||
dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
|
||||
lst.clear()
|
||||
for h in list(reversed(self.manager.runs()))[:self._SIDE_RUNS]:
|
||||
it = QListWidgetItem(f"{dots.get(h.status, '•')} {h.name}"
|
||||
f" {h.progress_text()}")
|
||||
it.setData(Qt.UserRole, h.id)
|
||||
it.setToolTip(f"{h.name} · {tr('co4e.status.' + h.status)} · {h.created_at or '-'}")
|
||||
lst.addItem(it)
|
||||
|
||||
def _on_side_run_clicked(self, item) -> None:
|
||||
"""Open the full Flow Status page with this run selected."""
|
||||
run_id = item.data(Qt.UserRole)
|
||||
self._show_runs(True)
|
||||
for r in range(self.runs_table.rowCount()):
|
||||
cell = self.runs_table.item(r, 0)
|
||||
if cell is not None and cell.data(Qt.UserRole) == run_id:
|
||||
self.runs_table.setCurrentCell(r, 0)
|
||||
break
|
||||
|
||||
def _section(self, key: str, body: QWidget, action: QPushButton | None = None,
|
||||
stretch: int = 1) -> QWidget:
|
||||
"""One named, foldable section of the sidebar column.
|
||||
|
||||
Replaces the three icon-only tabs: all the lists are visible at once
|
||||
(WORKFLOWS · AGENTS · SKILLS · runs), each under its own heading with the
|
||||
action that belongs to it. Clicking the heading folds the section, so a
|
||||
narrow window can still get to everything.
|
||||
"""
|
||||
box = QWidget()
|
||||
v = QVBoxLayout(box)
|
||||
v.setContentsMargins(0, 0, 0, 0)
|
||||
v.setSpacing(2)
|
||||
|
||||
row = QHBoxLayout()
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.setSpacing(4)
|
||||
head = QPushButton()
|
||||
head.setObjectName("co4eSectionHdr")
|
||||
head.setCheckable(True)
|
||||
head.setChecked(True)
|
||||
head.setCursor(Qt.PointingHandCursor)
|
||||
head.setFlat(True)
|
||||
head.toggled.connect(lambda on, w=body, b=box: self._fold_section(key, w, b, on))
|
||||
row.addWidget(head, 1)
|
||||
if action is not None:
|
||||
row.addWidget(action, 0)
|
||||
v.addLayout(row)
|
||||
v.addWidget(body, 1)
|
||||
|
||||
self._sections[key] = (head, body, stretch)
|
||||
self._sync_section_arrow(key)
|
||||
return box
|
||||
|
||||
def _fold_section(self, key: str, body: QWidget, box: QWidget, on: bool) -> None:
|
||||
"""Fold/unfold a section AND give its height back to the others.
|
||||
|
||||
Inside a splitter, hiding the body is not enough — the pane keeps its
|
||||
share of the height, so folding would free nothing. Clamping the whole
|
||||
section to its header height makes the splitter re-deal the space.
|
||||
"""
|
||||
body.setVisible(on)
|
||||
if on:
|
||||
box.setMaximumHeight(16777215)
|
||||
else:
|
||||
box.setMaximumHeight(box.layout().itemAt(0).sizeHint().height() + 4)
|
||||
self._sync_section_arrow(key)
|
||||
|
||||
def _sync_section_arrow(self, key: str) -> None:
|
||||
head, _body, _s = self._sections[key]
|
||||
head.setText(("▾ " if head.isChecked() else "▸ ") + tr(key).upper())
|
||||
|
||||
def _icon_btn(self, icon_name: str, tip_key: str, slot) -> QPushButton:
|
||||
b = QPushButton(); b.setIcon(icon(icon_name)); b.setToolTip(tr(tip_key))
|
||||
b.setFixedWidth(34)
|
||||
@@ -564,13 +748,14 @@ class Co4ETab(QWidget):
|
||||
# Tab colours + layout live in theme.py (QTabBar#flowTabs — theme-aware,
|
||||
# flush, centred). Here we only style the per-tab close (✕) button, which
|
||||
# QTabBar places centred on the tab's right (see _add_tab_close_button).
|
||||
_fp = current_palette()
|
||||
self.flow_bar.setStyleSheet(
|
||||
"QPushButton#flowTabClose {"
|
||||
" border: none; background: transparent; color: #8FB2D4;"
|
||||
f" border: none; background: transparent; color: {_fp.text_muted};"
|
||||
" font-size: 13px; font-weight: bold; padding: 0; margin: 0;"
|
||||
" border-radius: 8px; }"
|
||||
f" border-radius: {_fp.radius_sm}px; }}"
|
||||
"QPushButton#flowTabClose:hover {"
|
||||
" background: rgba(229,72,77,0.18); color: #E5484D; }")
|
||||
f" background: {_fp.danger_soft}; color: {_fp.danger}; }}")
|
||||
runs_idx = self.flow_bar.addTab(icon("monitoring"), tr("co4e.runs_tab")) # 0 = Runs
|
||||
self.flow_bar.setTabButton(runs_idx, QTabBar.RightSide, None) # pinned
|
||||
self.flow_bar.currentChanged.connect(self._on_flow_tab_changed)
|
||||
@@ -606,15 +791,16 @@ class Co4ETab(QWidget):
|
||||
"QScrollArea#flowTabScroll { background: transparent; border: none; }"
|
||||
"QScrollArea#flowTabScroll QScrollBar:horizontal { height: 8px; background: transparent; margin: 0; }"
|
||||
"QScrollArea#flowTabScroll QScrollBar::handle:horizontal {"
|
||||
" background: rgba(143,178,212,0.45); border-radius: 4px; min-width: 30px; }"
|
||||
f" background: {current_palette().scroll_handle}; border-radius: 4px; min-width: 30px; }}"
|
||||
"QScrollArea#flowTabScroll QScrollBar::add-line:horizontal,"
|
||||
"QScrollArea#flowTabScroll QScrollBar::sub-line:horizontal { width: 0; height: 0; }")
|
||||
flow_row = QHBoxLayout()
|
||||
flow_row.setContentsMargins(0, 0, 0, 0)
|
||||
flow_row.setSpacing(3) # same gap as between tabs → looks like one strip
|
||||
flow_row.addWidget(self.flow_scroll, 1)
|
||||
flow_row.addWidget(self.flow_add_btn, 0, Qt.AlignVCenter)
|
||||
lay.addLayout(flow_row)
|
||||
# The strip itself is NOT shown any more (see class docstring): flows are
|
||||
# picked from the WORKFLOWS list on the left, one open at a time. The
|
||||
# QTabBar stays alive off-screen as the index that maps flow ↔ canvas —
|
||||
# every open/close/rename path already goes through it — but the user
|
||||
# never sees or drives it.
|
||||
self.flow_scroll.setVisible(False)
|
||||
self.flow_add_btn.setVisible(False)
|
||||
|
||||
# Content switches between the Runs table (tab 0) and the flow editor.
|
||||
self.center_stack = QStackedWidget()
|
||||
@@ -650,6 +836,14 @@ class Co4ETab(QWidget):
|
||||
self.run_btn.setToolTip(tr("co4e.tt_run"))
|
||||
self.run_btn.clicked.connect(self._on_run_clicked)
|
||||
|
||||
# The pinned "Runs" tab lost its strip, so it becomes a toggle here —
|
||||
# one click to the run table and one click back, from either page.
|
||||
self.runs_btn = QPushButton(tr("co4e.runs_tab"))
|
||||
self.runs_btn.setIcon(icon("monitoring"))
|
||||
self.runs_btn.setCheckable(True)
|
||||
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.runs_btn.toggled.connect(self._show_runs)
|
||||
|
||||
bar.addWidget(QLabel(tr("co4e.flow_name")))
|
||||
bar.addWidget(self.name_edit, 1)
|
||||
bar.addWidget(self.add_step_btn)
|
||||
@@ -657,6 +851,7 @@ class Co4ETab(QWidget):
|
||||
bar.addWidget(self.save_tpl_btn)
|
||||
bar.addWidget(self.mode_combo)
|
||||
bar.addWidget(self.run_btn)
|
||||
bar.addWidget(self.runs_btn)
|
||||
lay.addLayout(bar)
|
||||
|
||||
self.canvas = Co4ECanvas()
|
||||
@@ -683,6 +878,13 @@ class Co4ETab(QWidget):
|
||||
w = QWidget()
|
||||
v = QVBoxLayout(w)
|
||||
hdr = QHBoxLayout()
|
||||
# The Runs page covers the flow toolbar, so it carries its own way back —
|
||||
# otherwise the toggle that opened it is off screen.
|
||||
self.runs_back_btn = QPushButton(tr("co4e.back_to_flow"))
|
||||
self.runs_back_btn.setIcon(icon("chevron-left"))
|
||||
self.runs_back_btn.setToolTip(tr("co4e.tt_back_to_flow"))
|
||||
self.runs_back_btn.clicked.connect(lambda: self._show_runs(False))
|
||||
hdr.addWidget(self.runs_back_btn)
|
||||
self.runs_title = QLabel(tr("co4e.running_flows"))
|
||||
self.runs_title.setObjectName("hint")
|
||||
hdr.addWidget(self.runs_title)
|
||||
@@ -762,6 +964,29 @@ class Co4ETab(QWidget):
|
||||
self.config_container = container
|
||||
return container
|
||||
|
||||
# Below this window width the three panes (rail + 180 sidebar + canvas +
|
||||
# 300 config) leave the canvas too little to draw a flow in, and the config
|
||||
# fields start clipping instead of shrinking. Measured with
|
||||
# tools/check_responsive.py — Co4E gets the full content area (no project or
|
||||
# history pane beside it), so the threshold is about its own screen only.
|
||||
_NARROW = 1300
|
||||
|
||||
def showEvent(self, e): # noqa: N802 - Qt override
|
||||
super().showEvent(e)
|
||||
self._narrow_guard.attach()
|
||||
|
||||
def _apply_narrow_layout(self, narrow: bool) -> None: # noqa: D401
|
||||
"""Fold the step-config panel on a narrow window, restore it when there
|
||||
is room again.
|
||||
|
||||
Attached from __init__ rather than only on show: this page sits inside a
|
||||
QTabWidget, whose minimum width is the MAXIMUM over all its pages —
|
||||
including hidden ones. While Co4E sat unfolded in the background it was
|
||||
forcing Project and Cowork to be ~1180px wide too.
|
||||
"""
|
||||
if narrow != self._config_collapsed:
|
||||
self._toggle_config()
|
||||
|
||||
def _toggle_config(self) -> None:
|
||||
self._config_collapsed = not self._config_collapsed
|
||||
v = self._cfg_vlayout
|
||||
@@ -785,6 +1010,11 @@ class Co4ETab(QWidget):
|
||||
sizes[2] = 34
|
||||
sizes[1] = max(200, sizes[1] + freed)
|
||||
self._split.setSizes(sizes)
|
||||
# Without this the splitter keeps reporting the OLD minimum width,
|
||||
# and since a QTabWidget's minimum is the maximum over all its pages
|
||||
# — hidden ones included — Co4E would go on forcing Project and
|
||||
# Cowork to be 1180px wide even while folded here.
|
||||
self._refresh_min_width()
|
||||
else:
|
||||
v.removeItem(self._cfg_top_spacer)
|
||||
v.removeItem(self._cfg_bot_spacer)
|
||||
@@ -800,6 +1030,14 @@ class Co4ETab(QWidget):
|
||||
sizes[2] = want
|
||||
sizes[1] = max(200, sizes[1] - delta)
|
||||
self._split.setSizes(sizes)
|
||||
self._refresh_min_width()
|
||||
|
||||
def _refresh_min_width(self) -> None:
|
||||
"""Make the splitter (and everything above it) re-read its minimum."""
|
||||
self.config_container.updateGeometry()
|
||||
self._split.refresh()
|
||||
self._split.updateGeometry()
|
||||
self.updateGeometry()
|
||||
|
||||
def _build_canvas_overlay(self) -> None:
|
||||
"""Zoom +/− and Fit as a small floating control at the canvas's
|
||||
@@ -861,7 +1099,8 @@ class Co4ETab(QWidget):
|
||||
# $cost) at the bottom, exactly like Cowork's conversation total.
|
||||
self._usage_total_lbl = QLabel("")
|
||||
self._usage_total_lbl.setObjectName("hint")
|
||||
self._usage_total_lbl.setStyleSheet("color: rgba(140,146,152,0.9); font-size: 11px;")
|
||||
self._usage_total_lbl.setStyleSheet(
|
||||
f"color: {current_palette().text_faint}; font-size: 11px;")
|
||||
crow.addWidget(self._usage_total_lbl)
|
||||
_inp = QWidget(); row = QHBoxLayout(_inp); row.setContentsMargins(0, 0, 0, 0)
|
||||
self.chat_input = _ChatInput()
|
||||
@@ -969,7 +1208,14 @@ class Co4ETab(QWidget):
|
||||
self._refresh_usage_total() # show THIS flow's token/cost total
|
||||
|
||||
def _new_workflow(self) -> None:
|
||||
self._open_flow(co4e.new_workflow(tr("co4e.untitled"))) # opens a new tab
|
||||
self._open_flow(co4e.new_workflow(tr("co4e.untitled")))
|
||||
# Pressing this while the canvas already holds an empty untitled flow
|
||||
# produced an identical empty untitled flow — correct, and completely
|
||||
# invisible, so the button read as broken. Say what happened and put the
|
||||
# cursor where the next thing to do is: naming it.
|
||||
self.name_edit.setFocus()
|
||||
self.name_edit.selectAll()
|
||||
self.status_message.emit(tr("co4e.new_flow_ready"))
|
||||
|
||||
def _selected_wf(self) -> Optional[co4e.Workflow]:
|
||||
"""Materialise the selected saved-flow row into a Workflow."""
|
||||
@@ -1331,8 +1577,9 @@ class Co4ETab(QWidget):
|
||||
# Rebuild the always-fresh Runs table from the manager (single source of truth).
|
||||
if not hasattr(self, "runs_table"):
|
||||
return
|
||||
color = {"running": "#48CAE4", "done": "#48D9A0", "error": "#E5484D",
|
||||
"stopped": "#8FB2D4"}
|
||||
p = current_palette()
|
||||
color = {"running": p.accent, "done": p.success, "error": p.danger,
|
||||
"stopped": p.text_muted}
|
||||
dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"}
|
||||
# Most-recent run at the TOP, oldest at the bottom (manager keeps runs in
|
||||
# chronological insertion order, so reverse it for display).
|
||||
@@ -1352,16 +1599,22 @@ class Co4ETab(QWidget):
|
||||
if c == 0:
|
||||
it.setData(Qt.UserRole, h.id)
|
||||
if c == 1:
|
||||
it.setForeground(_qcolor(color.get(h.status, "#E0F0FF")))
|
||||
it.setForeground(_qcolor(color.get(h.status, p.text)))
|
||||
t.setItem(r, c, it)
|
||||
if h.id == sel_id:
|
||||
sel_row = r
|
||||
if sel_row >= 0:
|
||||
t.setCurrentCell(sel_row, 0)
|
||||
# reflect the active run count in the pinned Runs tab title
|
||||
# The sidebar's short run list is the same data — refresh it together.
|
||||
self._refresh_side_runs()
|
||||
# Active-run count, on the sidebar heading now that the tab strip is gone.
|
||||
n = self.manager.active_count()
|
||||
label = tr("co4e.runs_tab_n", n=n) if n else tr("co4e.runs_tab")
|
||||
if hasattr(self, "flow_bar"):
|
||||
n = self.manager.active_count()
|
||||
self.flow_bar.setTabText(0, tr("co4e.runs_tab_n", n=n) if n else tr("co4e.runs_tab"))
|
||||
self.flow_bar.setTabText(0, label)
|
||||
head = (self._sections.get("co4e.runs_tab") or (None,))[0]
|
||||
if head is not None:
|
||||
head.setText(("▾ " if head.isChecked() else "▸ ") + label.upper())
|
||||
|
||||
def _stop_selected_run(self) -> None:
|
||||
row = self.runs_table.currentRow()
|
||||
@@ -1502,7 +1755,9 @@ class Co4ETab(QWidget):
|
||||
return
|
||||
self.manager.start(wf, skill_map=self._skill_map(),
|
||||
plan_mode=(self._current_mode() == "plan"))
|
||||
self.sidebar.setCurrentIndex(0)
|
||||
# Used to jump the sidebar back to the Workflows tab; with one column
|
||||
# there is nothing to jump to — show the run that just started instead.
|
||||
self._refresh_side_runs()
|
||||
self.status_message.emit(tr("co4e.bg_started", name=wf.name))
|
||||
|
||||
def _wf_by_id(self, wf_id: str) -> Optional[co4e.Workflow]:
|
||||
@@ -1803,9 +2058,15 @@ class Co4ETab(QWidget):
|
||||
|
||||
# ---- i18n -------------------------------------------------------------
|
||||
def _retranslate(self) -> None:
|
||||
self.sidebar.setTabToolTip(0, tr("co4e.tab_workflows"))
|
||||
self.sidebar.setTabToolTip(1, tr("co4e.tab_agents"))
|
||||
self.sidebar.setTabToolTip(2, tr("co4e.tab_skills"))
|
||||
for key in self._sections:
|
||||
self._sync_section_arrow(key)
|
||||
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.wf_new_btn.setText(tr("co4e.new"))
|
||||
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf"))
|
||||
self.runs_btn.setText(tr("co4e.runs_tab"))
|
||||
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
|
||||
self.runs_back_btn.setText(tr("co4e.back_to_flow"))
|
||||
self.runs_back_btn.setToolTip(tr("co4e.tt_back_to_flow"))
|
||||
self.runs_title.setText(tr("co4e.running_flows"))
|
||||
self.run_stop_btn.setText(tr("co4e.stop"))
|
||||
self.run_rename_btn.setText(tr("co4e.rename_run"))
|
||||
|
||||
+20
-4
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from ..config import CONFIG_DIR
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..theme import current_palette
|
||||
from .icons import icon, IconLabel
|
||||
|
||||
|
||||
@@ -451,7 +452,12 @@ class Composer(QWidget):
|
||||
self.stop_btn.setObjectName("danger")
|
||||
self.stop_btn.setVisible(False)
|
||||
self.stop_btn.clicked.connect(self.stop_requested.emit)
|
||||
# Attach pinned to the input's top edge, Send (and Stop, once a turn
|
||||
# is running) pinned to its bottom edge — the gap between them is
|
||||
# absorbed by this stretch instead of splitting evenly above/below
|
||||
# the whole button column, which is what centering it did before.
|
||||
btns.addWidget(self.attach_btn)
|
||||
btns.addStretch(1)
|
||||
btns.addWidget(self.send_btn)
|
||||
btns.addWidget(self.stop_btn)
|
||||
row.addLayout(btns)
|
||||
@@ -459,11 +465,18 @@ class Composer(QWidget):
|
||||
|
||||
# bottom row: left slot (e.g. Cowork's output-folder picker) — stretch —
|
||||
# right slot (e.g. Plan/Act toggle, the per-tab Agent combo on Code/Cowork)
|
||||
# Its own strip UNDER the typing box, styled as a status line rather
|
||||
# than a second toolbar: the design asks for the typing area to be just
|
||||
# input · attach · send, with agent / routing / usage / folder reading
|
||||
# as status underneath. They stay interactive — only quieter.
|
||||
self._bottom_left_count = 0
|
||||
self.extra_row = QHBoxLayout()
|
||||
self.extra_row.setContentsMargins(0, 0, 0, 0)
|
||||
self.extra_bar = QWidget()
|
||||
self.extra_bar.setObjectName("composerStatus")
|
||||
self.extra_row = QHBoxLayout(self.extra_bar)
|
||||
self.extra_row.setContentsMargins(2, 2, 2, 0)
|
||||
self.extra_row.setSpacing(6)
|
||||
self.extra_row.addStretch(1)
|
||||
root.addLayout(self.extra_row)
|
||||
root.addWidget(self.extra_bar)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
@@ -583,7 +596,10 @@ class Composer(QWidget):
|
||||
for p in self._attachments:
|
||||
item = QListWidgetItem()
|
||||
row = QWidget()
|
||||
row.setStyleSheet("background: rgba(140,146,152,0.18); border-radius: 6px;")
|
||||
_cp = current_palette()
|
||||
row.setStyleSheet(
|
||||
f"background: {_cp.surface_raised}; border: 1px solid {_cp.border};"
|
||||
f" border-radius: {_cp.radius_sm}px;")
|
||||
h = QHBoxLayout(row)
|
||||
h.setContentsMargins(8, 2, 4, 2)
|
||||
h.setSpacing(4)
|
||||
|
||||
+181
-136
@@ -1,18 +1,20 @@
|
||||
"""Connectors (MCP / REST API) management — the setup UI.
|
||||
|
||||
Lives in Monitoring → Tools → "Connector" sub-tab (moved out of Settings). A
|
||||
tree of the four categories (CAD / CAE / MS365 / Other); each connector has an
|
||||
Enabled checkbox and can be added / edited / deleted (MCP-stdio or REST-API,
|
||||
via ExtConnectorEditDialog). Built-in connectors (MS365 OneDrive / SharePoint,
|
||||
and Jira under "Other") appear as rows in the tree with their checkbox bound to
|
||||
config; double-clicking one opens its setup — nothing spills outside the tree.
|
||||
Lives in Monitoring → Tools → "Connector" sub-tab (moved out of Settings).
|
||||
Grouped by the four real categories (CAD / CAE / MS365 / Other): each is a
|
||||
header (icon + name + the software it covers) above a left-aligned row of
|
||||
cards, one per real connector, each with its own on/off switch — user-added
|
||||
connectors (MCP-stdio or REST-API, via ExtConnectorEditDialog) also get
|
||||
✎ Sửa/🗑 Xóa; the built-in ones (MS365 OneDrive/SharePoint, Jira under
|
||||
"Other") only get what they actually support (Jira: ✎ Sửa only, opens its own
|
||||
setup dialog; OneDrive/SharePoint: neither, they only toggle).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox, QDialog, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox,
|
||||
QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,
|
||||
QDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, QMessageBox,
|
||||
QPushButton, QScrollArea, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
|
||||
@@ -21,6 +23,7 @@ from ..i18n import on_language_changed, tr
|
||||
from ..state import AppContext
|
||||
from .ext_connector_dialog import ExtConnectorEditDialog
|
||||
from .icons import icon
|
||||
from .widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_card
|
||||
|
||||
|
||||
class JiraConnectDialog(QDialog):
|
||||
@@ -130,174 +133,219 @@ class ConnectorsPanel(QWidget):
|
||||
# Master switch: connect to external connectors at all (default ON).
|
||||
# Off = the agent connects to NO external connector/MCP (see
|
||||
# AppContext.build_mcp_tools), regardless of the per-connector checks below.
|
||||
self.connect_external_chk = QCheckBox(tr("connectors.connect_external"))
|
||||
self.connect_external_chk.setChecked(self.ctx.config.connect_external)
|
||||
self.connect_external_chk.setToolTip(tr("connectors.connect_external_tooltip"))
|
||||
self.connect_external_chk.toggled.connect(self._on_connect_external_toggled)
|
||||
lay.addWidget(self.connect_external_chk)
|
||||
self.connect_external_sw = ToggleSwitch(tr("connectors.connect_external"))
|
||||
self.connect_external_sw.setChecked(self.ctx.config.connect_external)
|
||||
self.connect_external_sw.setToolTip(tr("connectors.connect_external_tooltip"))
|
||||
self.connect_external_sw.toggled.connect(self._on_connect_external_toggled)
|
||||
lay.addWidget(self.connect_external_sw)
|
||||
|
||||
hint = QLabel(tr("settings.ext_hint"))
|
||||
hint.setObjectName("hint")
|
||||
hint.setWordWrap(True)
|
||||
lay.addWidget(hint)
|
||||
|
||||
self.ext_tree = QTreeWidget()
|
||||
self.ext_tree.setHeaderHidden(True)
|
||||
self.ext_tree.itemChanged.connect(self._on_ext_check)
|
||||
self.ext_tree.itemDoubleClicked.connect(lambda *_: self._ext_edit())
|
||||
lay.addWidget(self.ext_tree, 1)
|
||||
# Each category (CAD/CAE/MS365/Other) is a header + a left-aligned,
|
||||
# wrapping row of cards — one per real connector — instead of a tree
|
||||
# the admin had to expand to see what was inside.
|
||||
self._cat_scroll = QScrollArea()
|
||||
self._cat_scroll.setWidgetResizable(True)
|
||||
self._cat_scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
cat_host = QWidget()
|
||||
enable_height_for_width(cat_host) # holds several height-for-width sections — see FlowLayout
|
||||
self._cat_lay = QVBoxLayout(cat_host)
|
||||
self._cat_lay.setContentsMargins(0, 4, 0, 4)
|
||||
self._cat_lay.setSpacing(10)
|
||||
self._cat_scroll.setWidget(cat_host)
|
||||
lay.addWidget(self._cat_scroll, 1)
|
||||
|
||||
self.dbl_hint = QLabel(tr("connectors.dbl_configure"))
|
||||
self.dbl_hint.setObjectName("hint")
|
||||
lay.addWidget(self.dbl_hint)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self.add_btn = QPushButton(tr("settings.ext_add_btn"))
|
||||
self.add_btn.setIcon(icon("plus"))
|
||||
self.add_btn.setObjectName("primary")
|
||||
self.add_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.add_btn.clicked.connect(self._ext_add)
|
||||
self.edit_btn = QPushButton(tr("settings.ext_edit_btn"))
|
||||
self.edit_btn.setIcon(icon("edit"))
|
||||
self.edit_btn.clicked.connect(self._ext_edit)
|
||||
self.del_btn = QPushButton(tr("settings.ext_delete_btn"))
|
||||
self.del_btn.setIcon(icon("trash"))
|
||||
self.del_btn.clicked.connect(self._ext_delete)
|
||||
row.addWidget(self.add_btn)
|
||||
row.addWidget(self.edit_btn)
|
||||
row.addWidget(self.del_btn)
|
||||
row.addStretch(1)
|
||||
lay.addLayout(row)
|
||||
add_row = QHBoxLayout()
|
||||
add_row.addWidget(self.add_btn)
|
||||
add_row.addStretch(1)
|
||||
lay.addLayout(add_row)
|
||||
|
||||
self.ms365_local_status = QLabel()
|
||||
self.ms365_local_status.setObjectName("hint")
|
||||
self.ms365_local_status.setWordWrap(True)
|
||||
lay.addWidget(self.ms365_local_status)
|
||||
|
||||
# on_language_changed() below already invokes _retranslate() once
|
||||
# immediately (see i18n.py), which itself calls _reload_connectors() —
|
||||
# calling it again here would rebuild the category cards twice
|
||||
# back-to-back with no event-loop turn in between, so the first pass's
|
||||
# widgets are only QUEUED for deleteLater() (not yet gone) when the
|
||||
# second pass adds new ones on top: the two rows visually overlap
|
||||
# (the exact bug agents_admin_tab.py hit the same way).
|
||||
self._refresh_ms365_local_status()
|
||||
self._reload_ext_tree()
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
# ---- rendering ------------------------------------------------------------
|
||||
def _reload_ext_tree(self) -> None:
|
||||
self.ext_tree.blockSignals(True)
|
||||
self.ext_tree.clear()
|
||||
ext = self.ctx.config.ext_connectors
|
||||
def _clear_categories(self) -> None:
|
||||
while self._cat_lay.count():
|
||||
item = self._cat_lay.takeAt(0)
|
||||
w = item.widget()
|
||||
if w is not None:
|
||||
w.deleteLater()
|
||||
|
||||
def _reload_connectors(self) -> None:
|
||||
self._clear_categories()
|
||||
for cat in EXT_CATEGORIES:
|
||||
cat_item = QTreeWidgetItem([self._EXT_CATEGORY_LABELS.get(cat, cat)])
|
||||
cat_item.setIcon(0, icon(self._EXT_CATEGORY_ICONS.get(cat, "plug")))
|
||||
cat_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
||||
cat_item.setData(0, Qt.UserRole, ("category", cat))
|
||||
self.ext_tree.addTopLevelItem(cat_item)
|
||||
if cat == "ms365":
|
||||
conns = self.ctx.config.ms365.get("connectors", {})
|
||||
for key, label in self._MS365_BUILTIN_LABELS.items():
|
||||
b = QTreeWidgetItem([f"{label} — {tr('ext.mode_builtin')}"])
|
||||
b.setFlags(b.flags() | Qt.ItemIsUserCheckable)
|
||||
b.setCheckState(0, Qt.Checked if conns.get(key) else Qt.Unchecked)
|
||||
b.setData(0, Qt.UserRole, ("ms365_builtin", "ms365", key))
|
||||
cat_item.addChild(b)
|
||||
for idx, entry in enumerate(ext.get(cat, [])):
|
||||
mode_label = tr("ext.mode_mcp") if entry.get("mode") == "mcp_stdio" else tr("ext.mode_rest")
|
||||
child = QTreeWidgetItem([f"{entry.get('name', '')} — {mode_label}"])
|
||||
child.setFlags(child.flags() | Qt.ItemIsUserCheckable)
|
||||
child.setCheckState(0, Qt.Checked if entry.get("enabled") else Qt.Unchecked)
|
||||
child.setData(0, Qt.UserRole, ("connector", cat, idx))
|
||||
cat_item.addChild(child)
|
||||
if cat == "other":
|
||||
# Jira is a built-in "Other" connector (like OneDrive under MS365):
|
||||
# checkbox = enabled; double-click opens its minimal setup dialog.
|
||||
jira = self.ctx.config.data.get("jira", {})
|
||||
configured = bool(jira.get("base_url") and jira.get("email")
|
||||
and jira.get("api_token"))
|
||||
jstate = tr("connectors.jira_connected") if configured else tr("connectors.jira_not_set")
|
||||
jrow = QTreeWidgetItem([f"Jira — {tr('ext.mode_builtin')} · {jstate}"])
|
||||
jrow.setIcon(0, icon("link"))
|
||||
jrow.setFlags(jrow.flags() | Qt.ItemIsUserCheckable)
|
||||
on = configured and jira.get("enabled", True)
|
||||
jrow.setCheckState(0, Qt.Checked if on else Qt.Unchecked)
|
||||
jrow.setToolTip(0, tr("connectors.jira_setup_hint"))
|
||||
jrow.setData(0, Qt.UserRole, ("jira_builtin", "other"))
|
||||
cat_item.addChild(jrow)
|
||||
cat_item.setExpanded(True)
|
||||
self.ext_tree.blockSignals(False)
|
||||
self._cat_lay.addWidget(self._category_section(cat))
|
||||
|
||||
def _on_ext_check(self, item: QTreeWidgetItem, _col: int) -> None:
|
||||
data = item.data(0, Qt.UserRole)
|
||||
if data and data[0] == "ms365_builtin":
|
||||
self.ctx.config.ms365.setdefault("connectors", {})[data[2]] = (
|
||||
item.checkState(0) == Qt.Checked)
|
||||
self.ctx.save()
|
||||
return
|
||||
if data and data[0] == "jira_builtin":
|
||||
self.ctx.config.data.setdefault("jira", {})["enabled"] = (
|
||||
item.checkState(0) == Qt.Checked)
|
||||
self.ctx.save()
|
||||
return
|
||||
if not data or data[0] != "connector":
|
||||
return
|
||||
_, cat, idx = data
|
||||
entries = self.ctx.config.ext_connectors.get(cat, [])
|
||||
if 0 <= idx < len(entries):
|
||||
entries[idx]["enabled"] = item.checkState(0) == Qt.Checked
|
||||
self.ctx.save()
|
||||
def _category_section(self, cat: str) -> QWidget:
|
||||
section = QWidget()
|
||||
enable_height_for_width(section) # this section wraps a FlowLayout row — see FlowLayout
|
||||
sl = QVBoxLayout(section)
|
||||
sl.setContentsMargins(0, 0, 0, 0)
|
||||
sl.setSpacing(6)
|
||||
|
||||
def _current_ext_category(self) -> str:
|
||||
item = self.ext_tree.currentItem()
|
||||
data = item.data(0, Qt.UserRole) if item else None
|
||||
return data[1] if data else EXT_CATEGORIES[0]
|
||||
hdr = QHBoxLayout()
|
||||
hdr.setSpacing(6)
|
||||
icon_lbl = QLabel()
|
||||
icon_lbl.setPixmap(icon(self._EXT_CATEGORY_ICONS.get(cat, "plug"), size=18).pixmap(18, 18))
|
||||
hdr.addWidget(icon_lbl)
|
||||
name, _, subtitle = self._EXT_CATEGORY_LABELS.get(cat, cat).partition(" (")
|
||||
name_lbl = QLabel(name)
|
||||
name_lbl.setStyleSheet("font-weight:700;")
|
||||
hdr.addWidget(name_lbl)
|
||||
if subtitle:
|
||||
sub_lbl = QLabel("(" + subtitle)
|
||||
sub_lbl.setObjectName("hint")
|
||||
hdr.addWidget(sub_lbl)
|
||||
hdr.addStretch(1)
|
||||
sl.addLayout(hdr)
|
||||
|
||||
def _current_ext_connector(self):
|
||||
item = self.ext_tree.currentItem()
|
||||
data = item.data(0, Qt.UserRole) if item else None
|
||||
if not data or data[0] != "connector":
|
||||
return None
|
||||
_, cat, idx = data
|
||||
entries = self.ctx.config.ext_connectors.get(cat, [])
|
||||
return (cat, entries[idx]) if 0 <= idx < len(entries) else None
|
||||
flow_host = QWidget()
|
||||
flow = FlowLayout(flow_host, margin=0, h_spacing=10, v_spacing=10)
|
||||
|
||||
if cat == "ms365":
|
||||
conns = self.ctx.config.ms365.get("connectors", {})
|
||||
for key, label in self._MS365_BUILTIN_LABELS.items():
|
||||
flow.addWidget(self._connector_card(
|
||||
label, tr("connectors.builtin_auto"), bool(conns.get(key)),
|
||||
lambda on, k=key: self._toggle_ms365_builtin(k, on)))
|
||||
|
||||
for entry in self.ctx.config.ext_connectors.get(cat, []):
|
||||
mode_label = tr("ext.mode_mcp") if entry.get("mode") == "mcp_stdio" else tr("ext.mode_rest")
|
||||
flow.addWidget(self._connector_card(
|
||||
entry.get("name", ""), mode_label, bool(entry.get("enabled")),
|
||||
lambda on, e=entry: self._toggle_ext_entry(e, on),
|
||||
# QPushButton.clicked emits a `checked` bool — a lambda whose
|
||||
# ONLY parameter is a defaulted capture (`e=entry`) looks like
|
||||
# it accepts that bool, so Qt hands it the click state instead
|
||||
# of using the default, silently replacing the captured dict
|
||||
# with False. An explicit leading `checked=False` soaks up the
|
||||
# signal's argument so `e` keeps the entry it was defined with.
|
||||
edit_cb=lambda checked=False, e=entry: self._edit_ext_entry(cat, e),
|
||||
delete_cb=lambda checked=False, e=entry: self._delete_ext_entry(cat, e)))
|
||||
|
||||
if cat == "other":
|
||||
# Jira is a built-in "Other" connector (like OneDrive under MS365):
|
||||
# switch = enabled; ✎ Sửa opens its own minimal setup dialog — no
|
||||
# 🗑 Xóa, same as OneDrive/SharePoint have neither (nothing to delete).
|
||||
jira = self.ctx.config.data.get("jira", {})
|
||||
configured = bool(jira.get("base_url") and jira.get("email") and jira.get("api_token"))
|
||||
jstate = tr("connectors.jira_connected") if configured else tr("connectors.jira_not_set")
|
||||
flow.addWidget(self._connector_card(
|
||||
"Jira", f"{tr('ext.mode_builtin').capitalize()} · {jstate}",
|
||||
configured and jira.get("enabled", True), self._toggle_jira,
|
||||
edit_cb=self._open_jira_dialog))
|
||||
|
||||
sl.addWidget(flow_host)
|
||||
return section
|
||||
|
||||
def _connector_card(self, title: str, subtitle: str, checked: bool, on_toggle,
|
||||
edit_cb=None, delete_cb=None) -> QWidget:
|
||||
card = QFrame()
|
||||
card.setFrameShape(QFrame.NoFrame)
|
||||
style_card(card)
|
||||
lay = QVBoxLayout(card)
|
||||
lay.setContentsMargins(10, 8, 10, 8)
|
||||
lay.setSpacing(4)
|
||||
|
||||
hdr = QHBoxLayout()
|
||||
name_lbl = QLabel(title)
|
||||
name_lbl.setStyleSheet("font-weight:700; border: none;")
|
||||
hdr.addWidget(name_lbl)
|
||||
hdr.addStretch(1)
|
||||
sw = ToggleSwitch()
|
||||
sw.setChecked(checked)
|
||||
sw.toggled.connect(on_toggle)
|
||||
hdr.addWidget(sw)
|
||||
lay.addLayout(hdr)
|
||||
|
||||
sub_lbl = QLabel(subtitle)
|
||||
sub_lbl.setObjectName("hint")
|
||||
sub_lbl.setStyleSheet("border: none;")
|
||||
lay.addWidget(sub_lbl)
|
||||
|
||||
if edit_cb is not None or delete_cb is not None:
|
||||
actions = QHBoxLayout()
|
||||
actions.setContentsMargins(0, 2, 0, 0)
|
||||
actions.setSpacing(2)
|
||||
if edit_cb is not None:
|
||||
b = QPushButton(tr("settings.ext_edit_btn"))
|
||||
b.setIcon(icon("edit"))
|
||||
b.setFlat(True)
|
||||
b.setCursor(Qt.PointingHandCursor)
|
||||
b.clicked.connect(edit_cb)
|
||||
actions.addWidget(b)
|
||||
if delete_cb is not None:
|
||||
b = QPushButton(tr("settings.ext_delete_btn"))
|
||||
b.setIcon(icon("trash"))
|
||||
b.setFlat(True)
|
||||
b.setCursor(Qt.PointingHandCursor)
|
||||
b.clicked.connect(delete_cb)
|
||||
actions.addWidget(b)
|
||||
actions.addStretch(1)
|
||||
lay.addLayout(actions)
|
||||
|
||||
return card
|
||||
|
||||
def _toggle_ms365_builtin(self, key: str, checked: bool) -> None:
|
||||
self.ctx.config.ms365.setdefault("connectors", {})[key] = checked
|
||||
self.ctx.save()
|
||||
|
||||
def _toggle_jira(self, checked: bool) -> None:
|
||||
self.ctx.config.data.setdefault("jira", {})["enabled"] = checked
|
||||
self.ctx.save()
|
||||
|
||||
def _toggle_ext_entry(self, entry: dict, checked: bool) -> None:
|
||||
entry["enabled"] = checked
|
||||
self.ctx.save()
|
||||
|
||||
# ---- CRUD -----------------------------------------------------------------
|
||||
def _ext_add(self) -> None:
|
||||
dlg = ExtConnectorEditDialog(self, category=self._current_ext_category())
|
||||
dlg = ExtConnectorEditDialog(self, category=EXT_CATEGORIES[0])
|
||||
if dlg.exec():
|
||||
entry = dlg.result_connector()
|
||||
self.ctx.config.ext_connectors.setdefault(entry["category"], []).append(entry)
|
||||
self.ctx.save()
|
||||
self._reload_ext_tree()
|
||||
self._reload_connectors()
|
||||
|
||||
def _ext_edit(self) -> None:
|
||||
"""Configure the selected row. Built-in Jira → its minimal dialog;
|
||||
a normal connector → the MCP/REST editor."""
|
||||
item = self.ext_tree.currentItem()
|
||||
data = item.data(0, Qt.UserRole) if item else None
|
||||
if data and data[0] == "jira_builtin":
|
||||
self._open_jira_dialog()
|
||||
return
|
||||
current = self._current_ext_connector()
|
||||
if current is None:
|
||||
return
|
||||
cat, entry = current
|
||||
def _edit_ext_entry(self, cat: str, entry: dict) -> None:
|
||||
dlg = ExtConnectorEditDialog(self, category=cat, connector=entry)
|
||||
if dlg.exec():
|
||||
entry.update(dlg.result_connector())
|
||||
self.ctx.save()
|
||||
self._reload_ext_tree()
|
||||
self._reload_connectors()
|
||||
|
||||
def _open_jira_dialog(self) -> None:
|
||||
JiraConnectDialog(self.ctx, self).exec()
|
||||
self._reload_ext_tree()
|
||||
self._reload_connectors()
|
||||
|
||||
def _ext_delete(self) -> None:
|
||||
current = self._current_ext_connector()
|
||||
if current is None:
|
||||
return
|
||||
cat, entry = current
|
||||
def _delete_ext_entry(self, cat: str, entry: dict) -> None:
|
||||
if QMessageBox.question(
|
||||
self, tr("settings.ext_delete_btn"),
|
||||
tr("settings.ext_delete_confirm", name=entry.get("name", ""))) != QMessageBox.Yes:
|
||||
return
|
||||
self.ctx.config.ext_connectors[cat].remove(entry)
|
||||
self.ctx.save()
|
||||
self._reload_ext_tree()
|
||||
self._reload_connectors()
|
||||
|
||||
def _refresh_ms365_local_status(self) -> None:
|
||||
from .. import paths
|
||||
@@ -314,16 +362,13 @@ class ConnectorsPanel(QWidget):
|
||||
def _apply_connect_external_enabled(self, on: bool) -> None:
|
||||
"""Grey out the per-connector setup when the master switch is off — the
|
||||
agent won't connect to any of them anyway."""
|
||||
for w in (self.ext_tree, self.add_btn, self.edit_btn, self.del_btn):
|
||||
for w in (self._cat_scroll, self.add_btn):
|
||||
w.setEnabled(on)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self.connect_external_chk.setText(tr("connectors.connect_external"))
|
||||
self.connect_external_chk.setToolTip(tr("connectors.connect_external_tooltip"))
|
||||
self.connect_external_sw.setText(tr("connectors.connect_external"))
|
||||
self.connect_external_sw.setToolTip(tr("connectors.connect_external_tooltip"))
|
||||
self.add_btn.setText(tr("settings.ext_add_btn"))
|
||||
self.edit_btn.setText(tr("settings.ext_edit_btn"))
|
||||
self.del_btn.setText(tr("settings.ext_delete_btn"))
|
||||
self.dbl_hint.setText(tr("connectors.dbl_configure"))
|
||||
self._refresh_ms365_local_status()
|
||||
self._reload_ext_tree()
|
||||
self._reload_connectors()
|
||||
self._apply_connect_external_enabled(self.ctx.config.connect_external)
|
||||
|
||||
+14
-1
@@ -81,8 +81,21 @@ class CoworkTab(ChatPanel):
|
||||
# succeeds (see _cleanup_turn) — never intermediate files or a folder name.
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
def refresh_title(self) -> None:
|
||||
"""Head the screen with the thread you are in, as the drawing does.
|
||||
|
||||
It said "Cowork" on every conversation — the screen's own name, which
|
||||
the rail already shows. The thread's title is the thing that changes and
|
||||
the thing that tells you where you are; a thread with no title yet (a
|
||||
new chat, before its first turn) falls back to the screen name.
|
||||
"""
|
||||
lbl = getattr(self, "_title_lbl", None)
|
||||
if lbl is None:
|
||||
return # ChatPanel.__init__ sets self.title before we exist
|
||||
lbl.setText(getattr(self, "title", "") or tr("cowork.title"))
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self._title_lbl.setText(tr("cowork.title"))
|
||||
self.refresh_title()
|
||||
self.skills_btn.setText(tr("cowork.skills_btn"))
|
||||
self.skills_btn.setToolTip(tr("cowork.skills_tooltip"))
|
||||
self._new_btn.setText(tr("cowork.new_chat"))
|
||||
|
||||
+36
-19
@@ -24,6 +24,7 @@ from ..core import usage_tracker as ut
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..state import AppContext
|
||||
from ..theme import current_palette
|
||||
from .icons import icon
|
||||
from .spline_chart import SplineChart
|
||||
from .widgets import BudgetCard as _BudgetCard
|
||||
@@ -91,39 +92,52 @@ class DashboardTab(QWidget):
|
||||
self.refresh_btn.setIcon(icon("refresh"))
|
||||
self.refresh_btn.setFixedWidth(34)
|
||||
self.refresh_btn.clicked.connect(self.refresh)
|
||||
# Two rows, grouped by what the controls do, instead of nine widgets
|
||||
# strung across one line where the title, a date pager, two chart
|
||||
# selectors, a currency picker and Refresh all read as one undifferentiated
|
||||
# strip. Row 1 is "where am I"; row 2 is "what am I looking at".
|
||||
head.addWidget(self._title, 1)
|
||||
head.addWidget(self.chart_prev_btn)
|
||||
head.addWidget(self._chart_period_lbl)
|
||||
head.addWidget(self.chart_next_btn)
|
||||
head.addWidget(self.gran_combo)
|
||||
head.addWidget(self.metric_combo)
|
||||
head.addWidget(self.currency_lbl)
|
||||
head.addWidget(self.currency_combo)
|
||||
head.addWidget(self.refresh_btn)
|
||||
root.addLayout(head)
|
||||
|
||||
controls = QHBoxLayout()
|
||||
controls.setSpacing(6)
|
||||
controls.addWidget(self.chart_prev_btn) # period pager
|
||||
controls.addWidget(self._chart_period_lbl)
|
||||
controls.addWidget(self.chart_next_btn)
|
||||
controls.addSpacing(12)
|
||||
controls.addWidget(self.gran_combo) # what the chart plots
|
||||
controls.addWidget(self.metric_combo)
|
||||
controls.addStretch(1)
|
||||
controls.addWidget(self.currency_lbl) # how money is displayed
|
||||
controls.addWidget(self.currency_combo)
|
||||
root.addLayout(controls)
|
||||
|
||||
# ---- stat cards ---------------------------------------------------
|
||||
# Single row, 5 equal-width cards (same layout as Monitoring Overview)
|
||||
# Cost is the headline this screen exists for, so it gets a card twice
|
||||
# the height of the rest instead of being the fifth of five identical
|
||||
# tiles — with six equal cards nothing said which number mattered.
|
||||
cards_grid = QGridLayout()
|
||||
cards_grid.setSpacing(8)
|
||||
self.card_total = _StatCard()
|
||||
self.card_in = _StatCard()
|
||||
self.card_out = _StatCard()
|
||||
self.card_cache = _StatCard()
|
||||
self.card_cost = _StatCard()
|
||||
for i, card in enumerate((self.card_total, self.card_in, self.card_out,
|
||||
self.card_cache, self.card_cost)):
|
||||
cards_grid.addWidget(card, 0, i)
|
||||
self.card_cost = _StatCard().as_hero()
|
||||
# Hero on the left, spanning both rows; the four supporting figures fill
|
||||
# a 2×2 block beside it.
|
||||
cards_grid.addWidget(self.card_cost, 0, 0, 2, 1)
|
||||
for i, card in enumerate((self.card_total, self.card_in,
|
||||
self.card_out, self.card_cache)):
|
||||
cards_grid.addWidget(card, i // 2, 1 + i % 2)
|
||||
# Budget: remaining/budget, direct entry, auto-warns red past 85% used.
|
||||
self.budget_card = _BudgetCard()
|
||||
self.budget_card.apply_btn.setIcon(icon("check"))
|
||||
self.budget_card.apply_btn.clicked.connect(self._apply_budget)
|
||||
cards_grid.addWidget(self.budget_card, 0, 5)
|
||||
# Equal stretch on every column — otherwise the grid sizes each column
|
||||
# to its widest cell's natural content (Budget's longer "$X / $Y" value
|
||||
# + entry row made its column ~25% wider than the plain stat cards).
|
||||
for col in range(6):
|
||||
cards_grid.setColumnStretch(col, 1)
|
||||
cards_grid.addWidget(self.budget_card, 0, 3, 2, 1)
|
||||
# The hero and Budget columns get more room than the small tiles.
|
||||
for col, stretch in ((0, 3), (1, 2), (2, 2), (3, 3)):
|
||||
cards_grid.setColumnStretch(col, stretch)
|
||||
root.addLayout(cards_grid)
|
||||
|
||||
# ---- token/cost within the selected period (spline): WEEK → 7 days
|
||||
@@ -298,8 +312,11 @@ class DashboardTab(QWidget):
|
||||
n_points = max(1, len(parts))
|
||||
refs = []
|
||||
if prev[mi] > 0:
|
||||
# Muted on purpose: the comparison line is a reference, not the
|
||||
# series — it must not compete with the accent-coloured spline.
|
||||
refs.append((prev[mi] / n_points,
|
||||
f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}", "#B08968"))
|
||||
f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}",
|
||||
current_palette().text_muted))
|
||||
self.chart.set_reference_lines(refs)
|
||||
self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}"))
|
||||
self._chart_period_lbl.setText(ut.period_range_label(gran, self._chart_offset))
|
||||
|
||||
@@ -16,9 +16,14 @@ from PySide6.QtWidgets import (
|
||||
QLineEdit, QMessageBox, QPushButton, QStackedWidget, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..core.ext_connectors import PRESETS
|
||||
from ..core.ext_connectors import CATEGORIES, PRESETS
|
||||
from ..i18n import tr
|
||||
|
||||
# ms365 has no user-created entries here (see ConnectorsPanel) — it auto-connects
|
||||
# via its own built-in OneDrive/SharePoint toggles, so it's left off this picker.
|
||||
_PICKABLE_CATEGORIES = tuple(c for c in CATEGORIES if c != "ms365")
|
||||
_CATEGORY_LABEL = {"cad": "CAD", "cae": "CAE", "other": "Other"}
|
||||
|
||||
|
||||
class ExtConnectorEditDialog(QDialog):
|
||||
def __init__(self, parent=None, category: str = "cad", connector: Optional[dict] = None):
|
||||
@@ -32,10 +37,20 @@ class ExtConnectorEditDialog(QDialog):
|
||||
lay = QVBoxLayout(self)
|
||||
|
||||
form = QFormLayout()
|
||||
# Category picker — the single "+ Thêm connector…" button (Monitoring ▸
|
||||
# Công cụ ▸ Connector) has no tree selection to infer this from anymore,
|
||||
# so the dialog itself asks. Fixed once created, same as the preset.
|
||||
self.category_combo = QComboBox()
|
||||
for cat in _PICKABLE_CATEGORIES:
|
||||
self.category_combo.addItem(_CATEGORY_LABEL.get(cat, cat), cat)
|
||||
idx = self.category_combo.findData(self.category)
|
||||
self.category_combo.setCurrentIndex(max(0, idx))
|
||||
self.category_combo.setEnabled(not editing)
|
||||
self.category_combo.currentIndexChanged.connect(self._on_category_changed)
|
||||
form.addRow(tr("ext.category_label"), self.category_combo)
|
||||
|
||||
self.preset_combo = QComboBox()
|
||||
self.preset_combo.addItem(tr("ext.preset_custom"), "")
|
||||
for p in PRESETS.get(self.category, []):
|
||||
self.preset_combo.addItem(p["name"], p["id"])
|
||||
self._reload_presets()
|
||||
if editing:
|
||||
self.preset_combo.setEnabled(False) # identity fixed once created
|
||||
form.addRow(tr("ext.preset_label"), self.preset_combo)
|
||||
@@ -105,6 +120,18 @@ class ExtConnectorEditDialog(QDialog):
|
||||
buttons.rejected.connect(self.reject)
|
||||
lay.addWidget(buttons)
|
||||
|
||||
def _reload_presets(self) -> None:
|
||||
self.preset_combo.blockSignals(True)
|
||||
self.preset_combo.clear()
|
||||
self.preset_combo.addItem(tr("ext.preset_custom"), "")
|
||||
for p in PRESETS.get(self.category, []):
|
||||
self.preset_combo.addItem(p["name"], p["id"])
|
||||
self.preset_combo.blockSignals(False)
|
||||
|
||||
def _on_category_changed(self) -> None:
|
||||
self.category = self.category_combo.currentData() or self.category
|
||||
self._reload_presets()
|
||||
|
||||
def _apply_preset(self) -> None:
|
||||
preset_id = self.preset_combo.currentData()
|
||||
if preset_id and not self.name_edit.text().strip():
|
||||
|
||||
+41
-29
@@ -33,6 +33,7 @@ from PySide6.QtWidgets import (
|
||||
from ..core.worker import AgentWorker
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..state import AppContext
|
||||
from ..theme import current_palette
|
||||
from .chat_view import ChatView
|
||||
from .icons import icon
|
||||
from .libreoffice_view import DOC_SUFFIXES
|
||||
@@ -89,23 +90,26 @@ class PygmentsHighlighter(QSyntaxHighlighter):
|
||||
from pygments.token import (
|
||||
Comment, Error, Keyword, Name, Number, Operator, Punctuation, String,
|
||||
)
|
||||
p = current_palette()
|
||||
# Ordered specific → general: first matching token type wins.
|
||||
# Colours are resolved when the editor is built, so reopening a file
|
||||
# after a theme switch re-highlights it in the new theme.
|
||||
return [
|
||||
(Comment, _fmt("#6A9955", italic=True)),
|
||||
(Keyword.Type, _fmt("#4EC9B0")),
|
||||
(Keyword, _fmt("#569CD6")),
|
||||
(Name.Function, _fmt("#DCDCAA")),
|
||||
(Name.Class, _fmt("#4EC9B0")),
|
||||
(Name.Decorator, _fmt("#DCDCAA")),
|
||||
(Name.Builtin, _fmt("#4EC9B0")),
|
||||
(Name.Tag, _fmt("#569CD6")),
|
||||
(Name.Attribute, _fmt("#9CDCFE")),
|
||||
(String.Doc, _fmt("#6A9955", italic=True)),
|
||||
(String, _fmt("#CE9178")),
|
||||
(Number, _fmt("#B5CEA8")),
|
||||
(Operator, _fmt("#D4D4D4")),
|
||||
(Punctuation, _fmt("#D4D4D4")),
|
||||
(Error, _fmt("#F44747")),
|
||||
(Comment, _fmt(p.code_comment, italic=True)),
|
||||
(Keyword.Type, _fmt(p.code_type)),
|
||||
(Keyword, _fmt(p.code_keyword)),
|
||||
(Name.Function, _fmt(p.code_func)),
|
||||
(Name.Class, _fmt(p.code_type)),
|
||||
(Name.Decorator, _fmt(p.code_func)),
|
||||
(Name.Builtin, _fmt(p.code_type)),
|
||||
(Name.Tag, _fmt(p.code_keyword)),
|
||||
(Name.Attribute, _fmt(p.code_attr)),
|
||||
(String.Doc, _fmt(p.code_comment, italic=True)),
|
||||
(String, _fmt(p.code_string)),
|
||||
(Number, _fmt(p.code_number)),
|
||||
(Operator, _fmt(p.code_fg)),
|
||||
(Punctuation, _fmt(p.code_fg)),
|
||||
(Error, _fmt(p.code_error)),
|
||||
]
|
||||
|
||||
def set_filename(self, filename: str, text: str = "") -> None:
|
||||
@@ -179,9 +183,7 @@ class CodeEditor(QPlainTextEdit):
|
||||
font.setStyleHint(QFont.Monospace)
|
||||
font.setPointSize(10)
|
||||
self.setFont(font)
|
||||
self.setStyleSheet(
|
||||
"#codeEditor { background: #1e1e1e; color: #d4d4d4; border: none; "
|
||||
"selection-background-color: #264f78; }")
|
||||
# Surface comes from the central style sheet (#codeEditor) — see theme.py.
|
||||
self._gutter = _LineNumbers(self)
|
||||
self.blockCountChanged.connect(lambda _=0: self._update_gutter_width())
|
||||
self.updateRequest.connect(self._on_update_request)
|
||||
@@ -210,13 +212,14 @@ class CodeEditor(QPlainTextEdit):
|
||||
self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.line_number_width(), cr.height()))
|
||||
|
||||
def paint_line_numbers(self, event) -> None:
|
||||
p = current_palette()
|
||||
painter = QPainter(self._gutter)
|
||||
painter.fillRect(event.rect(), QColor("#1a1a1a"))
|
||||
painter.fillRect(event.rect(), QColor(p.code_gutter_bg))
|
||||
block = self.firstVisibleBlock()
|
||||
num = block.blockNumber()
|
||||
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
|
||||
bottom = top + self.blockBoundingRect(block).height()
|
||||
painter.setPen(QColor("#858585"))
|
||||
painter.setPen(QColor(p.code_gutter_fg))
|
||||
while block.isValid() and top <= event.rect().bottom():
|
||||
if block.isVisible() and bottom >= event.rect().top():
|
||||
painter.drawText(0, int(top), self._gutter.width() - 6,
|
||||
@@ -258,14 +261,20 @@ class FolderTab(QWidget):
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
|
||||
# The path IS the title of this screen, so it is written as one rather
|
||||
# than shown in a read-only text box that looks editable and costs a
|
||||
# whole row of its own. Full path on hover; the button still opens the
|
||||
# folder picker.
|
||||
bar = QHBoxLayout()
|
||||
self.path_edit = QLineEdit(self._root)
|
||||
self.path_edit.setReadOnly(True)
|
||||
self.path_lbl = QLabel(self._root)
|
||||
self.path_lbl.setObjectName("folderTitle")
|
||||
self.path_lbl.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
self.path_lbl.setToolTip(self._root)
|
||||
self._open_btn = QPushButton()
|
||||
self._open_btn.setIcon(icon("folder"))
|
||||
self._open_btn.setObjectName("primary")
|
||||
self._open_btn.clicked.connect(self._pick_root)
|
||||
bar.addWidget(self.path_edit, 1)
|
||||
bar.addWidget(self.path_lbl, 1)
|
||||
bar.addWidget(self._open_btn)
|
||||
root.addLayout(bar)
|
||||
|
||||
@@ -380,7 +389,8 @@ class FolderTab(QWidget):
|
||||
if not p or not os.path.isdir(p):
|
||||
return
|
||||
self._root = p
|
||||
self.path_edit.setText(p)
|
||||
self.path_lbl.setText(p)
|
||||
self.path_lbl.setToolTip(p)
|
||||
self.model.setRootPath(p)
|
||||
self.tree.setRootIndex(self.model.index(p))
|
||||
if getattr(self, "terminal", None) is not None:
|
||||
@@ -1096,7 +1106,7 @@ class FolderTab(QWidget):
|
||||
if n and hasattr(self, "_ai_status"):
|
||||
self._ai_status.setText("⏳ " + tr("folder.ai_status_running")
|
||||
+ " · " + tr("folder.ai_queue_count", n=n))
|
||||
self._ai_status.setStyleSheet("color:#0096C7;")
|
||||
self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
|
||||
|
||||
def _ai_maybe_dequeue(self) -> None:
|
||||
"""When the pipeline is fully idle, start the next queued instruction."""
|
||||
@@ -1308,7 +1318,7 @@ class FolderTab(QWidget):
|
||||
name = target if create else getattr(self, "_ai_running_file", "")
|
||||
self.status_message.emit(tr("folder.ai_proposed_status", name=name))
|
||||
self._ai_status.setText("● " + hint)
|
||||
self._ai_status.setStyleSheet("color:#c77d00;")
|
||||
self._ai_status.setStyleSheet(f"color:{current_palette().warning};")
|
||||
|
||||
def _ai_apply(self) -> None:
|
||||
"""Confirmed by the user. If the edit GENERATES images, ask the image
|
||||
@@ -1464,7 +1474,7 @@ class FolderTab(QWidget):
|
||||
self.ai_send_btn.setEnabled(not busy)
|
||||
if busy:
|
||||
self._ai_status.setText("⏳ " + tr("folder.ai_status_running"))
|
||||
self._ai_status.setStyleSheet("color:#0096C7;")
|
||||
self._ai_status.setStyleSheet(f"color:{current_palette().accent};")
|
||||
self.ai_btn.setText(tr("folder.ai_edit") + " ⏳") # visible even when collapsed
|
||||
else:
|
||||
self._ai_status.setText("")
|
||||
@@ -1478,7 +1488,7 @@ class FolderTab(QWidget):
|
||||
self._ai_maybe_dequeue()
|
||||
return
|
||||
self._ai_status.setText("✓ " + tr("folder.ai_status_done"))
|
||||
self._ai_status.setStyleSheet("color:#1f9d63;")
|
||||
self._ai_status.setStyleSheet(f"color:{current_palette().success};")
|
||||
if not self.ai_btn.isChecked() or self._ai_panel.isHidden():
|
||||
self.ai_btn.setText(tr("folder.ai_edit") + " ✓")
|
||||
|
||||
@@ -1489,7 +1499,9 @@ class FolderTab(QWidget):
|
||||
else tr("folder.preview"))
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self.path_edit.setPlaceholderText(tr("folder.path_placeholder"))
|
||||
# The label always shows a real path, so the placeholder became a
|
||||
# tooltip hint on the button that changes it.
|
||||
self._open_btn.setToolTip(tr("folder.path_placeholder"))
|
||||
self._open_btn.setText(tr("folder.open_folder"))
|
||||
self.save_btn.setText(tr("folder.save"))
|
||||
self.ext_btn.setText(tr("folder.open_external"))
|
||||
|
||||
+194
-108
@@ -17,9 +17,9 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PySide6.QtCore import QSize, Qt, Signal
|
||||
from PySide6.QtGui import QIcon, QPixmap
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTextBrowser,
|
||||
QFrame, QHBoxLayout, QLabel, QLineEdit, QMenu, QPushButton, QTextBrowser,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
@@ -31,13 +31,31 @@ from .icons import icon
|
||||
_ASSETS = Path(__file__).resolve().parent.parent / "assets"
|
||||
|
||||
_MARGIN = 18 # gap from the window's bottom-right corner
|
||||
_LAUNCHER = 64 # collapsed app-icon badge size (a clean rounded card, like image 2)
|
||||
_LAUNCHER_ICON = 52 # the icon inside it, inset so the light badge frames it
|
||||
_COLLAPSE_W, _COLLAPSE_H = 18, 44 # the "hide to the edge" chevron beside it
|
||||
_GAP = 2
|
||||
_TAB_W, _TAB_H = 16, 48 # the thin "show" tab when hidden at the edge
|
||||
# Closed, the assistant is a single 26px dot. It used to be an 84×64 block (a
|
||||
# 64px badge plus an 18px "hide" chevron beside it) sitting permanently over the
|
||||
# bottom-right of every screen — on Cowork, right on top of the Send button —
|
||||
# for something opened a few times a day. The name now appears on hover only,
|
||||
# and "hide to the edge" moved into the panel's ⋯ menu.
|
||||
# The audit page draws this at 26px ("26×26 · không chữ, không chevron").
|
||||
# Doubled at the user's request: 26 read as too small to notice on a 1920
|
||||
# screen. Still half the area of the 84×64 button it replaced.
|
||||
_DOT = 52 # closed launcher (a round chip)
|
||||
_DOT_ICON = 28 # the sparkle inside it
|
||||
_PILL_PAD = 12 # extra width for the label when hovered
|
||||
_TAB_W, _TAB_H = 28, 48 # the "show" tab when hidden at the edge (was 16 wide)
|
||||
_PANEL_W, _PANEL_H = 340, 460 # expanded chat panel size
|
||||
|
||||
# Straight from docs/ui-audit.html (.wf .fab / .fabpill / .spark): the
|
||||
# assistant is teal, not the app accent, and the same in both themes —
|
||||
# it is one recognisable object floating over every screen.
|
||||
_TEAL_BG, _TEAL_LINE = "#E6F6F4", "#7FD0C4"
|
||||
_TEAL_TEXT = "#0F6E62"
|
||||
# The page's CSS says .spark{color:#0F9B8A}, but the glyph is the ✨ EMOJI and a
|
||||
# colour emoji ignores CSS colour — so what the page actually renders is the
|
||||
# gold star. Sampled from the page's own render of section 27 (1055 pixels of
|
||||
# the star, averaged): #FDBE59.
|
||||
_SPARK_GOLD = "#FDBE59"
|
||||
|
||||
# The three states the floating assistant cycles through.
|
||||
_HIDDEN, _LAUNCHER_ST, _PANEL = "hidden", "launcher", "panel"
|
||||
|
||||
@@ -53,27 +71,46 @@ def _app_icon() -> QIcon:
|
||||
return QIcon(str(p)) if p.exists() else icon("robot")
|
||||
|
||||
|
||||
def _app_pixmap(size: int) -> QPixmap:
|
||||
"""icon.png scaled to ``size`` (smooth), for the launcher badge label."""
|
||||
p = _ASSETS / "icon.png"
|
||||
if p.exists():
|
||||
return QPixmap(str(p)).scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
return icon("robot").pixmap(size, size)
|
||||
# _app_pixmap()/_IconTap were the 52px icon and its click-through QLabel for the
|
||||
# old 64px badge. The launcher is a real button now, so both are gone.
|
||||
|
||||
|
||||
class _IconTap(QLabel):
|
||||
"""A QLabel that behaves like a button (click → signal) — used for the
|
||||
launcher badge so it carries NO QPushButton chrome/box, just the icon on a
|
||||
clean rounded card."""
|
||||
class _HoverPill(QPushButton):
|
||||
"""The closed launcher: a dot at rest, a labelled pill under the pointer.
|
||||
|
||||
clicked = Signal()
|
||||
Keyboard focus counts as hover, so the name is reachable without a mouse.
|
||||
Resizing is delegated to the owner because this widget is inside an overlay
|
||||
that has to re-pin itself to the window corner whenever its size changes.
|
||||
"""
|
||||
|
||||
def mousePressEvent(self, e): # noqa: N802 - Qt override
|
||||
if e.button() == Qt.LeftButton:
|
||||
self.clicked.emit()
|
||||
e.accept()
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
self._owner = owner
|
||||
self.open = False
|
||||
|
||||
def _set_open(self, value: bool) -> None:
|
||||
if value == self.open:
|
||||
return
|
||||
super().mousePressEvent(e)
|
||||
self.open = value
|
||||
self.setText(f" {tr('help_agent.badge')}" if value else "")
|
||||
self._owner._layout_launcher()
|
||||
|
||||
def enterEvent(self, e): # noqa: N802 - Qt override
|
||||
self._set_open(True)
|
||||
super().enterEvent(e)
|
||||
|
||||
def leaveEvent(self, e): # noqa: N802 - Qt override
|
||||
if not self.hasFocus():
|
||||
self._set_open(False)
|
||||
super().leaveEvent(e)
|
||||
|
||||
def focusInEvent(self, e): # noqa: N802 - Qt override
|
||||
self._set_open(True)
|
||||
super().focusInEvent(e)
|
||||
|
||||
def focusOutEvent(self, e): # noqa: N802 - Qt override
|
||||
self._set_open(False)
|
||||
super().focusOutEvent(e)
|
||||
|
||||
|
||||
class HelpAgentWidget(QWidget):
|
||||
@@ -91,9 +128,12 @@ class HelpAgentWidget(QWidget):
|
||||
self._worker: Optional[AgentWorker] = None
|
||||
# Conversation history (excludes the system prompt, prepended per call).
|
||||
# Seeded with the greeting so the panel always opens on a friendly hello.
|
||||
self._history: List[Dict[str, str]] = [
|
||||
{"role": "assistant", "content": self._greeting()}
|
||||
]
|
||||
# Kept by identity so retranslate() can rewrite it without having to
|
||||
# guess which language the visible text is in — and without touching a
|
||||
# real reply that happens to look like a greeting.
|
||||
self._greet_msg: Dict[str, str] = {
|
||||
"role": "assistant", "content": self._greeting()}
|
||||
self._history: List[Dict[str, str]] = [self._greet_msg]
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
self._pal = self._compute_palette()
|
||||
self._build_edge_tab()
|
||||
@@ -103,67 +143,63 @@ class HelpAgentWidget(QWidget):
|
||||
self._apply_state()
|
||||
|
||||
# ---- theming ----------------------------------------------------------
|
||||
def _compute_palette(self) -> Dict[str, str]:
|
||||
"""Chat-body colours that FOLLOW the app's light/dark theme. The header
|
||||
is intentionally NOT themed here (it stays a fixed light bar — see
|
||||
_apply_style), only the conversation area adapts."""
|
||||
from ..theme import resolve_theme
|
||||
dark = resolve_theme(getattr(self.ctx.config, "theme", "system")) == "dark"
|
||||
if dark:
|
||||
return {
|
||||
"panel_bg": "#16202b", "text": "#e3ebf5", "log_bg": "#0f1720",
|
||||
"input_bg": "#1b2733", "border": "#33404d",
|
||||
"user_bg": "#123a52", "user_label": "#58c0ee",
|
||||
"bot_bg": "#232f3b", "bot_label": "#6fe3a4",
|
||||
}
|
||||
return {
|
||||
"panel_bg": "#ffffff", "text": "#14212b", "log_bg": "#f7f9fb",
|
||||
"input_bg": "#ffffff", "border": "#d5d9de",
|
||||
"user_bg": "#dceff8", "user_label": "#0077B6",
|
||||
"bot_bg": "#eef1f4", "bot_label": "#2f7d55",
|
||||
}
|
||||
def _compute_palette(self):
|
||||
"""The app's design tokens for the theme in effect. The whole dock —
|
||||
header included — follows the app theme; a header locked to a light
|
||||
strip stranded a bright bar in the middle of the dark UI."""
|
||||
from ..theme import palette
|
||||
return palette(getattr(self.ctx.config, "theme", "system"))
|
||||
|
||||
def apply_theme(self) -> None:
|
||||
"""Re-style + re-render when the app theme switches (called from
|
||||
MainWindow._apply_theme). Header stays fixed; chat body re-colours."""
|
||||
MainWindow._apply_theme). The whole dock re-colours, icons included —
|
||||
icons are painted bitmaps, so they must be rebuilt, not restyled."""
|
||||
self._pal = self._compute_palette()
|
||||
self._apply_style()
|
||||
muted = self._pal.text_muted
|
||||
self.edge_tab.setIcon(icon("chevron-left", color=_TEAL_TEXT))
|
||||
self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_SPARK_GOLD))
|
||||
self.min_btn.setIcon(icon("minus", color=muted))
|
||||
self._render()
|
||||
|
||||
def _apply_style(self) -> None:
|
||||
# The HEADER bar is a FIXED light strip in both themes (per request); only
|
||||
# the chat body below follows the app's light/dark palette (self._pal).
|
||||
from ..theme import ACCENT, ACCENT2, GRADIENT
|
||||
"""The dock owns its own style sheet (it floats above the window, so the
|
||||
app-wide sheet does not reach it cleanly) but draws every value from the
|
||||
shared tokens — see theme.py."""
|
||||
p = self._pal
|
||||
r, rl = p.radius, p.radius_lg
|
||||
self.setStyleSheet(f"""
|
||||
/* Clean rounded app-icon badge (like image 2): a fixed light card
|
||||
framing the icon — no QPushButton box. */
|
||||
#helpLauncher {{ background: #e8f2fb; border: 1px solid #d3e3f2;
|
||||
border-radius: 16px; }}
|
||||
#helpLauncher:hover {{ background: #dcedfb; }}
|
||||
#helpCollapseBtn, #helpEdgeTab {{ background: rgba(0,0,0,0.06); border: none;
|
||||
border-radius: 6px; }}
|
||||
#helpCollapseBtn:hover, #helpEdgeTab:hover {{ background: rgba(0,0,0,0.14); }}
|
||||
#helpPanel {{ background: {p['panel_bg']}; border: 1px solid {p['border']};
|
||||
border-radius: 14px; color: {p['text']}; }}
|
||||
/* Faint-blue header bar — LOCKED light, dark title, in both themes.
|
||||
The header AND its child labels set fixed backgrounds so the dark
|
||||
theme never bleeds into the App-Assistant title strip. */
|
||||
#helpHeader {{ background: #e8f2fb; border-bottom: 1px solid #d9e6f2;
|
||||
border-top-left-radius: 14px; border-top-right-radius: 14px; }}
|
||||
#helpHeader QLabel {{ background: transparent; color: #14212b; }}
|
||||
#helpTitle {{ color: #14212b; font-weight: 700; font-size: 13px; background: transparent; }}
|
||||
#helpMinBtn {{ background: transparent; border: none; }}
|
||||
#helpMinBtn:hover {{ background: rgba(0,0,0,0.10); border-radius: 6px; }}
|
||||
#helpLog {{ background: {p['log_bg']}; border: none; color: {p['text']}; padding: 4px 6px; }}
|
||||
#helpInputRow {{ background: {p['panel_bg']}; border-bottom-left-radius: 14px;
|
||||
border-bottom-right-radius: 14px; }}
|
||||
#helpInput {{ border: 1px solid {p['border']}; border-radius: 8px; padding: 5px 8px;
|
||||
background: {p['input_bg']}; color: {p['text']}; }}
|
||||
#helpInput:focus {{ border: 1px solid {ACCENT}; }}
|
||||
#helpSendBtn {{ background: {GRADIENT}; border: none; border-radius: 8px; }}
|
||||
#helpSendBtn:hover {{ background: {ACCENT2}; }}
|
||||
#helpSendBtn:disabled {{ background: #b7c0c9; }}
|
||||
/* Closed launcher: a {_DOT}px dot. `pill` flips to true on hover, when
|
||||
the label comes out and the shape stretches to a rounded bar. */
|
||||
#helpLauncher {{ background: {_TEAL_BG}; border: 1px solid {_TEAL_LINE};
|
||||
border-radius: {_DOT // 2}px; color: {_TEAL_TEXT}; font-weight: 700;
|
||||
font-size: 12px; padding: 0; text-align: center; }}
|
||||
#helpLauncher[pill="true"] {{ text-align: left; padding-left: 6px; }}
|
||||
#helpLauncher:hover {{ background: #D5EFEA; border-color: {_TEAL_LINE}; }}
|
||||
#helpLauncher:focus {{ border: 1px solid {_TEAL_TEXT}; }}
|
||||
#helpEdgeTab {{ background: {_TEAL_BG}; border: 1px solid {_TEAL_LINE};
|
||||
border-right: none; border-top-left-radius: {r}px;
|
||||
border-bottom-left-radius: {r}px; }}
|
||||
#helpEdgeTab:hover {{ background: #D5EFEA; }}
|
||||
#helpPanel {{ background: {p.surface}; border: 1px solid {p.border};
|
||||
border-radius: {rl}px; color: {p.text}; }}
|
||||
#helpHeader {{ background: {p.surface}; border-bottom: 1px solid {p.border};
|
||||
border-top-left-radius: {rl}px; border-top-right-radius: {rl}px; }}
|
||||
#helpHeader QLabel {{ background: transparent; color: {p.text}; }}
|
||||
#helpTitle {{ color: {p.text}; font-weight: 600; font-size: 13px;
|
||||
background: transparent; }}
|
||||
#helpMinBtn {{ background: transparent; border: none; border-radius: {r}px; }}
|
||||
#helpMinBtn:hover {{ background: {p.hover}; }}
|
||||
#helpLog {{ background: {p.sunken}; border: none; color: {p.text};
|
||||
padding: 4px 6px; }}
|
||||
#helpInputRow {{ background: {p.surface};
|
||||
border-bottom-left-radius: {rl}px; border-bottom-right-radius: {rl}px; }}
|
||||
#helpInput {{ border: 1px solid {p.border}; border-radius: {r}px; padding: 5px 8px;
|
||||
background: {p.surface_raised}; color: {p.text}; }}
|
||||
#helpInput:focus {{ border: 1px solid {p.focus_ring}; }}
|
||||
#helpSendBtn {{ background: {p.accent_solid}; border: none; border-radius: {r}px; }}
|
||||
#helpSendBtn:hover {{ background: {p.accent_solid_hover}; }}
|
||||
#helpSendBtn:disabled {{ background: {p.border_strong}; }}
|
||||
""")
|
||||
|
||||
# ---- greeting / labels ------------------------------------------------
|
||||
@@ -177,28 +213,21 @@ class HelpAgentWidget(QWidget):
|
||||
# assistant back (chevron points left = "slide out").
|
||||
self.edge_tab = QPushButton(self)
|
||||
self.edge_tab.setObjectName("helpEdgeTab")
|
||||
self.edge_tab.setIcon(icon("chevron-left", color="#5a6570"))
|
||||
self.edge_tab.setIcon(icon("chevron-left", color=_TEAL_TEXT))
|
||||
self.edge_tab.setCursor(Qt.PointingHandCursor)
|
||||
self.edge_tab.setToolTip(tr("help_agent.show_tooltip"))
|
||||
self.edge_tab.clicked.connect(self._show_launcher)
|
||||
|
||||
def _build_launcher(self) -> None:
|
||||
# A left-side chevron collapses the assistant to the edge…
|
||||
self.collapse_btn = QPushButton(self)
|
||||
self.collapse_btn.setObjectName("helpCollapseBtn")
|
||||
self.collapse_btn.setIcon(icon("chevron-right", color="#5a6570"))
|
||||
self.collapse_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.collapse_btn.setToolTip(tr("help_agent.hide_tooltip"))
|
||||
self.collapse_btn.clicked.connect(self._hide_to_edge)
|
||||
# …and the app icon itself opens the chat — a clean rounded badge (like
|
||||
# image 2), NOT a QPushButton (which added a pale box around the icon).
|
||||
self.launcher = _IconTap(self)
|
||||
# One control, one job: this opens the chat. The chevron that used to sit
|
||||
# beside it (a second 18px hit target for a second meaning of "closed")
|
||||
# is gone — hiding to the edge is now a line in the panel's ⋯ menu.
|
||||
self.launcher = _HoverPill(self)
|
||||
self.launcher.setObjectName("helpLauncher")
|
||||
self.launcher.setFixedSize(_LAUNCHER, _LAUNCHER)
|
||||
self.launcher.setAlignment(Qt.AlignCenter)
|
||||
self.launcher.setPixmap(_app_pixmap(_LAUNCHER_ICON))
|
||||
self.launcher.setIcon(icon("sparkle", size=_DOT_ICON, color=_SPARK_GOLD))
|
||||
self.launcher.setCursor(Qt.PointingHandCursor)
|
||||
self.launcher.setToolTip(tr("help_agent.open_tooltip"))
|
||||
self.launcher.setToolTip(
|
||||
f'{tr("help_agent.open_tooltip")} · {tr("help_agent.dot_hint")}')
|
||||
self.launcher.clicked.connect(self._expand)
|
||||
|
||||
def _build_panel(self) -> None:
|
||||
@@ -215,19 +244,31 @@ class HelpAgentWidget(QWidget):
|
||||
hb = QHBoxLayout(header)
|
||||
hb.setContentsMargins(12, 8, 8, 8)
|
||||
self.title_icon = QLabel(header)
|
||||
self.title_icon.setPixmap(_app_icon().pixmap(20, 20))
|
||||
self.title_icon.setPixmap(
|
||||
icon("sparkle", size=16, color=_SPARK_GOLD).pixmap(16, 16))
|
||||
hb.addWidget(self.title_icon)
|
||||
self.title = QLabel(tr("help_agent.title"), header)
|
||||
self.title.setObjectName("helpTitle")
|
||||
hb.addWidget(self.title, 1)
|
||||
self.min_btn = QPushButton(header)
|
||||
self.min_btn.setObjectName("helpMinBtn")
|
||||
self.min_btn.setIcon(icon("minus", color="#5a6570"))
|
||||
self.min_btn.setIcon(icon("minus", color=self._pal.text_muted))
|
||||
self.min_btn.setFixedSize(24, 24)
|
||||
self.min_btn.setCursor(Qt.PointingHandCursor)
|
||||
self.min_btn.setToolTip(tr("help_agent.collapse_tooltip"))
|
||||
self.min_btn.clicked.connect(self._collapse)
|
||||
hb.addWidget(self.min_btn)
|
||||
# No ⋯ menu. The audit page put "Ẩn trợ lý" in one, but its two entries
|
||||
# were "thu nhỏ" — which the − button beside it already does — and
|
||||
# "ẩn vào cạnh phải". A drop-list to reach one action that duplicates
|
||||
# its neighbour is chrome; removed at the user's request.
|
||||
#
|
||||
# Hiding stays reachable by right-click, on the header while the panel
|
||||
# is open and on the dot while it is shut, so no route is lost.
|
||||
for target in (header, self.launcher):
|
||||
target.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
target.customContextMenuRequested.connect(
|
||||
lambda pos, w=target: self._hide_menu(w, pos))
|
||||
v.addWidget(header)
|
||||
|
||||
# Conversation log
|
||||
@@ -268,6 +309,19 @@ class HelpAgentWidget(QWidget):
|
||||
self._state = _LAUNCHER_ST
|
||||
self._apply_state()
|
||||
|
||||
def _hide_menu(self, widget, pos) -> None:
|
||||
"""Right-click, on the dot or the open panel's header: hide to the edge.
|
||||
|
||||
The only action worth offering here — collapsing is what the − button
|
||||
and the dot itself already are.
|
||||
"""
|
||||
from PySide6.QtWidgets import QMenu
|
||||
|
||||
menu = QMenu(widget)
|
||||
act = menu.addAction(tr("help_agent.hide_tooltip"))
|
||||
act.triggered.connect(self._hide_to_edge)
|
||||
menu.exec(widget.mapToGlobal(pos))
|
||||
|
||||
def _hide_to_edge(self) -> None:
|
||||
self._state = _HIDDEN
|
||||
self._apply_state()
|
||||
@@ -276,36 +330,57 @@ class HelpAgentWidget(QWidget):
|
||||
self._state = _LAUNCHER_ST
|
||||
self._apply_state()
|
||||
|
||||
def _layout_launcher(self) -> None:
|
||||
"""Size the overlay to the dot, or to the pill while it is hovered."""
|
||||
w = _DOT
|
||||
if self.launcher.open:
|
||||
w = max(_DOT, self.launcher.fontMetrics()
|
||||
.horizontalAdvance(self.launcher.text()) + _DOT + _PILL_PAD)
|
||||
self.resize(w, _DOT)
|
||||
self.launcher.setGeometry(0, 0, w, _DOT)
|
||||
# Round while it is a dot, pill-shaped once the label is out.
|
||||
self.launcher.setProperty("pill", bool(self.launcher.open))
|
||||
self.launcher.style().unpolish(self.launcher)
|
||||
self.launcher.style().polish(self.launcher)
|
||||
self.reposition()
|
||||
self.raise_()
|
||||
|
||||
def _apply_state(self) -> None:
|
||||
st = self._state
|
||||
self.edge_tab.setVisible(st == _HIDDEN)
|
||||
self.collapse_btn.setVisible(st == _LAUNCHER_ST)
|
||||
self.launcher.setVisible(st == _LAUNCHER_ST)
|
||||
self.panel.setVisible(st == _PANEL)
|
||||
if st == _PANEL:
|
||||
self.resize(_PANEL_W, _PANEL_H)
|
||||
self.panel.setGeometry(0, 0, _PANEL_W, _PANEL_H)
|
||||
elif st == _LAUNCHER_ST:
|
||||
w = _LAUNCHER + _GAP + _COLLAPSE_W
|
||||
self.resize(w, _LAUNCHER)
|
||||
# Icon on the left, the collapse chevron on the RIGHT (toward the
|
||||
# screen edge it tucks into).
|
||||
self.launcher.setGeometry(0, 0, _LAUNCHER, _LAUNCHER)
|
||||
self.collapse_btn.setGeometry(_LAUNCHER + _GAP, (_LAUNCHER - _COLLAPSE_H) // 2,
|
||||
_COLLAPSE_W, _COLLAPSE_H)
|
||||
self._layout_launcher()
|
||||
return # _layout_launcher repositions and raises
|
||||
else: # hidden
|
||||
self.resize(_TAB_W, _TAB_H)
|
||||
self.edge_tab.setGeometry(0, 0, _TAB_W, _TAB_H)
|
||||
self.reposition()
|
||||
self.raise_()
|
||||
|
||||
# A screen whose bottom edge is an input row (Cowork's composer) must not
|
||||
# have the dock sitting on top of it — set by MainWindow when the page
|
||||
# changes, in window coordinates.
|
||||
_bottom_guard = 0
|
||||
|
||||
def set_bottom_guard(self, height: int) -> None:
|
||||
"""Reserve `height` px at the foot of the window for the page's own
|
||||
controls; the dock floats above it instead of over the Send button."""
|
||||
if height != self._bottom_guard:
|
||||
self._bottom_guard = max(0, height)
|
||||
self.reposition()
|
||||
|
||||
def reposition(self) -> None:
|
||||
"""Pin to the parent's bottom-right corner (called on parent resize)."""
|
||||
p = self.parentWidget()
|
||||
if p is None:
|
||||
return
|
||||
x = max(0, p.width() - self.width() - _MARGIN)
|
||||
y = max(0, p.height() - self.height() - _MARGIN)
|
||||
y = max(0, p.height() - self.height() - _MARGIN - self._bottom_guard)
|
||||
self.move(x, y)
|
||||
|
||||
# ---- rendering --------------------------------------------------------
|
||||
@@ -318,17 +393,19 @@ class HelpAgentWidget(QWidget):
|
||||
p = self._pal
|
||||
text = (content or "").replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
text = text.replace("\n", "<br>")
|
||||
# bgcolor= is a solid-only HTML attribute, hence accent_wash (pre-blended)
|
||||
# rather than the translucent accent_soft used in style sheets.
|
||||
if who == "user":
|
||||
align, bg, label_color = "right", p["user_bg"], p["user_label"]
|
||||
align, bg, label_color = "right", p.accent_wash, p.accent
|
||||
label = tr("chat.you")
|
||||
else:
|
||||
align, bg, label_color = "left", p["bot_bg"], p["bot_label"]
|
||||
align, bg, label_color = "left", p.surface_raised, p.success
|
||||
label = tr("help_agent.title")
|
||||
return (
|
||||
f'<table width="100%" cellspacing="0" cellpadding="0"><tr>'
|
||||
f'<td align="{align}">'
|
||||
f'<table width="80%" cellspacing="0" cellpadding="7" bgcolor="{bg}"><tr>'
|
||||
f'<td style="color:{p["text"]};">'
|
||||
f'<td style="color:{p.text};">'
|
||||
f'<b style="color:{label_color};">{label}</b><br>{text}'
|
||||
f'</td></tr></table></td></tr></table>'
|
||||
'<div style="line-height:6px;"> </div>' # gap between turns
|
||||
@@ -388,9 +465,18 @@ class HelpAgentWidget(QWidget):
|
||||
self.send_btn.setEnabled(not busy)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
# The transcript is rendered HTML, so switching language left the
|
||||
# greeting — and every "AI Assistant" speaker label — in the language
|
||||
# the panel was built in.
|
||||
if self._history and self._history[0] is self._greet_msg:
|
||||
self._greet_msg["content"] = self._greeting()
|
||||
self._render()
|
||||
self.title.setText(tr("help_agent.title"))
|
||||
self.input.setPlaceholderText(tr("help_agent.placeholder"))
|
||||
self.launcher.setToolTip(tr("help_agent.open_tooltip"))
|
||||
self.launcher.setToolTip(
|
||||
f'{tr("help_agent.open_tooltip")} · {tr("help_agent.dot_hint")}')
|
||||
if self.launcher.open:
|
||||
self.launcher.setText(f" {tr('help_agent.badge')}")
|
||||
self._layout_launcher()
|
||||
self.min_btn.setToolTip(tr("help_agent.collapse_tooltip"))
|
||||
self.collapse_btn.setToolTip(tr("help_agent.hide_tooltip"))
|
||||
self.edge_tab.setToolTip(tr("help_agent.show_tooltip"))
|
||||
|
||||
+24
-12
@@ -21,7 +21,12 @@ from PySide6.QtGui import QBrush, QColor, QIcon, QPainter, QPen, QPixmap
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QWidget
|
||||
|
||||
_COLOR = "#8b8d98" # neutral grey, visible on both light and dark buttons
|
||||
def _default_color() -> str:
|
||||
"""The default icon tint: the theme's muted text colour, so glyphs sit at
|
||||
the same weight as the labels beside them. Resolved per call — icons are
|
||||
painted bitmaps, so a theme switch must repaint them, not restyle them."""
|
||||
from ..theme import current_palette
|
||||
return current_palette().text_muted
|
||||
|
||||
|
||||
def _hidpi_pixmap(size: int) -> QPixmap:
|
||||
@@ -230,10 +235,11 @@ def icon_picker_combo(current: str = "") -> QComboBox:
|
||||
return combo
|
||||
|
||||
|
||||
def icon(name: str, size: int = 16, color: str = _COLOR) -> QIcon:
|
||||
def icon(name: str, size: int = 16, color: str | None = None) -> QIcon:
|
||||
"""A flat thin-line icon for ``name`` (see ``_PATHS`` for the full list),
|
||||
tinted ``color`` — rendered from local SVG data, no image files/network.
|
||||
Stroke width 1.7 matches the Nova Platform web app's shared icon set."""
|
||||
color = color or _default_color()
|
||||
# A user-added custom icon (full SVG under ~/.cowork_local/icons) is rendered
|
||||
# as-is (keeps its own colours). Then built-in glyphs; then a neutral fallback.
|
||||
if name not in _PATHS:
|
||||
@@ -264,9 +270,10 @@ def icon(name: str, size: int = 16, color: str = _COLOR) -> QIcon:
|
||||
return QIcon(pm)
|
||||
|
||||
|
||||
def _panel_icon(fill_left: bool, size: int = 16, color: str = _COLOR) -> QIcon:
|
||||
def _panel_icon(fill_left: bool, size: int = 16, color: str | None = None) -> QIcon:
|
||||
"""A rounded panel split by a divider, with one narrow side filled solid
|
||||
(the 'sidebar' toggle look)."""
|
||||
color = color or _default_color()
|
||||
pm = _hidpi_pixmap(size)
|
||||
p = QPainter(pm)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
@@ -302,19 +309,24 @@ def collapse_right_icon() -> QIcon:
|
||||
return _panel_icon(fill_left=False)
|
||||
|
||||
|
||||
def pixmap(name: str, size: int = 16, color: str = _COLOR) -> QPixmap:
|
||||
def pixmap(name: str, size: int = 16, color: str | None = None) -> QPixmap:
|
||||
"""The line-icon ``name`` as a QPixmap (for QLabel.setPixmap — QLabel has no
|
||||
setIcon). Same glyph/renderer as ``icon()``."""
|
||||
return icon(name, size, color).pixmap(size, size)
|
||||
|
||||
|
||||
# Status-LED colors — a filled dot, the one place a solid glyph (not a line
|
||||
# Status-LED colours — a filled dot, the one place a solid glyph (not a line
|
||||
# icon) is the right metaphor for an on/off/running indicator.
|
||||
DOT_GREEN = "#22c55e"
|
||||
DOT_RED = "#ef4444"
|
||||
DOT_AMBER = "#f59e0b"
|
||||
DOT_BLUE = "#3b82f6"
|
||||
DOT_GREY = "#9ca3af"
|
||||
#
|
||||
# Deliberately the SAME in light and dark. An LED means one thing regardless of
|
||||
# theme, and these mid-saturation hues clear 3:1 against both #0B0B0C and
|
||||
# #FFFFFF, so a status dot never has to be re-learned. Everything else in the
|
||||
# UI goes through theme.palette(); this is the documented exception.
|
||||
DOT_GREEN = "#2EA043"
|
||||
DOT_RED = "#E5484D"
|
||||
DOT_AMBER = "#B7791F"
|
||||
DOT_BLUE = "#4C7BE8"
|
||||
DOT_GREY = "#8B8B94"
|
||||
|
||||
|
||||
def dot_icon(color: str = DOT_GREY, size: int = 12) -> QIcon:
|
||||
@@ -338,7 +350,7 @@ class IconLabel(QWidget):
|
||||
status labels (lock/unlock, …) keep working."""
|
||||
|
||||
def __init__(self, name: str, text: str = "", *, size: int = 16,
|
||||
color: str = _COLOR, gap: int = 6, parent=None):
|
||||
color: str | None = None, gap: int = 6, parent=None):
|
||||
super().__init__(parent)
|
||||
self._size = size
|
||||
lay = QHBoxLayout(self)
|
||||
@@ -357,7 +369,7 @@ class IconLabel(QWidget):
|
||||
def setText(self, text: str) -> None: # noqa: N802 — QLabel-compatible alias
|
||||
self._text.setText(text)
|
||||
|
||||
def set_icon(self, name: str, color: str = _COLOR) -> None:
|
||||
def set_icon(self, name: str, color: str | None = None) -> None:
|
||||
self._icon.setPixmap(pixmap(name, self._size, color))
|
||||
|
||||
def text_label(self) -> QLabel:
|
||||
|
||||
+38
-24
@@ -22,6 +22,7 @@ from .icons import icon
|
||||
|
||||
def _grid() -> QListWidget:
|
||||
g = QListWidget()
|
||||
g.setObjectName("iconGrid") # accent border on hover/selection, see theme.py
|
||||
g.setViewMode(QListWidget.IconMode)
|
||||
g.setResizeMode(QListWidget.Adjust)
|
||||
g.setMovement(QListWidget.Static)
|
||||
@@ -36,34 +37,44 @@ class IconsAdminTab(QWidget):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
root = QVBoxLayout(self)
|
||||
self._hint = QLabel(); self._hint.setObjectName("hint"); self._hint.setWordWrap(True)
|
||||
root.addWidget(self._hint)
|
||||
|
||||
# search over built-in names
|
||||
self.search = QLineEdit()
|
||||
self.search.textChanged.connect(self._reload_builtin)
|
||||
root.addWidget(self.search)
|
||||
|
||||
self._builtin_lbl = QLabel()
|
||||
root.addWidget(self._builtin_lbl)
|
||||
self.builtin_grid = _grid()
|
||||
root.addWidget(self.builtin_grid, 2)
|
||||
|
||||
self._custom_lbl = QLabel()
|
||||
root.addWidget(self._custom_lbl)
|
||||
self.custom_grid = _grid()
|
||||
root.addWidget(self.custom_grid, 1)
|
||||
|
||||
btns = QHBoxLayout()
|
||||
# Header row: the three actions sit beside the title, where the drawing
|
||||
# puts them, instead of in a strip below the two grids where they read
|
||||
# as belonging to the custom grid alone.
|
||||
head = QHBoxLayout()
|
||||
self._title = QLabel()
|
||||
self._title.setObjectName("monTitle")
|
||||
head.addWidget(self._title)
|
||||
head.addStretch(1)
|
||||
self.add_btn = QPushButton(); self.add_btn.setIcon(icon("plus"))
|
||||
self.add_btn.clicked.connect(self._add_icon)
|
||||
self.paste_btn = QPushButton()
|
||||
self.paste_btn.clicked.connect(self._add_from_svg_text)
|
||||
self.del_btn = QPushButton(); self.del_btn.setIcon(icon("trash"))
|
||||
self.del_btn.clicked.connect(self._delete_icon)
|
||||
btns.addWidget(self.add_btn); btns.addWidget(self.paste_btn)
|
||||
btns.addWidget(self.del_btn); btns.addStretch(1)
|
||||
root.addLayout(btns)
|
||||
for b in (self.add_btn, self.paste_btn, self.del_btn):
|
||||
head.addWidget(b)
|
||||
root.addLayout(head)
|
||||
|
||||
self._hint = QLabel(); self._hint.setObjectName("hint"); self._hint.setWordWrap(True)
|
||||
root.addWidget(self._hint)
|
||||
|
||||
# search over built-in names, with the magnifier the drawing asks for
|
||||
self.search = QLineEdit()
|
||||
self.search.addAction(icon("search"), QLineEdit.LeadingPosition)
|
||||
self.search.textChanged.connect(self._reload_builtin)
|
||||
root.addWidget(self.search)
|
||||
|
||||
self._builtin_lbl = QLabel()
|
||||
self._builtin_lbl.setObjectName("navSectionHdr") # quiet caps heading
|
||||
root.addWidget(self._builtin_lbl)
|
||||
self.builtin_grid = _grid()
|
||||
root.addWidget(self.builtin_grid, 2)
|
||||
|
||||
self._custom_lbl = QLabel()
|
||||
self._custom_lbl.setObjectName("navSectionHdr")
|
||||
root.addWidget(self._custom_lbl)
|
||||
self.custom_grid = _grid()
|
||||
root.addWidget(self.custom_grid, 1)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
self._retranslate()
|
||||
@@ -131,10 +142,13 @@ class IconsAdminTab(QWidget):
|
||||
self._reload_custom()
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
self._title.setText(tr("monitoring.tab_icons"))
|
||||
self._hint.setText(tr("icons_admin.hint"))
|
||||
self.search.setPlaceholderText(tr("icons_admin.search"))
|
||||
self._builtin_lbl.setText(tr("icons_admin.builtin"))
|
||||
self._custom_lbl.setText(tr("icons_admin.custom"))
|
||||
# ICON TÍCH HỢP / ICON TÙY CHỈNH — caps, like every other section
|
||||
# heading the audit page draws.
|
||||
self._builtin_lbl.setText(tr("icons_admin.builtin").upper())
|
||||
self._custom_lbl.setText(tr("icons_admin.custom").upper())
|
||||
self.add_btn.setText(tr("icons_admin.add"))
|
||||
self.paste_btn.setText(tr("icons_admin.paste"))
|
||||
self.del_btn.setText(tr("icons_admin.delete"))
|
||||
|
||||
+759
-104
File diff suppressed because it is too large
Load Diff
+794
-716
File diff suppressed because it is too large
Load Diff
+728
-649
File diff suppressed because it is too large
Load Diff
@@ -164,6 +164,9 @@ class HistorySidebar(QWidget):
|
||||
self._refresh_btn.setToolTip(tr("sidebar.refresh_tooltip"))
|
||||
self.refresh() # re-render group headers / running suffix in the new language
|
||||
|
||||
def is_collapsed(self) -> bool:
|
||||
return self._strip.isVisible()
|
||||
|
||||
def set_collapsed(self, collapsed: bool) -> None:
|
||||
"""Collapse to a thin line (kept visible) or restore the full panel."""
|
||||
self._content.setVisible(not collapsed)
|
||||
|
||||
+5
-9
@@ -13,7 +13,7 @@ from PySide6.QtCore import QPointF, Qt
|
||||
from PySide6.QtGui import QBrush, QColor, QLinearGradient, QPainter, QPainterPath, QPen
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
from ..theme import ACCENT
|
||||
from ..theme import current_palette
|
||||
|
||||
|
||||
def _endpoint_label_rect(point_x: float, point_y: float, text_width: float,
|
||||
@@ -75,17 +75,13 @@ class SplineChart(QWidget):
|
||||
self._refs = list(refs or [])
|
||||
self.update()
|
||||
|
||||
def _dark(self) -> bool:
|
||||
from .chat_view import _app_theme
|
||||
return _app_theme() == "dark"
|
||||
|
||||
def paintEvent(self, _e): # noqa: N802
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
dark = self._dark()
|
||||
grid = QColor("#1A2D4A" if dark else "#C7DEEE")
|
||||
text = QColor("#8FB2D4" if dark else "#5C7A94")
|
||||
accent = QColor(ACCENT)
|
||||
tok = current_palette()
|
||||
grid = QColor(tok.chart_grid)
|
||||
text = QColor(tok.chart_label)
|
||||
accent = QColor(tok.accent)
|
||||
w, h = self.width(), self.height()
|
||||
|
||||
pts = self._points
|
||||
|
||||
+1034
-993
File diff suppressed because it is too large
Load Diff
+68
-14
@@ -19,7 +19,7 @@ from PySide6.QtWidgets import (
|
||||
QCheckBox, QComboBox, QDateTimeEdit, QDialog, QDialogButtonBox, QFileDialog,
|
||||
QFormLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit,
|
||||
QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton,
|
||||
QScrollArea, QSpinBox, QVBoxLayout, QWidget,
|
||||
QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..config import PROVIDER_LABELS
|
||||
@@ -70,28 +70,36 @@ class TaskEditorDialog(QDialog):
|
||||
self.edited_task: Optional[dict] = None
|
||||
self.setWindowTitle(tr("schedtask.editor_title_edit" if task else "schedtask.editor_title_new"))
|
||||
self.resize(560, 680)
|
||||
# Flat inputs: every field (text, list, combo, spin, date) is transparent
|
||||
# so it shows the page background (the app theme otherwise fills inputs
|
||||
# with a lighter box) — just a light outline, consistent with the rest of
|
||||
# the app. The combo drop-down popup keeps a solid dark background so its
|
||||
# items stay readable.
|
||||
self.setStyleSheet(
|
||||
"QLineEdit, QPlainTextEdit, QListWidget, QComboBox, QAbstractSpinBox {"
|
||||
" background: transparent; border: 1px solid rgba(140,146,152,0.45);"
|
||||
" border-radius: 6px; }"
|
||||
"QListWidget::item { background: transparent; }"
|
||||
"QComboBox QAbstractItemView { background: #111D32; color: #E0F0FF; }")
|
||||
# Only the files/links/depends-on lists go flat (transparent, no boxed
|
||||
# panel) — they sit right next to their own +/trash buttons, which is
|
||||
# enough affordance without a filled background. Title/Description/
|
||||
# Prompt/combos etc. keep the app's normal raised-surface + border
|
||||
# look (theme.py's default for these widget types): a transparent
|
||||
# single/multi-line box with only a 1px border was tried here and
|
||||
# turned out too faint against the group's own background to read as
|
||||
# an editable field at all ("không thể nhận ra ô textbox của prompt").
|
||||
# Scoped to #flatList, not bare QListWidget — that would also blank
|
||||
# out the sectionIndex sidebar's :selected highlight (set by the app
|
||||
# theme), since a stylesheet set directly on this dialog overrides the
|
||||
# app-wide one for every descendant it matches, regardless of the
|
||||
# theme rule's own selector specificity.
|
||||
self.setStyleSheet("QListWidget#flatList, QListWidget#flatList::item { background: transparent; }")
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) # down only
|
||||
content = QWidget()
|
||||
scroll.setWidget(content)
|
||||
outer.addWidget(scroll, 1)
|
||||
root = QVBoxLayout(content)
|
||||
|
||||
# ---- basics ----------------------------------------------------
|
||||
form = QFormLayout()
|
||||
# A QGroupBox like the other four step pages (Schedule/Input/
|
||||
# Dependency/Execution), so this one isn't the odd one out once it's
|
||||
# moved into its own step page below (bare background, no title).
|
||||
self._basic_box = QGroupBox(tr("schedtask.g_basic"))
|
||||
form = QFormLayout(self._basic_box)
|
||||
self.title_edit = QLineEdit(self.task.get("title", ""))
|
||||
# Description is the source of truth. Its ✨ button GENERATES the Prompt
|
||||
# (Input) FROM the description — the title is just the task's label and
|
||||
@@ -204,7 +212,7 @@ class TaskEditorDialog(QDialog):
|
||||
form.addRow(tr("schedtask.f_skill"), self.skill_combo)
|
||||
form.addRow(tr("schedtask.f_priority"), self.priority_combo)
|
||||
form.addRow(tr("schedtask.f_status"), self.status_combo)
|
||||
root.addLayout(form)
|
||||
root.addWidget(self._basic_box)
|
||||
self._main_form = form
|
||||
self._model_box = model_box
|
||||
self._on_run_kind_changed() # apply agent/flow row visibility
|
||||
@@ -316,6 +324,7 @@ class TaskEditorDialog(QDialog):
|
||||
# multi-select file dialog and APPENDS (never wipes what's already
|
||||
# there), the trash button removes just the selected row(s).
|
||||
self.files_list = QListWidget()
|
||||
self.files_list.setObjectName("flatList")
|
||||
self.files_list.setMaximumHeight(90)
|
||||
self.files_list.setSelectionMode(QListWidget.ExtendedSelection)
|
||||
for p in inp.get("file_paths", []) or []:
|
||||
@@ -341,6 +350,7 @@ class TaskEditorDialog(QDialog):
|
||||
# Links — same "+"-list pattern; "+" prompts for one URL at a time
|
||||
# (fetched best-effort and inlined as context, same as file attachments).
|
||||
self.links_list = QListWidget()
|
||||
self.links_list.setObjectName("flatList")
|
||||
self.links_list.setMaximumHeight(90)
|
||||
self.links_list.setSelectionMode(QListWidget.ExtendedSelection)
|
||||
for u in inp.get("links", []) or []:
|
||||
@@ -390,6 +400,7 @@ class TaskEditorDialog(QDialog):
|
||||
# Fan-in: tick every task this one must WAIT for — it won't run until
|
||||
# ALL of them are Done (parallel predecessors feeding one successor).
|
||||
self.depends_list = QListWidget()
|
||||
self.depends_list.setObjectName("flatList")
|
||||
self.depends_list.setMaximumHeight(96)
|
||||
current_deps = set(dep.get("depends_on") or [])
|
||||
for t in self.all_tasks:
|
||||
@@ -426,6 +437,44 @@ class TaskEditorDialog(QDialog):
|
||||
eform.addRow("", self.approval_chk)
|
||||
root.addWidget(eg)
|
||||
|
||||
# Three steps, as tabs: Nội dung → Lịch chạy → Liên kết. The five group
|
||||
# boxes are re-parented into three pages — none is dropped, they are
|
||||
# grouped by the question being answered rather than stacked in one
|
||||
# scroll where the later ones are out of sight.
|
||||
# Left list + right panel, navigated exactly like Settings — five rows
|
||||
# matching the five real group boxes, so you always see which group you
|
||||
# are in and how many are left. (Not tabs: the audit page asks for this
|
||||
# shape specifically, for consistency with Settings.)
|
||||
from .widgets import section_panels
|
||||
|
||||
self._step_keys = ["schedtask.g_basic", "schedtask.g_schedule",
|
||||
"schedtask.g_input", "schedtask.g_dependency",
|
||||
"schedtask.g_execution"]
|
||||
pages = []
|
||||
for key, group in zip(self._step_keys, [self._basic_box, sg, ig, dg, eg]):
|
||||
page = QWidget()
|
||||
pv = QVBoxLayout(page)
|
||||
pv.setContentsMargins(4, 4, 4, 4)
|
||||
root.removeWidget(group)
|
||||
pv.addWidget(group)
|
||||
pv.addStretch(1)
|
||||
wrap = QScrollArea()
|
||||
wrap.setWidgetResizable(True)
|
||||
wrap.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
wrap.setWidget(page)
|
||||
pages.append((tr(key), wrap))
|
||||
self.section_list, self.section_stack = section_panels(pages)
|
||||
outer.removeWidget(scroll)
|
||||
scroll.setParent(None)
|
||||
body = QHBoxLayout()
|
||||
body.setSpacing(10)
|
||||
body.addWidget(self.section_list)
|
||||
body.addWidget(self.section_stack, 1)
|
||||
outer.insertLayout(0, body, 1)
|
||||
# Floor the width at what the widest page needs, at the font in use.
|
||||
widest = max(w.widget().sizeHint().width() for _lab, w in pages)
|
||||
self.setMinimumWidth(self.section_list.width() + widest + 60)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||
buttons.button(QDialogButtonBox.Save).setIcon(icon("save"))
|
||||
buttons.button(QDialogButtonBox.Cancel).setIcon(icon("close"))
|
||||
@@ -438,6 +487,11 @@ class TaskEditorDialog(QDialog):
|
||||
from .widgets import guard_wheel
|
||||
guard_wheel(self)
|
||||
|
||||
def _retranslate_steps(self) -> None:
|
||||
"""Re-label the five section rows for the current language."""
|
||||
for i, key in enumerate(self._step_keys):
|
||||
self.section_list.item(i).setText(tr(key))
|
||||
|
||||
def _apply_hints(self) -> None:
|
||||
"""Tooltip hints on every non-obvious control, so each option explains
|
||||
itself on hover."""
|
||||
|
||||
@@ -27,6 +27,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from ..i18n import on_language_changed, tr
|
||||
from ..theme import current_palette
|
||||
from .icons import icon
|
||||
|
||||
_IS_WIN = sys.platform == "win32"
|
||||
@@ -75,8 +76,10 @@ class TerminalPanel(QWidget):
|
||||
# ---- header (always visible; click to expand/collapse) --------------
|
||||
self._header = QFrame()
|
||||
self._header.setObjectName("termHeader")
|
||||
_tp = current_palette()
|
||||
self._header.setStyleSheet(
|
||||
"#termHeader { background: rgba(0,0,0,0.06); border-radius: 6px; }")
|
||||
f"#termHeader {{ background: {_tp.surface};"
|
||||
f" border-radius: {_tp.radius}px; }}")
|
||||
hb = QHBoxLayout(self._header)
|
||||
hb.setContentsMargins(8, 4, 8, 4)
|
||||
self._toggle_btn = QPushButton()
|
||||
@@ -107,8 +110,7 @@ class TerminalPanel(QWidget):
|
||||
mono.setStyleHint(QFont.Monospace)
|
||||
mono.setPointSize(10)
|
||||
self.output.setFont(mono)
|
||||
self.output.setStyleSheet(
|
||||
"#termOutput { background: #1e1e1e; color: #d4d4d4; border: none; }")
|
||||
# Surface comes from the central style sheet (#termOutput) — see theme.py.
|
||||
self.output.setMinimumHeight(160)
|
||||
bl.addWidget(self.output, 1)
|
||||
|
||||
@@ -119,9 +121,7 @@ class TerminalPanel(QWidget):
|
||||
self.input = _TermInput()
|
||||
self.input.setObjectName("termInput")
|
||||
self.input.setFont(mono)
|
||||
self.input.setStyleSheet(
|
||||
"#termInput { background: #1e1e1e; color: #d4d4d4; border: 1px solid #3c3c3c; "
|
||||
"border-radius: 6px; padding: 4px 8px; }")
|
||||
# Surface comes from the central style sheet (#termInput) — see theme.py.
|
||||
self.input.returnPressed.connect(self._run_current)
|
||||
self.input.complete_requested.connect(self._complete)
|
||||
self.input.history_prev.connect(lambda: self._history_move(-1))
|
||||
@@ -282,11 +282,12 @@ class TerminalPanel(QWidget):
|
||||
if not text:
|
||||
return
|
||||
from PySide6.QtGui import QColor, QTextCursor
|
||||
colors = {"cmd": "#4ec9b0", "err": "#f48771", "ok": "#6a9955", "out": "#d4d4d4"}
|
||||
p = current_palette()
|
||||
colors = {"cmd": p.code_type, "err": p.code_error, "ok": p.code_comment, "out": p.code_fg}
|
||||
cursor = self.output.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
fmt = cursor.charFormat()
|
||||
fmt.setForeground(QColor(colors.get(role, "#d4d4d4")))
|
||||
fmt.setForeground(QColor(colors.get(role, p.code_fg)))
|
||||
cursor.setCharFormat(fmt)
|
||||
cursor.insertText(text)
|
||||
self.output.setTextCursor(cursor)
|
||||
|
||||
+114
-69
@@ -2,8 +2,9 @@
|
||||
|
||||
Two sub-tabs:
|
||||
* "Tool" — built-in agent tools (read/write/edit files, run commands,
|
||||
install packages, fetch URLs); toggling one OFF removes it
|
||||
from the agent's toolset (persisted in ``config.tools_disabled``).
|
||||
install packages, fetch URLs) as a left-aligned card grid;
|
||||
toggling one OFF removes it from the agent's toolset
|
||||
(persisted in ``config.tools_disabled``).
|
||||
* "Connector" — the full Connectors (MCP / REST API) setup, moved here from
|
||||
Settings: add/edit/delete CAD/CAE/MS365/Other connectors and
|
||||
enable/disable each (``ConnectorsPanel``).
|
||||
@@ -11,9 +12,10 @@ Two sub-tabs:
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox, QHBoxLayout, QHeaderView, QLabel, QPushButton,
|
||||
QTableWidget, QTableWidgetItem, QTabWidget, QVBoxLayout, QWidget,
|
||||
QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTabWidget,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..core.tools import TOOL_SPECS
|
||||
@@ -22,18 +24,46 @@ from ..i18n import on_language_changed, tr
|
||||
from ..state import AppContext
|
||||
from .connectors_panel import ConnectorsPanel
|
||||
from .icons import icon
|
||||
from .widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_card
|
||||
|
||||
# Identity colour + icon per built-in tool — same "fixed colour regardless of
|
||||
# theme" convention as monitoring_tab.py's agent avatars / agents_admin_tab.py's
|
||||
# kind avatars, grouped by what the tool actually touches (file i/o, shell,
|
||||
# packages, network, Jira).
|
||||
_TOOL_COLOUR = {
|
||||
"read_file": "#0078D4", "list_dir": "#0078D4", "write_file": "#0078D4",
|
||||
"edit_file": "#0078D4", "run_command": "#107C10", "install_package": "#8764B8",
|
||||
"fetch_url": "#FFB900", "jira_search": "#8764B8", "jira_get_issue": "#8764B8",
|
||||
}
|
||||
_TOOL_ICON_NAME = {
|
||||
"read_file": "document", "list_dir": "folder", "write_file": "new",
|
||||
"edit_file": "edit", "run_command": "terminal", "install_package": "download",
|
||||
"fetch_url": "globe", "jira_search": "search", "jira_get_issue": "link",
|
||||
}
|
||||
|
||||
|
||||
def _center_checkbox(checked: bool, on_toggle) -> QWidget:
|
||||
box = QWidget()
|
||||
lay = QHBoxLayout(box)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setAlignment(Qt.AlignCenter)
|
||||
chk = QCheckBox()
|
||||
chk.setChecked(checked)
|
||||
chk.toggled.connect(on_toggle)
|
||||
lay.addWidget(chk)
|
||||
return box
|
||||
def _tool_icon_pixmap(name: str, size: int = 28) -> QPixmap:
|
||||
pm = QPixmap(size, size)
|
||||
pm.fill(Qt.transparent)
|
||||
p = QPainter(pm)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor(_TOOL_COLOUR.get(name, "#0078D4")))
|
||||
r = size * 0.28
|
||||
p.drawRoundedRect(0, 0, size, size, r, r)
|
||||
inner = int(size * 0.58)
|
||||
glyph = icon(_TOOL_ICON_NAME.get(name, "puzzle"), size=inner, color="#FFFFFF").pixmap(inner, inner)
|
||||
p.drawPixmap((size - inner) // 2, (size - inner) // 2, glyph)
|
||||
p.end()
|
||||
return pm
|
||||
|
||||
|
||||
def _clear_flow(flow: FlowLayout) -> None:
|
||||
while flow.count():
|
||||
item = flow.takeAt(0)
|
||||
w = item.widget()
|
||||
if w is not None:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
class ToolsAdminTab(QWidget):
|
||||
@@ -54,21 +84,20 @@ class ToolsAdminTab(QWidget):
|
||||
self._hint.setWordWrap(True)
|
||||
tl.addWidget(self._hint)
|
||||
|
||||
self.table = QTableWidget(0, 3)
|
||||
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
# Description is the long column — IT stretches to fill remaining
|
||||
# width (was Name, leaving Description squeezed into whatever was
|
||||
# left over); Name/Enabled size to their own content.
|
||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
||||
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
self.table.setWordWrap(True)
|
||||
tl.addWidget(self.table, 1)
|
||||
# A left-aligned, wrapping card grid — one card per built-in tool
|
||||
# (colour-coded icon + name + toggle switch + description), replacing
|
||||
# the old flat Name/Description/Enabled table.
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
cards_host = QWidget()
|
||||
self._tool_flow = FlowLayout(cards_host, margin=0, h_spacing=10, v_spacing=10)
|
||||
scroll.setWidget(cards_host)
|
||||
tl.addWidget(scroll, 1)
|
||||
|
||||
# "Test Internet" self-test lives INSIDE the fetch_url tool row now (see
|
||||
# refresh) instead of a separate boxed section — persistent widgets so
|
||||
# they survive table rebuilds.
|
||||
# "Test Internet" self-test lives INSIDE the fetch_url tool's card now
|
||||
# (see refresh) instead of a separate boxed section — persistent
|
||||
# widgets so they survive card rebuilds.
|
||||
self.test_internet_btn = QPushButton(tr("settings.test_internet"))
|
||||
self.test_internet_btn.setIcon(icon("globe"))
|
||||
self.test_internet_btn.setToolTip(tr("settings.test_internet_tooltip"))
|
||||
@@ -94,51 +123,71 @@ class ToolsAdminTab(QWidget):
|
||||
self.connectors_panel = ConnectorsPanel(ctx)
|
||||
self.subtabs.addTab(self.connectors_panel, "")
|
||||
|
||||
# on_language_changed() already invokes _retranslate() once immediately
|
||||
# (see i18n.py) — a second explicit call here double-populates the
|
||||
# card grid back-to-back with no event-loop turn in between, so the
|
||||
# first pass's cards are only queued for deleteLater() (not yet gone)
|
||||
# when the second pass adds new ones on top (see connectors_panel.py's
|
||||
# ConnectorsPanel, which hit the exact same bug this same way).
|
||||
on_language_changed(self._retranslate)
|
||||
self._retranslate()
|
||||
|
||||
# ---- built-in tools table -------------------------------------------------
|
||||
# ---- built-in tools card grid ---------------------------------------------
|
||||
def refresh(self) -> None:
|
||||
disabled = set(self.ctx.config.tools_disabled)
|
||||
specs = list(TOOL_SPECS)
|
||||
self.table.setRowCount(len(specs))
|
||||
for r, spec in enumerate(specs):
|
||||
self.table.setItem(r, 0, QTableWidgetItem(spec.name))
|
||||
# Full description (was truncated to 80 chars, hiding the rest) —
|
||||
# word-wraps inside the stretched column; resizeRowToContents
|
||||
# below grows the row to fit however many lines that takes.
|
||||
if spec.name == "fetch_url":
|
||||
# This tool's row carries the live "Test Internet" self-test
|
||||
# right below its description — no separate boxed section.
|
||||
self.table.setItem(r, 1, None)
|
||||
self.table.setCellWidget(r, 1, self._fetch_url_desc_cell(spec))
|
||||
else:
|
||||
desc_item = QTableWidgetItem(spec.description)
|
||||
desc_item.setToolTip(spec.description)
|
||||
self.table.setItem(r, 1, desc_item)
|
||||
self.table.setCellWidget(
|
||||
r, 2, _center_checkbox(spec.name not in disabled,
|
||||
lambda on, n=spec.name: self._toggle_builtin(n, on)))
|
||||
# Once ALL rows/columns are populated (so the stretched Description
|
||||
# column has its real width), grow each row to fit its wrapped text.
|
||||
self.table.resizeRowsToContents()
|
||||
_clear_flow(self._tool_flow)
|
||||
for spec in TOOL_SPECS:
|
||||
self._tool_flow.addWidget(self._tool_card(spec, spec.name not in disabled))
|
||||
|
||||
def _tool_card(self, spec, enabled: bool) -> QWidget:
|
||||
card = QFrame()
|
||||
card.setFrameShape(QFrame.NoFrame)
|
||||
style_card(card)
|
||||
card.setFixedWidth(220)
|
||||
# The description below wraps to a variable number of lines at this
|
||||
# fixed width, so the card's own height depends on its width — without
|
||||
# this, the outer FlowLayout's QWidgetItem queries card.sizePolicy()
|
||||
# (not the description label's), gets a too-short sizeHint, and
|
||||
# squeezes the card into less height than its QVBoxLayout needs,
|
||||
# which is what overlapped the header onto the description text.
|
||||
enable_height_for_width(card)
|
||||
lay = QVBoxLayout(card)
|
||||
lay.setContentsMargins(10, 8, 10, 8)
|
||||
lay.setSpacing(4)
|
||||
|
||||
hdr = QHBoxLayout()
|
||||
icon_lbl = QLabel()
|
||||
icon_lbl.setPixmap(_tool_icon_pixmap(spec.name))
|
||||
icon_lbl.setStyleSheet("border: none;")
|
||||
hdr.addWidget(icon_lbl)
|
||||
name_lbl = QLabel(spec.name)
|
||||
name_lbl.setStyleSheet("font-weight:700; border: none;")
|
||||
hdr.addWidget(name_lbl)
|
||||
hdr.addStretch(1)
|
||||
sw = ToggleSwitch()
|
||||
sw.setChecked(enabled)
|
||||
sw.toggled.connect(lambda on, n=spec.name: self._toggle_builtin(n, on))
|
||||
hdr.addWidget(sw)
|
||||
lay.addLayout(hdr)
|
||||
|
||||
def _fetch_url_desc_cell(self, spec) -> QWidget:
|
||||
cell = QWidget()
|
||||
cl = QVBoxLayout(cell)
|
||||
cl.setContentsMargins(6, 4, 6, 4)
|
||||
cl.setSpacing(4)
|
||||
desc = QLabel(spec.description)
|
||||
desc.setWordWrap(True)
|
||||
desc.setToolTip(spec.description)
|
||||
cl.addWidget(desc)
|
||||
net = QWidget()
|
||||
nl = QHBoxLayout(net)
|
||||
nl.setContentsMargins(0, 0, 0, 0)
|
||||
nl.addWidget(self.test_internet_btn)
|
||||
nl.addWidget(self.test_internet_status, 1)
|
||||
cl.addWidget(net)
|
||||
return cell
|
||||
desc.setObjectName("hint")
|
||||
desc.setStyleSheet("border: none;")
|
||||
lay.addWidget(desc)
|
||||
|
||||
if spec.name == "fetch_url":
|
||||
# The live "Test Internet" self-test lives inside fetch_url's own
|
||||
# card — it tests THIS capability, not the tab as a whole.
|
||||
net = QWidget()
|
||||
net.setStyleSheet("border: none;")
|
||||
nl = QHBoxLayout(net)
|
||||
nl.setContentsMargins(0, 2, 0, 0)
|
||||
nl.addWidget(self.test_internet_btn)
|
||||
nl.addWidget(self.test_internet_status, 1)
|
||||
lay.addWidget(net)
|
||||
|
||||
return card
|
||||
|
||||
def _toggle_builtin(self, name: str, enabled: bool) -> None:
|
||||
self.ctx.config.set_tool_enabled(name, enabled)
|
||||
@@ -192,9 +241,5 @@ class ToolsAdminTab(QWidget):
|
||||
self.test_internet_btn.setText(tr("settings.test_internet"))
|
||||
self.test_internet_btn.setToolTip(tr("settings.test_internet_tooltip"))
|
||||
self.refresh_btn.setText(tr("tools_admin.refresh"))
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
tr("tools_admin.col_name"), tr("tools_admin.col_desc"),
|
||||
tr("tools_admin.col_enabled"),
|
||||
])
|
||||
self.jira_note.setText(tr("tools_admin.jira_note"))
|
||||
self.refresh()
|
||||
|
||||
+474
-36
@@ -5,39 +5,150 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, Qt, Signal
|
||||
from PySide6.QtCore import (
|
||||
QEvent, QObject, QPoint, QPointF, QRect, QRectF, QSize, Qt, Signal,
|
||||
)
|
||||
from PySide6.QtGui import QColor, QPainter, QPen
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractSpinBox, QComboBox, QDoubleSpinBox, QFrame, QGraphicsDropShadowEffect,
|
||||
QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSizePolicy,
|
||||
QVBoxLayout, QWidget,
|
||||
QAbstractSpinBox, QCheckBox, QComboBox, QDoubleSpinBox, QFrame,
|
||||
QHBoxLayout, QLabel, QLayout, QListWidget, QListWidgetItem, QPushButton,
|
||||
QSizePolicy, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from ..core.flows import STEP_DONE, STEP_ERROR, STEP_PENDING, STEP_RUNNING
|
||||
from ..theme import ACCENT
|
||||
from ..theme import current_palette
|
||||
from .icons import DOT_BLUE, DOT_GREEN, DOT_GREY, DOT_RED, dot_icon, icon
|
||||
|
||||
|
||||
def badge_pill_widget(text: str, object_name: str) -> QWidget:
|
||||
"""A small rounded pill for a table cell — a coloured role/status tag
|
||||
(``object_name`` is one of theme.py's ``badge*`` QLabel names). Wrapped in
|
||||
a transparent container rather than passed as a bare label: a
|
||||
``setCellWidget()`` widget is stretched to fill the whole cell, and
|
||||
without the container's own ``background: transparent`` the app-wide
|
||||
``QWidget { background: $bg }`` rule (theme.py) paints that stretched
|
||||
area opaque, hiding the pill inside a solid block instead of a snug tag."""
|
||||
container = QWidget()
|
||||
container.setStyleSheet("background: transparent;")
|
||||
lay = QHBoxLayout(container)
|
||||
lay.setContentsMargins(4, 2, 4, 2)
|
||||
lbl = QLabel(text)
|
||||
lbl.setObjectName(object_name)
|
||||
lay.addWidget(lbl, 0, Qt.AlignVCenter)
|
||||
lay.addStretch(1)
|
||||
return container
|
||||
|
||||
|
||||
def enable_height_for_width(widget: QWidget) -> None:
|
||||
"""Flag ``widget`` as height-for-width so a PARENT layout reserves the
|
||||
right amount of vertical space for it — needed at every widget boundary
|
||||
between a :class:`FlowLayout` and the outermost layout, since each
|
||||
``addWidget()`` hop asks the WIDGET's own sizePolicy, not its layout's
|
||||
(see FlowLayout's docstring)."""
|
||||
policy = widget.sizePolicy()
|
||||
policy.setHeightForWidth(True)
|
||||
widget.setSizePolicy(policy)
|
||||
|
||||
|
||||
class FlowLayout(QLayout):
|
||||
"""A left-aligned layout that wraps its children onto new lines as the
|
||||
container narrows, each item kept at its own natural size — the
|
||||
``.card`` grids in ui-audit_v2.html ("không kéo giãn lấp đầy hàng": cards
|
||||
stay sized to their own content, never stretched to fill a row). Qt has
|
||||
no built-in equivalent; this is the standard recipe (Qt's own C++
|
||||
FlowLayout example, ported)."""
|
||||
|
||||
def __init__(self, parent=None, margin: int = 0, h_spacing: int = 8, v_spacing: int = 8):
|
||||
super().__init__(parent)
|
||||
self._h_spacing = h_spacing
|
||||
self._v_spacing = v_spacing
|
||||
self._items: list = []
|
||||
self.setContentsMargins(margin, margin, margin, margin)
|
||||
if parent is not None:
|
||||
enable_height_for_width(parent)
|
||||
|
||||
def addItem(self, item) -> None: # noqa: N802 - Qt override
|
||||
self._items.append(item)
|
||||
|
||||
def count(self) -> int: # noqa: N802 - Qt override
|
||||
return len(self._items)
|
||||
|
||||
def itemAt(self, index: int): # noqa: N802 - Qt override
|
||||
return self._items[index] if 0 <= index < len(self._items) else None
|
||||
|
||||
def takeAt(self, index: int): # noqa: N802 - Qt override
|
||||
return self._items.pop(index) if 0 <= index < len(self._items) else None
|
||||
|
||||
def expandingDirections(self): # noqa: N802 - Qt override
|
||||
return Qt.Orientations(Qt.Orientation(0))
|
||||
|
||||
def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt override
|
||||
return True
|
||||
|
||||
def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt override
|
||||
return self._do_layout(QRect(0, 0, width, 0), test_only=True)
|
||||
|
||||
def setGeometry(self, rect) -> None: # noqa: N802 - Qt override
|
||||
super().setGeometry(rect)
|
||||
self._do_layout(rect, test_only=False)
|
||||
|
||||
def sizeHint(self): # noqa: N802 - Qt override
|
||||
return self.minimumSize()
|
||||
|
||||
def minimumSize(self): # noqa: N802 - Qt override
|
||||
size = QSize()
|
||||
for item in self._items:
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
m = self.contentsMargins()
|
||||
size += QSize(m.left() + m.right(), m.top() + m.bottom())
|
||||
return size
|
||||
|
||||
def _do_layout(self, rect, test_only: bool) -> int:
|
||||
m = self.contentsMargins()
|
||||
effective = QRect(rect.x() + m.left(), rect.y() + m.top(),
|
||||
rect.width() - m.left() - m.right(),
|
||||
rect.height() - m.top() - m.bottom())
|
||||
x, y = effective.x(), effective.y()
|
||||
line_height = 0
|
||||
for item in self._items:
|
||||
hint = item.sizeHint()
|
||||
next_x = x + hint.width() + self._h_spacing
|
||||
if next_x - self._h_spacing > effective.right() and line_height > 0:
|
||||
x = effective.x()
|
||||
y = y + line_height + self._v_spacing
|
||||
next_x = x + hint.width() + self._h_spacing
|
||||
line_height = 0
|
||||
if not test_only:
|
||||
item.setGeometry(QRect(QPoint(x, y), hint))
|
||||
x = next_x
|
||||
line_height = max(line_height, hint.height())
|
||||
return y + line_height - rect.y() + m.bottom()
|
||||
|
||||
|
||||
def style_card(frame: QFrame) -> None:
|
||||
"""Give a stat/budget card its surface. Flat by design: the raised surface
|
||||
plus a hairline is what separates it from the page — the old drop shadow
|
||||
made a grid of these look like it was hovering off the screen."""
|
||||
p = current_palette()
|
||||
frame.setStyleSheet(
|
||||
f"QFrame {{ background: {p.surface}; border: 1px solid {p.border};"
|
||||
f" border-radius: {p.radius_lg}px; }}")
|
||||
|
||||
|
||||
class StatCard(QFrame):
|
||||
"""A titled value card (e.g. token count + its cost as the subtitle) —
|
||||
shared by Dashboard and Monitoring's token/cost displays."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
self.setStyleSheet(
|
||||
"QFrame { border: 1px solid rgba(140,146,152,0.35); border-radius: 16px; }")
|
||||
shadow = QGraphicsDropShadowEffect(self)
|
||||
shadow.setBlurRadius(18)
|
||||
shadow.setOffset(0, 3)
|
||||
shadow.setColor(QColor(0, 0, 0, 60))
|
||||
self.setGraphicsEffect(shadow)
|
||||
self.setFrameShape(QFrame.NoFrame)
|
||||
style_card(self)
|
||||
lay = QVBoxLayout(self)
|
||||
self.title_lbl = QLabel("")
|
||||
self.title_lbl.setObjectName("hint")
|
||||
self.title_lbl.setStyleSheet("border: none;")
|
||||
self.value_lbl = QLabel("—")
|
||||
self.value_lbl.setStyleSheet("border: none; font-size: 20px; font-weight: 700;")
|
||||
self.value_lbl.setStyleSheet("border: none; font-size: 22px; font-weight: 600;")
|
||||
self.sub_lbl = QLabel("")
|
||||
self.sub_lbl.setObjectName("hint")
|
||||
self.sub_lbl.setStyleSheet("border: none;")
|
||||
@@ -46,8 +157,10 @@ class StatCard(QFrame):
|
||||
# sizeHint hundreds of px wide, forcing its WHOLE grid column open and
|
||||
# throwing every card in the row out of alignment.
|
||||
self.sub_lbl.setWordWrap(True)
|
||||
lay.addWidget(self.title_lbl)
|
||||
# Number first, name under it — the figure is what the eye is looking
|
||||
# for, and it is how the audit page's tiles are drawn.
|
||||
lay.addWidget(self.value_lbl)
|
||||
lay.addWidget(self.title_lbl)
|
||||
lay.addWidget(self.sub_lbl)
|
||||
|
||||
def set(self, title: str, value: str, sub: str = "") -> None:
|
||||
@@ -55,6 +168,19 @@ class StatCard(QFrame):
|
||||
self.value_lbl.setText(value)
|
||||
self.sub_lbl.setText(sub)
|
||||
|
||||
def as_hero(self) -> "StatCard":
|
||||
"""Make this the headline card: bigger number, accent colour.
|
||||
|
||||
Used for the one figure a screen is really about (Dashboard's total
|
||||
cost), so a row of otherwise identical tiles has a clear first read.
|
||||
"""
|
||||
from ..theme import current_palette
|
||||
p = current_palette()
|
||||
self.value_lbl.setStyleSheet(
|
||||
f"border: none; font-size: 34px; font-weight: 700; color: {p.accent};")
|
||||
self.setObjectName("heroCard")
|
||||
return self
|
||||
|
||||
|
||||
class BudgetCard(QFrame):
|
||||
"""Remaining/Budget box — same card chrome as :class:`StatCard`, plus a
|
||||
@@ -66,20 +192,14 @@ class BudgetCard(QFrame):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
self.setStyleSheet(
|
||||
"QFrame { border: 1px solid rgba(140,146,152,0.35); border-radius: 16px; }")
|
||||
shadow = QGraphicsDropShadowEffect(self)
|
||||
shadow.setBlurRadius(18)
|
||||
shadow.setOffset(0, 3)
|
||||
shadow.setColor(QColor(0, 0, 0, 60))
|
||||
self.setGraphicsEffect(shadow)
|
||||
self.setFrameShape(QFrame.NoFrame)
|
||||
style_card(self)
|
||||
lay = QVBoxLayout(self)
|
||||
self.title_lbl = QLabel("")
|
||||
self.title_lbl.setObjectName("hint")
|
||||
self.title_lbl.setStyleSheet("border: none;")
|
||||
self.value_lbl = QLabel("—")
|
||||
self._value_style = "border: none; font-size: 20px; font-weight: 700;"
|
||||
self._value_style = "border: none; font-size: 22px; font-weight: 600;"
|
||||
self.value_lbl.setStyleSheet(self._value_style)
|
||||
self.sub_lbl = QLabel("")
|
||||
self.sub_lbl.setObjectName("hint")
|
||||
@@ -89,8 +209,9 @@ class BudgetCard(QFrame):
|
||||
# sizeHint hundreds of px wide, forcing its WHOLE grid column open and
|
||||
# throwing every card in the row out of alignment.
|
||||
self.sub_lbl.setWordWrap(True)
|
||||
lay.addWidget(self.title_lbl)
|
||||
# Same order as StatCard: the number first, its name under it.
|
||||
lay.addWidget(self.value_lbl)
|
||||
lay.addWidget(self.title_lbl)
|
||||
lay.addWidget(self.sub_lbl)
|
||||
|
||||
row = QHBoxLayout()
|
||||
@@ -109,7 +230,7 @@ class BudgetCard(QFrame):
|
||||
self.title_lbl.setText(title)
|
||||
self.value_lbl.setText(value)
|
||||
self.value_lbl.setStyleSheet(
|
||||
self._value_style + (" color: #E5484D;" if warn else ""))
|
||||
self._value_style + (f" color: {current_palette().danger};" if warn else ""))
|
||||
self.sub_lbl.setText(sub)
|
||||
|
||||
|
||||
@@ -148,6 +269,310 @@ def guard_wheel(root: QWidget) -> None:
|
||||
w.installEventFilter(_wheel_guard)
|
||||
|
||||
|
||||
def tidy_popup(combo) -> None:
|
||||
"""Make a drop-list show its options and nothing else.
|
||||
|
||||
Two platform habits to undo. macOS marks the current row with a checkmark,
|
||||
drawn by the menu-style delegate a combo gets by default; the row is already
|
||||
tinted by selection-background-color, so the tick says nothing twice and, in
|
||||
a combo only as wide as "VN", covered the letters it was marking. Handing
|
||||
the view a plain QStyledItemDelegate switches it to item-view painting,
|
||||
where no such glyph exists.
|
||||
|
||||
And the popup inherits the combo's width unless told otherwise, which had
|
||||
the project and provider names cut off here regardless of platform. So
|
||||
measure the longest item — plus an indicator's worth of room, in case a
|
||||
style still draws one — and set that as the view's minimum.
|
||||
"""
|
||||
from PySide6.QtWidgets import QStyle, QStyledItemDelegate
|
||||
|
||||
view = combo.view()
|
||||
combo.setItemDelegate(QStyledItemDelegate(combo))
|
||||
fm = view.fontMetrics()
|
||||
longest = max((fm.horizontalAdvance(combo.itemText(i))
|
||||
for i in range(combo.count())), default=0)
|
||||
tick = combo.style().pixelMetric(QStyle.PM_IndicatorWidth, None, combo)
|
||||
pad = combo.style().pixelMetric(QStyle.PM_FocusFrameHMargin, None, combo) * 2
|
||||
view.setMinimumWidth(longest + tick + pad + 16)
|
||||
|
||||
|
||||
def ui_scale(widget: QWidget) -> float:
|
||||
"""How much bigger this machine draws things than the design baseline.
|
||||
|
||||
Breakpoints written as raw pixels only hold on the screen they were tuned
|
||||
on. At 125%/150% display scaling Qt still reports logical pixels, but every
|
||||
label, button and margin is taller — so the same layout needs MORE logical
|
||||
width before it stops being cramped. Font height is the honest proxy for
|
||||
that: it moves with the display scale and with a user's font-size choice,
|
||||
both of which change how much fits.
|
||||
|
||||
1.0 at the 15px line height the layouts were measured against.
|
||||
"""
|
||||
return max(0.75, min(2.5, widget.fontMetrics().height() / 15.0))
|
||||
|
||||
|
||||
class _NarrowGuard(QObject):
|
||||
"""Calls back when the WINDOW crosses a width threshold.
|
||||
|
||||
Watching the widget's own width does not work: a pane whose minimum width is
|
||||
larger than the space available never reports being narrow — it just gets
|
||||
clipped, which is the very problem being solved. The window always knows its
|
||||
real size, so that is what gets watched.
|
||||
|
||||
A fold the user did by hand is never undone: auto-expand only reverses an
|
||||
auto-collapse.
|
||||
"""
|
||||
|
||||
def __init__(self, owner: QWidget, threshold: int, apply):
|
||||
super().__init__(owner)
|
||||
self._owner = owner
|
||||
self._threshold = threshold
|
||||
self._apply = apply
|
||||
self._auto = False # True while WE are the ones holding it folded
|
||||
self._window = None
|
||||
|
||||
def attach(self) -> None:
|
||||
win = self._owner.window()
|
||||
if win is not None and win is not self._owner and win is not self._window:
|
||||
win.installEventFilter(self)
|
||||
self._window = win
|
||||
# Dragging the window to a monitor with different scaling changes
|
||||
# how much fits without changing its width, so re-decide then too.
|
||||
handle = win.windowHandle()
|
||||
if handle is not None:
|
||||
handle.screenChanged.connect(lambda *_a: self.check())
|
||||
self.check()
|
||||
|
||||
def eventFilter(self, obj, ev): # noqa: N802 - Qt override
|
||||
if ev.type() == QEvent.Resize and obj is self._window:
|
||||
self.check()
|
||||
return super().eventFilter(obj, ev)
|
||||
|
||||
def check(self) -> None:
|
||||
win = self._owner.window()
|
||||
width = win.width() if win is not None else self._owner.width()
|
||||
# The threshold is written for the baseline scale and grows with the
|
||||
# machine's — see ui_scale().
|
||||
narrow = width < self._threshold * ui_scale(self._owner)
|
||||
if narrow == self._auto:
|
||||
return
|
||||
self._auto = narrow
|
||||
self._apply(narrow)
|
||||
|
||||
|
||||
def narrow_guard(owner: QWidget, threshold: int, apply):
|
||||
"""Fold `owner`'s secondary panes below `threshold` px of window width.
|
||||
|
||||
``apply(narrow: bool)`` does the folding. Call ``.attach()`` from showEvent.
|
||||
"""
|
||||
return _NarrowGuard(owner, threshold, apply)
|
||||
|
||||
|
||||
class ToggleSwitch(QCheckBox):
|
||||
"""A checkbox drawn as an on/off switch.
|
||||
|
||||
Subclasses QCheckBox rather than replacing it, so every ``isChecked()`` /
|
||||
``setChecked()`` / ``stateChanged`` call site keeps working untouched — only
|
||||
the painting changes. A switch reads as "this is on or off" where a tick box
|
||||
reads as "this is selected", which is what these settings actually mean.
|
||||
"""
|
||||
|
||||
_W, _H = 34, 18
|
||||
|
||||
def __init__(self, text: str = "", parent=None):
|
||||
super().__init__(text, parent)
|
||||
self.setCursor(Qt.PointingHandCursor)
|
||||
|
||||
def sizeHint(self): # noqa: N802 - Qt override
|
||||
base = super().sizeHint()
|
||||
base.setWidth(base.width() + self._W)
|
||||
base.setHeight(max(base.height(), self._H + 4))
|
||||
return base
|
||||
|
||||
def paintEvent(self, _e): # noqa: N802 - Qt override
|
||||
from ..theme import current_palette
|
||||
p = current_palette()
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
y = (self.height() - self._H) // 2
|
||||
track = QRectF(0, y, self._W, self._H)
|
||||
on = self.isChecked()
|
||||
enabled = self.isEnabled()
|
||||
fill = QColor(p.accent_solid if on else p.border_strong)
|
||||
if not enabled:
|
||||
fill.setAlpha(110)
|
||||
painter.setPen(Qt.NoPen)
|
||||
painter.setBrush(fill)
|
||||
painter.drawRoundedRect(track, self._H / 2, self._H / 2)
|
||||
knob = self._H - 4
|
||||
kx = self._W - knob - 2 if on else 2
|
||||
painter.setBrush(QColor("#FFFFFF" if enabled else p.text_faint))
|
||||
painter.drawEllipse(QRectF(kx, y + 2, knob, knob))
|
||||
if self.text():
|
||||
painter.setPen(QColor(p.text if enabled else p.text_faint))
|
||||
painter.drawText(
|
||||
QRectF(self._W + 8, 0, self.width() - self._W - 8, self.height()),
|
||||
int(Qt.AlignLeft | Qt.AlignVCenter), self.text())
|
||||
painter.end()
|
||||
|
||||
|
||||
class SegmentedControl(QWidget):
|
||||
"""Two-to-four choices shown side by side instead of hidden in a drop-list.
|
||||
|
||||
Exposes the slice of the QComboBox API this app's settings code uses
|
||||
(addItem / findData / currentData / setCurrentIndex / currentIndexChanged),
|
||||
so it drops into an existing form without touching the save/load paths.
|
||||
"""
|
||||
|
||||
currentIndexChanged = Signal(int)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._data: list = []
|
||||
self._buttons: list = []
|
||||
self._current = -1
|
||||
lay = QHBoxLayout(self)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.setSpacing(0)
|
||||
self._lay = lay
|
||||
lay.addStretch(1)
|
||||
|
||||
def addItem(self, text: str, data=None) -> None: # noqa: N802 - Qt-style name
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
btn = QPushButton(text)
|
||||
btn.setObjectName("segItem")
|
||||
btn.setCheckable(True)
|
||||
btn.setCursor(Qt.PointingHandCursor)
|
||||
index = len(self._buttons)
|
||||
btn.clicked.connect(lambda _c=False, i=index: self.setCurrentIndex(i))
|
||||
self._lay.insertWidget(index, btn)
|
||||
self._buttons.append(btn)
|
||||
self._data.append(data)
|
||||
if self._current < 0:
|
||||
self.setCurrentIndex(0)
|
||||
|
||||
def findData(self, value) -> int: # noqa: N802
|
||||
return self._data.index(value) if value in self._data else -1
|
||||
|
||||
def currentData(self): # noqa: N802
|
||||
return self._data[self._current] if 0 <= self._current < len(self._data) else None
|
||||
|
||||
def currentIndex(self) -> int: # noqa: N802
|
||||
return self._current
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self._buttons)
|
||||
|
||||
def setItemText(self, index: int, text: str) -> None: # noqa: N802
|
||||
if 0 <= index < len(self._buttons):
|
||||
self._buttons[index].setText(text)
|
||||
|
||||
def setCurrentIndex(self, index: int) -> None: # noqa: N802
|
||||
if not (0 <= index < len(self._buttons)) or index == self._current:
|
||||
for i, b in enumerate(self._buttons):
|
||||
b.setChecked(i == self._current)
|
||||
return
|
||||
self._current = index
|
||||
for i, b in enumerate(self._buttons):
|
||||
b.setChecked(i == index)
|
||||
self.currentIndexChanged.emit(index)
|
||||
|
||||
|
||||
def section_panels(sections, width: int = 260):
|
||||
"""Left list + right panel: pick a section, see that section only.
|
||||
|
||||
``sections`` is [(label, widget)]. Returns (list_widget, stack) for the
|
||||
caller to place side by side. Used by Settings and the Task editor so both
|
||||
are navigated the same way, instead of one long scroll where you cannot
|
||||
tell which group you are in or how many are left.
|
||||
"""
|
||||
from PySide6.QtWidgets import QListWidget, QListWidgetItem, QStackedWidget
|
||||
|
||||
index = QListWidget()
|
||||
index.setObjectName("sectionIndex")
|
||||
index.setFrameShape(QListWidget.NoFrame)
|
||||
index.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
index.setTextElideMode(Qt.ElideRight)
|
||||
index.setWordWrap(False)
|
||||
|
||||
stack = QStackedWidget()
|
||||
for label, widget in sections:
|
||||
item = QListWidgetItem(label)
|
||||
item.setToolTip(label)
|
||||
index.addItem(item)
|
||||
stack.addWidget(widget)
|
||||
index.currentRowChanged.connect(stack.setCurrentIndex)
|
||||
index.setCurrentRow(0)
|
||||
|
||||
natural = max(index.fontMetrics().horizontalAdvance(lab) for lab, _w in sections) + 36
|
||||
index.setFixedWidth(max(120, min(width, natural)))
|
||||
return index, stack
|
||||
|
||||
|
||||
def section_index(scroll, sections, width: int = 260):
|
||||
"""A clickable table of contents for a long scrolling dialog.
|
||||
|
||||
``sections`` is [(label, anchor_widget)]. Clicking a row scrolls its anchor
|
||||
into view; scrolling the dialog moves the highlight back. Purely navigation:
|
||||
every field stays exactly where it was, in the same one scrolling column —
|
||||
Settings and the Task editor were five stacked group boxes deep with no way
|
||||
to tell what was further down.
|
||||
|
||||
Returns the QListWidget so the caller can place it.
|
||||
"""
|
||||
from PySide6.QtWidgets import QListWidget, QListWidgetItem
|
||||
|
||||
index = QListWidget()
|
||||
index.setObjectName("sectionIndex")
|
||||
index.setFrameShape(QListWidget.NoFrame)
|
||||
# Long section names (and 125%/150% display scaling) used to push a
|
||||
# horizontal scrollbar into this list. It elides instead, with the full
|
||||
# name on hover.
|
||||
index.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
index.setTextElideMode(Qt.ElideRight)
|
||||
index.setWordWrap(False)
|
||||
for label, anchor in sections:
|
||||
item = QListWidgetItem(label)
|
||||
item.setToolTip(label)
|
||||
item.setData(Qt.UserRole, anchor)
|
||||
index.addItem(item)
|
||||
index.setCurrentRow(0)
|
||||
# Wide enough for the longest name at the CURRENT font — so the width grows
|
||||
# with display scaling instead of eliding everything — but capped so it
|
||||
# never eats the form beside it. `width` is that cap, not a fixed size.
|
||||
natural = max(index.fontMetrics().horizontalAdvance(lab) for lab, _a in sections) + 36
|
||||
index.setFixedWidth(max(120, min(width, natural)))
|
||||
|
||||
def _jump(item):
|
||||
anchor = item.data(Qt.UserRole)
|
||||
if anchor is not None:
|
||||
# Scroll so the section's top edge lands at the top of the viewport,
|
||||
# rather than merely "somewhere visible".
|
||||
bar = scroll.verticalScrollBar()
|
||||
top = anchor.mapTo(scroll.widget(), anchor.rect().topLeft()).y()
|
||||
bar.setValue(min(top, bar.maximum()))
|
||||
|
||||
index.itemClicked.connect(_jump)
|
||||
|
||||
def _follow(value: int):
|
||||
"""Highlight the last section whose top has passed the viewport top."""
|
||||
row = 0
|
||||
for i in range(index.count()):
|
||||
anchor = index.item(i).data(Qt.UserRole)
|
||||
if anchor is None:
|
||||
continue
|
||||
top = anchor.mapTo(scroll.widget(), anchor.rect().topLeft()).y()
|
||||
if top <= value + 4:
|
||||
row = i
|
||||
if index.currentRow() != row:
|
||||
blocked = index.blockSignals(True)
|
||||
index.setCurrentRow(row)
|
||||
index.blockSignals(blocked)
|
||||
|
||||
scroll.verticalScrollBar().valueChanged.connect(_follow)
|
||||
return index
|
||||
|
||||
|
||||
class CollapseStrip(QWidget):
|
||||
"""The slim bar shown in place of a collapsed side panel.
|
||||
|
||||
@@ -185,15 +610,16 @@ class CollapseStrip(QWidget):
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.Antialiasing)
|
||||
w = self.width()
|
||||
accent = QColor(ACCENT) if self._hover else QColor("#8b8d98")
|
||||
tok = current_palette()
|
||||
accent = QColor(tok.accent) if self._hover else QColor(tok.text_faint)
|
||||
|
||||
# A small rounded "button" at the top carries the expand arrow so the
|
||||
# collapsed panel always shows a clear, clickable affordance.
|
||||
bw = min(w - 2.0, 16.0)
|
||||
btn = QRectF((w - bw) / 2.0, 6.0, bw, 18.0)
|
||||
p.setPen(QPen(QColor(139, 144, 150, 130), 1.0))
|
||||
p.setBrush(QColor(155, 160, 166, 70) if self._hover else QColor(155, 160, 166, 32))
|
||||
p.drawRoundedRect(btn, 4.0, 4.0)
|
||||
p.setPen(QPen(QColor(tok.border_strong), 1.0))
|
||||
p.setBrush(QColor(tok.hover if self._hover else tok.surface))
|
||||
p.drawRoundedRect(btn, float(tok.radius_sm), float(tok.radius_sm))
|
||||
|
||||
cx = w / 2.0
|
||||
cy = btn.center().y()
|
||||
@@ -211,7 +637,7 @@ class CollapseStrip(QWidget):
|
||||
|
||||
# thin handle line below the button
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor(155, 160, 166, 90))
|
||||
p.setBrush(QColor(tok.border_strong))
|
||||
line_w = 2.0
|
||||
x = (w - line_w) / 2.0
|
||||
ltop = btn.bottom() + 6.0
|
||||
@@ -226,7 +652,12 @@ class PlanSection(QWidget):
|
||||
close). Hidden until it has steps; updated in place as the agent calls
|
||||
``update_plan``."""
|
||||
|
||||
_COLORS = {STEP_RUNNING: ACCENT, STEP_DONE: "#6fe3a4", STEP_ERROR: "#ef6368"}
|
||||
@staticmethod
|
||||
def _step_color(status: str) -> str | None:
|
||||
"""Row text colour per step status; None leaves the default. Resolved
|
||||
per call so it follows a live theme switch."""
|
||||
p = current_palette()
|
||||
return {STEP_RUNNING: p.accent, STEP_DONE: p.success, STEP_ERROR: p.danger}.get(status)
|
||||
|
||||
@staticmethod
|
||||
def _step_icon(status: str):
|
||||
@@ -272,7 +703,7 @@ class PlanSection(QWidget):
|
||||
continue
|
||||
status = str((s or {}).get("status", STEP_PENDING)).strip().lower()
|
||||
item = QListWidgetItem(self._step_icon(status), f" {title}")
|
||||
color = self._COLORS.get(status)
|
||||
color = self._step_color(status)
|
||||
if color:
|
||||
item.setForeground(QColor(color))
|
||||
self.list.addItem(item)
|
||||
@@ -308,7 +739,11 @@ class CollapsibleSection(QWidget):
|
||||
|
||||
activated = Signal(str) # emits the path of a clicked item
|
||||
|
||||
def __init__(self, title: str, max_height: int = 130):
|
||||
def __init__(self, title: str, max_height: int | None = 130):
|
||||
"""``max_height`` caps the list so it scrolls instead of growing
|
||||
(the default, e.g. for a section sharing space with siblings).
|
||||
``None`` instead lets it expand to fill whatever room its parent
|
||||
layout hands it — for a section that owns the whole panel."""
|
||||
super().__init__()
|
||||
self._title = title
|
||||
self._paths: list[str] = []
|
||||
@@ -325,11 +760,14 @@ class CollapsibleSection(QWidget):
|
||||
lay.addWidget(self.header)
|
||||
|
||||
self.list = QListWidget()
|
||||
self.list.setMaximumHeight(max_height) # scrolls when longer
|
||||
if max_height is not None:
|
||||
self.list.setMaximumHeight(max_height) # scrolls when longer
|
||||
else:
|
||||
self.list.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
|
||||
self.list.setVisible(False)
|
||||
self.list.itemActivated.connect(self._emit)
|
||||
self.list.itemClicked.connect(self._emit)
|
||||
lay.addWidget(self.list)
|
||||
lay.addWidget(self.list, 1 if max_height is None else 0)
|
||||
|
||||
self.setVisible(False)
|
||||
self._update_header()
|
||||
|
||||
+277
-17
@@ -32,12 +32,32 @@ from .osutil import open_folder
|
||||
from .widgets import CollapseStrip
|
||||
|
||||
|
||||
class _ProjectRow(QWidget):
|
||||
"""A project in the list: its name, and under it how much is in it.
|
||||
|
||||
The drawing gives every row a second line — "2 đoạn chat · 3 task" — which
|
||||
is the only thing on this screen that says a project holds anything at all.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str, counts: str):
|
||||
super().__init__()
|
||||
lay = QVBoxLayout(self)
|
||||
lay.setContentsMargins(6, 4, 6, 4)
|
||||
lay.setSpacing(0)
|
||||
title = QLabel(name)
|
||||
sub = QLabel(counts)
|
||||
sub.setObjectName("hint")
|
||||
lay.addWidget(title)
|
||||
lay.addWidget(sub)
|
||||
|
||||
|
||||
class WorkspaceTab(QWidget):
|
||||
status_message = Signal(str)
|
||||
open_chat = Signal(str, dict) # kind, conversation — open a thread in Cowork
|
||||
new_chat = Signal(str) # project_id — start a new thread in this project
|
||||
projects_changed = Signal() # created/edited/deleted → History regroups
|
||||
subtabs_changed = Signal() # visible sub-tabs changed → left-nav children refresh
|
||||
project_selected = Signal(str) # project_id — the rail's picker follows this
|
||||
|
||||
# ---- nav integration: the sub-tabs are driven from the left nav rail -----
|
||||
def nav_subtabs(self):
|
||||
@@ -50,10 +70,37 @@ class WorkspaceTab(QWidget):
|
||||
return [(self.tabs.tabText(i), i, icons.get(i, "chevron-right"))
|
||||
for i in range(self.tabs.count()) if self.tabs.isTabVisible(i)]
|
||||
|
||||
def nav_entries(self):
|
||||
"""(label, index, icon_name, enabled) for EVERY sub-tab, hidden ones
|
||||
included.
|
||||
|
||||
The rail lists all five all the time and greys out the ones the project
|
||||
gate is currently closing (Cowork, GraphRAG) instead of removing them —
|
||||
same gate, shown rather than hidden, so the menu stops changing shape
|
||||
under the user's hand. See nav_subtabs() for the visible-only view.
|
||||
"""
|
||||
icons = {self._project_tab_idx: "folder", self._cowork_tab_idx: "chat",
|
||||
self._co4e_tab_idx: "flow", self._folder_tab_idx: "folder",
|
||||
self._graphrag_tab_idx: "graph"}
|
||||
return [(self.tabs.tabText(i), i, icons.get(i, "chevron-right"),
|
||||
self.tabs.isTabVisible(i))
|
||||
for i in range(self.tabs.count())]
|
||||
|
||||
def subtab_available(self, index: int) -> bool:
|
||||
"""False while the project gate is holding this sub-tab shut.
|
||||
|
||||
The rail greys those rows out, but that only guards the rail. This lets
|
||||
every other route ask the same question of the same state.
|
||||
"""
|
||||
return bool(0 <= index < self.tabs.count() and self.tabs.isTabVisible(index))
|
||||
|
||||
def select_subtab(self, index: int) -> None:
|
||||
if 0 <= index < self.tabs.count():
|
||||
self.tabs.setCurrentIndex(index)
|
||||
|
||||
def current_subtab(self) -> int:
|
||||
return self.tabs.currentIndex()
|
||||
|
||||
def hide_tab_bar(self) -> None:
|
||||
"""Hide the in-content tab strip (the nav rail drives the sub-tabs now),
|
||||
so the content area is as large as possible."""
|
||||
@@ -63,6 +110,9 @@ class WorkspaceTab(QWidget):
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._current_id = ""
|
||||
# True once the user asks for the full History panel; until then Cowork
|
||||
# opens with it folded, as the drawing lays the screen out.
|
||||
self._history_opened = False
|
||||
# Shared widgets embedded as per-project sub-tabs (None in unit tests
|
||||
# that only drive project management).
|
||||
self._cowork = cowork
|
||||
@@ -70,12 +120,23 @@ class WorkspaceTab(QWidget):
|
||||
self._sidebar = sidebar
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
# Title row — the drawing puts "+ Project mới" up here beside the title,
|
||||
# not at the foot of the project list where it read as belonging to the
|
||||
# list's own controls.
|
||||
self._header = QLabel()
|
||||
self._header.setStyleSheet("font-weight:700; font-size:15px;")
|
||||
self._new_btn = QPushButton()
|
||||
self._new_btn.setIcon(icon("plus"))
|
||||
self._new_btn.setObjectName("primary")
|
||||
self._new_btn.clicked.connect(self._create)
|
||||
title_row = QHBoxLayout()
|
||||
title_row.addWidget(self._header)
|
||||
title_row.addStretch(1)
|
||||
title_row.addWidget(self._new_btn)
|
||||
root.addLayout(title_row)
|
||||
self._hint = QLabel()
|
||||
self._hint.setObjectName("hint")
|
||||
self._hint.setWordWrap(True)
|
||||
root.addWidget(self._header)
|
||||
root.addWidget(self._hint)
|
||||
|
||||
self._split = QSplitter(Qt.Horizontal)
|
||||
@@ -87,6 +148,9 @@ class WorkspaceTab(QWidget):
|
||||
ll = QVBoxLayout(left)
|
||||
ll.setContentsMargins(0, 0, 0, 0)
|
||||
left_hdr = QHBoxLayout()
|
||||
self._projects_hdr = QLabel()
|
||||
self._projects_hdr.setObjectName("navSectionHdr")
|
||||
left_hdr.addWidget(self._projects_hdr)
|
||||
self._proj_collapse_btn = QPushButton()
|
||||
self._proj_collapse_btn.setIcon(collapse_left_icon())
|
||||
self._proj_collapse_btn.setFixedWidth(28)
|
||||
@@ -98,15 +162,13 @@ class WorkspaceTab(QWidget):
|
||||
self.project_list.currentItemChanged.connect(self._on_select)
|
||||
ll.addWidget(self.project_list, 1)
|
||||
btns = QHBoxLayout()
|
||||
self._new_btn = QPushButton()
|
||||
self._new_btn.setIcon(icon("plus"))
|
||||
self._new_btn.setObjectName("primary")
|
||||
self._new_btn.clicked.connect(self._create)
|
||||
# Delete stays under the list it acts on. The drawing does not show it,
|
||||
# but it does not show it moved either, and dropping a control is not
|
||||
# something a layout pass gets to do.
|
||||
self._del_btn = QPushButton()
|
||||
self._del_btn.setIcon(icon("trash"))
|
||||
self._del_btn.clicked.connect(self._delete)
|
||||
btns.addWidget(self._new_btn, 1)
|
||||
btns.addWidget(self._del_btn)
|
||||
btns.addWidget(self._del_btn, 1)
|
||||
ll.addLayout(btns)
|
||||
self._projects_panel = left
|
||||
|
||||
@@ -127,6 +189,13 @@ class WorkspaceTab(QWidget):
|
||||
# Project comes FIRST; Cowork + GraphRAG only appear once a project is
|
||||
# actually selected (see _update_tab_visibility).
|
||||
self.tabs = QTabWidget()
|
||||
# A QTabWidget's minimum width is the MAXIMUM over every page, hidden
|
||||
# ones included — so Co4E (the widest, ~1180px) was setting the floor for
|
||||
# Project and Cowork as well, and through them for the whole window,
|
||||
# which then refused to be smaller than 1453px on any screen. An explicit
|
||||
# minimum overrides that: each page still gets whatever width is going,
|
||||
# and the pages that are not on screen no longer vote.
|
||||
self.tabs.setMinimumWidth(560)
|
||||
self._project_tab_idx = self.tabs.addTab(self._build_project_tab(), tr("workspace.tab_project"))
|
||||
self._cowork_tab_idx = -1
|
||||
self._graphrag_tab_idx = -1
|
||||
@@ -205,9 +274,14 @@ class WorkspaceTab(QWidget):
|
||||
rl.addWidget(self._instr_lbl)
|
||||
rl.addWidget(self.instr_edit)
|
||||
|
||||
self._folder_hdr = QLabel()
|
||||
rl.addWidget(self._folder_hdr)
|
||||
folder_row = QHBoxLayout()
|
||||
self.folder_lbl = QLabel()
|
||||
self.folder_lbl.setObjectName("hint")
|
||||
# The drawing shows the path in a field, not as grey caption text. A
|
||||
# read-only line edit looks like one and, unlike a label, lets the path
|
||||
# be selected and copied.
|
||||
self.folder_lbl = QLineEdit()
|
||||
self.folder_lbl.setReadOnly(True)
|
||||
self._browse_btn = QPushButton()
|
||||
self._browse_btn.setIcon(icon("folder"))
|
||||
self._browse_btn.clicked.connect(self._pick_folder)
|
||||
@@ -219,6 +293,7 @@ class WorkspaceTab(QWidget):
|
||||
folder_row.addWidget(self._open_btn)
|
||||
rl.addLayout(folder_row)
|
||||
|
||||
rl.addStretch(1) # the drawing floats Lưu project at the bottom
|
||||
save_row = QHBoxLayout()
|
||||
self._save_btn = QPushButton()
|
||||
self._save_btn.setIcon(icon("save"))
|
||||
@@ -239,11 +314,20 @@ class WorkspaceTab(QWidget):
|
||||
sb = self._sidebar
|
||||
sb.open_chat.connect(self._on_sidebar_open)
|
||||
sb.new_chat.connect(self._on_sidebar_new)
|
||||
sb.collapse_requested.connect(lambda: self._set_sidebar_collapsed(True))
|
||||
sb.expand_requested.connect(lambda: self._set_sidebar_collapsed(False))
|
||||
sb.collapse_requested.connect(lambda: self._on_history_fold(True))
|
||||
sb.expand_requested.connect(lambda: self._on_history_fold(False))
|
||||
sb.refresh_requested.connect(self._on_sidebar_refresh)
|
||||
sb.history_changed.connect(self._reload_threads)
|
||||
|
||||
def _on_history_fold(self, collapsed: bool) -> None:
|
||||
"""Its chevron closes the panel away, back to the drawn layout."""
|
||||
self._history_opened = not collapsed
|
||||
if collapsed:
|
||||
self._sidebar.setVisible(False)
|
||||
self._apply_pane_visibility()
|
||||
else:
|
||||
self._set_sidebar_collapsed(False)
|
||||
|
||||
def _set_sidebar_collapsed(self, collapsed: bool) -> None:
|
||||
"""Collapse/expand History sidebar and redistribute splitter space so
|
||||
the Cowork chat area fills the freed width (same pattern as
|
||||
@@ -321,17 +405,44 @@ class WorkspaceTab(QWidget):
|
||||
on_project = idx == self._project_tab_idx
|
||||
on_cowork = self._cowork_tab_idx >= 0 and idx == self._cowork_tab_idx
|
||||
self._projects_pane.setVisible(on_project)
|
||||
# "Workspace — Projects" and its three-line explanation describe the
|
||||
# PROJECT screen, but were drawn above every sub-tab — ~90px of vertical
|
||||
# space taken from Co4E's canvas and Folder's tree on every laptop
|
||||
# screen. Shown where they apply; the text itself is unchanged.
|
||||
self._header.setVisible(on_project)
|
||||
# The title row sits above the sub-tabs, so everything on it has to
|
||||
# follow the same rule the title does — moving + Project mới up here put
|
||||
# it in the corner of Cowork, Co4E, Folder and GraphRAG as well.
|
||||
self._new_btn.setVisible(on_project)
|
||||
# ...and the explanation only while there is nothing to explain against:
|
||||
# the drawing heads a populated screen with the title alone.
|
||||
self._hint.setVisible(on_project and self.project_list.count() == 0)
|
||||
narrow = getattr(self, "_is_narrow", False)
|
||||
if self._sidebar is not None:
|
||||
self._sidebar.setVisible(on_cowork)
|
||||
# Auto-expand History when entering Cowork tab so it's always usable
|
||||
if on_cowork:
|
||||
# Cowork is two columns in the drawing — transcript and files — with
|
||||
# no history pane and no strip where one used to be. The relocation
|
||||
# table is explicit: History moves to Sidebar ▸ RECENTS, "giữ, dễ
|
||||
# tới hơn". So the panel is not on this screen at all until asked
|
||||
# for: "Tất cả project…" in RECENTS brings it in, since search,
|
||||
# filters, pin, rename and multi-select delete live only there.
|
||||
self._sidebar.setVisible(on_cowork and self._history_opened)
|
||||
if on_cowork and self._history_opened:
|
||||
self._sidebar.set_collapsed(False)
|
||||
# QSplitter ignores hidden panes, but the freed width isn't handed to
|
||||
# the remaining panes deterministically — set explicit sizes after any
|
||||
# pane toggle (same lesson as _set_projects_collapsed).
|
||||
total = sum(self._split.sizes()) or 1300
|
||||
proj_w = 260 if on_project else 0
|
||||
hist_w = 240 if (on_cowork and self._sidebar is not None) else 0
|
||||
# A collapsed pane must be given the STRIP width here, not its open
|
||||
# width: this ran after every tab change and handed History a flat
|
||||
# 240px even while it was folded to an 18px strip, leaving ~220px of
|
||||
# dead space beside the chat on a small screen.
|
||||
strip_w = CollapseStrip.WIDTH + 2
|
||||
proj_w = 0
|
||||
if on_project:
|
||||
proj_w = strip_w if self._projects_strip.isVisible() else 260
|
||||
hist_w = 0
|
||||
if on_cowork and self._sidebar is not None:
|
||||
hist_w = strip_w if self._sidebar.is_collapsed() else 240
|
||||
if self._split.count() >= 3:
|
||||
self._split.setSizes([proj_w, hist_w, max(1, total - proj_w - hist_w)])
|
||||
else:
|
||||
@@ -341,6 +452,8 @@ class WorkspaceTab(QWidget):
|
||||
def _retranslate(self) -> None:
|
||||
self._header.setText(tr("workspace.header"))
|
||||
self._hint.setText(tr("workspace.hint"))
|
||||
self._projects_hdr.setText(tr("workspace.projects_heading").upper())
|
||||
self._folder_hdr.setText(tr("workspace.folder_label"))
|
||||
self._new_btn.setText(tr("workspace.new_project"))
|
||||
self._del_btn.setText(tr("workspace.delete"))
|
||||
self._name_lbl.setText(tr("workspace.name"))
|
||||
@@ -365,6 +478,38 @@ class WorkspaceTab(QWidget):
|
||||
self.tabs.setTabText(self._graphrag_tab_idx, tr("workspace.tab_graphrag"))
|
||||
|
||||
# ---- project list collapse (same pattern as History / GraphRAG Agent panel) --
|
||||
# Below this window width the three panes (projects 260 + history 240 +
|
||||
# the sub-page, which alone wants ~1245px on Cowork) no longer fit and Qt
|
||||
# clips them instead of shrinking. Measured with tools/check_responsive.py.
|
||||
_NARROW = 1500
|
||||
|
||||
def showEvent(self, e): # noqa: N802 - Qt override
|
||||
super().showEvent(e)
|
||||
if getattr(self, "_narrow", None) is None:
|
||||
from .widgets import narrow_guard
|
||||
self._narrow = narrow_guard(self, self._NARROW, self._apply_narrow)
|
||||
self._narrow.attach()
|
||||
|
||||
def _apply_narrow(self, narrow: bool) -> None:
|
||||
"""Fold the two side panes on a small screen so the sub-page keeps its
|
||||
width; unfold them when the window grows back.
|
||||
|
||||
Nothing becomes unreachable: both panes leave their usual collapse strip
|
||||
behind, and the project picker + RECENTS in the rail cover the same
|
||||
ground while they are folded.
|
||||
"""
|
||||
self._is_narrow = narrow
|
||||
self._set_projects_collapsed(narrow)
|
||||
if self._sidebar is not None:
|
||||
self._set_sidebar_collapsed(narrow)
|
||||
# The chat's Files pane (~300px) is the other thing that pushes Cowork
|
||||
# past the window; it has the same collapse strip to come back from.
|
||||
if self._cowork is not None and hasattr(self._cowork, "_set_io_collapsed"):
|
||||
self._cowork._set_io_collapsed(narrow)
|
||||
if not narrow:
|
||||
# Re-apply the per-tab rules the two calls above just overrode.
|
||||
self._apply_pane_visibility()
|
||||
|
||||
def _set_projects_collapsed(self, collapsed: bool) -> None:
|
||||
strip_w = CollapseStrip.WIDTH + 2
|
||||
self._projects_panel.setVisible(not collapsed)
|
||||
@@ -406,21 +551,48 @@ class WorkspaceTab(QWidget):
|
||||
from ..core.projects import list_projects
|
||||
|
||||
keep = self._current_id
|
||||
counts = self._project_counts()
|
||||
self.project_list.blockSignals(True)
|
||||
self.project_list.clear()
|
||||
row_to_select = 0
|
||||
for i, p in enumerate(list_projects()):
|
||||
item = QListWidgetItem(p.name)
|
||||
chats, tasks = counts.get(p.project_id, (0, 0))
|
||||
# No text on the item: the row widget paints the name, and setting
|
||||
# both drew it twice, one string ghosting the other.
|
||||
item = QListWidgetItem()
|
||||
item.setData(Qt.UserRole, p.project_id)
|
||||
if p.description:
|
||||
item.setToolTip(p.description)
|
||||
self.project_list.addItem(item)
|
||||
row = _ProjectRow(p.name, tr("workspace.counts", chats=chats, tasks=tasks))
|
||||
item.setSizeHint(row.sizeHint())
|
||||
self.project_list.setItemWidget(item, row)
|
||||
if p.project_id == keep:
|
||||
row_to_select = i
|
||||
self.project_list.blockSignals(False)
|
||||
self.project_list.setCurrentRow(row_to_select)
|
||||
# The drawing heads a populated screen with the title alone; the
|
||||
# explanation is what an EMPTY one says instead of showing nothing.
|
||||
self._hint.setVisible(self.project_list.count() == 0)
|
||||
self._load_current()
|
||||
|
||||
@staticmethod
|
||||
def _project_counts():
|
||||
"""{project_id: (chats, tasks)} — read once per refresh, not per row."""
|
||||
from ..core.history import list_conversations
|
||||
from ..core.tasks import list_tasks
|
||||
|
||||
out: dict = {}
|
||||
for conv in list_conversations():
|
||||
pid = conv.get("project_id") or "default"
|
||||
chats, tasks = out.get(pid, (0, 0))
|
||||
out[pid] = (chats + 1, tasks)
|
||||
for task in list_tasks():
|
||||
pid = task.get("project_id") or "default"
|
||||
chats, tasks = out.get(pid, (0, 0))
|
||||
out[pid] = (chats, tasks + 1)
|
||||
return out
|
||||
|
||||
def _selected_id(self) -> str:
|
||||
item = self.project_list.currentItem()
|
||||
return item.data(Qt.UserRole) if item else ""
|
||||
@@ -474,6 +646,94 @@ class WorkspaceTab(QWidget):
|
||||
self._bind_project(pid)
|
||||
finally:
|
||||
self._set_tabs_busy(False)
|
||||
# The rail's project picker mirrors this selection — it is a second view
|
||||
# of the same state, never a second source of truth.
|
||||
self.project_selected.emit(pid)
|
||||
|
||||
# ---- rail integration -------------------------------------------------
|
||||
def project_choices(self):
|
||||
"""(name, project_id) for the rail picker, in the list's own order.
|
||||
|
||||
Read from the store, which is what the list is built from — reading
|
||||
item.text() coupled the picker to how a row happens to be drawn, and
|
||||
when rows became widgets the picker went blank: every project showed
|
||||
as a bare folder glyph with no name.
|
||||
"""
|
||||
from ..core.projects import list_projects
|
||||
|
||||
return [(p.name, p.project_id) for p in list_projects()]
|
||||
|
||||
def selected_project_id(self) -> str:
|
||||
return self._selected_id()
|
||||
|
||||
def choose_project(self, project_id: str) -> bool:
|
||||
"""Select a project by id — the same path the list row takes."""
|
||||
return self._select_project_row(project_id)
|
||||
|
||||
def recent_threads(self, limit: int = 5):
|
||||
"""The active project's most recent conversations, newest first.
|
||||
|
||||
Scoped to the project on purpose: history is stored inside the project's
|
||||
own folder (config.history_dir() follows the selection) and the History
|
||||
pane groups by project. A flat, cross-project recents list would quietly
|
||||
drop that scoping.
|
||||
"""
|
||||
from ..core.history import list_conversations
|
||||
|
||||
pid = self._current_id
|
||||
if not pid:
|
||||
return []
|
||||
try:
|
||||
convos = list_conversations(self.ctx.config.history_dir())
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
out = []
|
||||
for meta in convos:
|
||||
if (meta.get("project_id", "") or "default") != pid:
|
||||
continue
|
||||
out.append({
|
||||
"title": meta.get("title") or tr("sidebar.empty"),
|
||||
"path": str(meta["path"]),
|
||||
"kind": meta.get("kind", "") or "cowork",
|
||||
"pinned": bool(meta.get("pinned", False)),
|
||||
"session_id": meta.get("session_id", ""),
|
||||
})
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
def open_thread(self, path: str, kind: str = "cowork") -> bool:
|
||||
"""Open a conversation by file path — the same route the History pane's
|
||||
own click takes (load_conversation → _on_sidebar_open)."""
|
||||
from ..core.history import load_conversation
|
||||
|
||||
try:
|
||||
conv = load_conversation(path)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
self._on_sidebar_open(kind, conv)
|
||||
return True
|
||||
|
||||
def show_history_pane(self) -> None:
|
||||
"""Bring the full History panel into view (un-collapsing it if needed).
|
||||
|
||||
The rail's recents list is a shortcut, not a replacement: search,
|
||||
filters, pin, rename, multi-select delete and the context menu all still
|
||||
live in this panel.
|
||||
"""
|
||||
self._history_opened = True
|
||||
self._show_cowork_tab()
|
||||
self._sidebar.setVisible(True)
|
||||
self._set_sidebar_collapsed(False)
|
||||
|
||||
def start_new_chat(self) -> None:
|
||||
"""Start a new thread in the current project and show it.
|
||||
|
||||
Exactly what the History pane's own new-chat button does
|
||||
(_on_sidebar_new); the rail button is a second door to the same room,
|
||||
not a second implementation.
|
||||
"""
|
||||
self._on_sidebar_new("cowork")
|
||||
|
||||
def _set_tabs_busy(self, busy: bool) -> None:
|
||||
for idx in (self._cowork_tab_idx, self._graphrag_tab_idx):
|
||||
|
||||
Reference in New Issue
Block a user