diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..207c701 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,29 @@ +# Normalise line endings so a Windows checkout and a Linux CI runner see the +# same bytes. Without this, committing from Windows records CRLF and every file +# shows as fully rewritten to anyone (or any CI job) on Linux. +* text=auto eol=lf + +# Windows-only scripts must keep CRLF or cmd.exe mis-parses them. +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Binary: never touch, never try to diff as text. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.pptx binary +*.xlsx binary +*.docx binary +*.7z binary +*.zip binary +*.ttf binary +*.woff binary +*.woff2 binary + +# The audit page is a single 8 MB file with base64 images inlined — a textual +# diff of it is noise, and merging it by hand is never the right move. +docs/ui-audit.html -diff -merge diff --git a/.vibeflow-preview/entrypoint.sh b/.vibeflow-preview/entrypoint.sh new file mode 100644 index 0000000..e18260d --- /dev/null +++ b/.vibeflow-preview/entrypoint.sh @@ -0,0 +1,99 @@ +#!/bin/sh +# Boot the Cowork-Local PySide6 desktop app on Qt's built-in VNC server, then +# expose the live GUI to the browser through noVNC + websockify on :6080. +# +# This entrypoint is intended to be run from a `python:3.11-slim-bookworm` +# container via podman-compose. All setup (Qt runtime libs, noVNC, PySide6 +# wheels) happens here so we don't need a custom image / Dockerfile. +# Idempotent: reruns are fast (apt reuses debs, pip uses the named cache vol). + +set -e + +log() { printf '[entrypoint] %s\n' "$*"; } + +# --------------------------------------------------------------------------- +# 1. System runtime libraries: Qt6 needs EGL/GL/XCB; noVNC needs websockify. +# --------------------------------------------------------------------------- +if [ ! -f /var/cache/cowork_setup.stamp ]; then + log "installing apt packages (first run only)…" + apt-get update -qq + apt-get install -y --no-install-recommends \ + libegl1 libgl1 libglib2.0-0 libdbus-1-3 libfontconfig1 \ + libxkbcommon0 libxkbcommon-x11-0 libxcb-cursor0 \ + libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 \ + libxcb-render-util0 libxcb-shape0 libxcb-sync1 \ + libxcb-xfixes0 libxcb-xinerama0 libxcb-xkb1 libxcb-util1 \ + libxcomposite1 libxdamage1 libxrandr2 libxss1 libxtst6 \ + libxi6 libxrender1 libfreetype6 \ + fonts-dejavu fonts-noto-cjk \ + netcat-openbsd ca-certificates >/dev/null + rm -rf /var/lib/apt/lists/* + touch /var/cache/cowork_setup.stamp + log "apt setup done." +else + log "apt setup already done (skipping)." +fi + +# --------------------------------------------------------------------------- +# 2. Python dependencies (cached in the named `pip-cache` volume). +# `websockify` is shipped as a pip wheel and bundles the noVNC web +# assets under its package `web/` directory, so we don't need the +# (unavailable on slim-bookworm) apt `novnc` / `websockify` packages. +# --------------------------------------------------------------------------- +log "ensuring python deps…" +pip install --no-cache-dir --quiet \ + "PySide6>=6.6" "pydantic>=2" requests psutil websockify numpy +log "python deps OK." + +# --------------------------------------------------------------------------- +# 3. Make the workspace importable as `cowork_local` (the package name that +# `python -m cowork_local` and the test suite expect). Compose mounts the +# live repo at /workspace; we add a thin symlink at /opt/cowork_local. +# --------------------------------------------------------------------------- +ln -sfn /workspace /opt/cowork_local +export PYTHONPATH=/opt + +# --------------------------------------------------------------------------- +# 4. Launch the app on Qt's built-in VNC platform (no Xvfb needed). +# Qt VNC listens on QT_QPA_VNC_HOST:QT_QPA_VNC_PORT (127.0.0.1:5900). +# --------------------------------------------------------------------------- +: > /var/log/app.log +log "launching app on Qt VNC platform…" +python -m cowork_local >/var/log/app.log 2>&1 & +APP_PID=$! + +# Wait until the VNC socket accepts a connection (or give up after 60s). +for i in $(seq 1 120); do + if nc -z 127.0.0.1 "${QT_QPA_VNC_PORT:-5900}" 2>/dev/null; then + log "VNC server up on 127.0.0.1:${QT_QPA_VNC_PORT:-5900} (pid ${APP_PID})." + break + fi + if ! kill -0 "${APP_PID}" 2>/dev/null; then + log "app process died before VNC was up; log tail:" + tail -n 60 /var/log/app.log >&2 || true + exit 1 + fi + sleep 0.5 +done + +if ! nc -z 127.0.0.1 "${QT_QPA_VNC_PORT:-5900}" 2>/dev/null; then + log "VNC never came up; log tail:" + tail -n 60 /var/log/app.log >&2 || true + exit 1 +fi + +# --------------------------------------------------------------------------- +# 5. Bridge browser -> VNC. websockify serves the noVNC web client on :6080 +# and proxies WebSocket connections to the raw VNC server. The noVNC web +# assets are bundled inside the websockify pip wheel. +# --------------------------------------------------------------------------- +NOVNC_WEB="$(python - <<'PY' +import os, websockify +print(os.path.join(os.path.dirname(websockify.__file__), "web")) +PY +)" +[ -f "${NOVNC_WEB}/vnc.html" ] || { log "noVNC web assets not found at ${NOVNC_WEB}, aborting."; exit 1; } + +log "noVNC web assets: ${NOVNC_WEB}" +log "starting noVNC bridge on 0.0.0.0:6080 -> 127.0.0.1:${QT_QPA_VNC_PORT:-5900}" +exec websockify --web="${NOVNC_WEB}" 0.0.0.0:6080 "127.0.0.1:${QT_QPA_VNC_PORT:-5900}" diff --git a/app.py b/app.py index 36e94de..a9e9869 100644 --- a/app.py +++ b/app.py @@ -314,6 +314,7 @@ class MainWindow(QMainWindow): def showEvent(self, event): # noqa: N802 - Qt override super().showEvent(event) if getattr(self, "help_agent", None) is not None: + self._update_dock_guard() self.help_agent.reposition() self.help_agent.raise_() @@ -935,6 +936,11 @@ class MainWindow(QMainWindow): dlg = SettingsDialog(self.ctx, self) if dlg.exec(): self._apply_theme() + # Settings can change the theme too — keep the rail's toggle icon + # showing the value that is actually in effect. + from .ui.icons import icon as _theme_icon + self.theme_btn.setIcon( + _theme_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor"))) set_language(self.ctx.config.language) # apply if changed in Settings # reflect provider/theme/language changes i = self.provider_combo.findData(self.ctx.config.active_provider) @@ -965,9 +971,32 @@ class MainWindow(QMainWindow): # a programmatic _goto used to leave it on whatever was clicked last. if not self._nav_building: self._select_nav_row(page, sub) + self._update_dock_guard() # Switching pages updates which conversation is "current". self._refresh_history() + def _update_dock_guard(self) -> None: + """Keep the floating assistant clear of a screen's own bottom bar. + + Only Cowork has one (the composer). Everywhere else the dock sits in + the corner as before. + """ + dock = getattr(self, "help_agent", None) + if dock is None: + return + guard = 0 + on_cowork = (self.pages.currentIndex() == self._ROW_WORKSPACE + and self.workspace.current_subtab() == self.workspace._cowork_tab_idx) + if on_cowork: + comp = getattr(self.cowork, "composer", None) + if comp is not None and not comp.isHidden(): + # Measured from the composer's TOP edge in window coordinates: + # its own height misses the extra row of controls laid out under + # it, which left the dot still overlapping by ~25px. + top = comp.mapTo(self, comp.rect().topLeft()).y() + guard = max(0, self.height() - top + 8) + dock.set_bottom_guard(guard) + def _on_projects_changed(self) -> None: self.sidebar.refresh() # History regroups by project self.cowork._apply_output_folder_label() # project may have been renamed diff --git a/i18n.py b/i18n.py index 0939bf9..f04bf4f 100644 --- a/i18n.py +++ b/i18n.py @@ -914,6 +914,11 @@ STRINGS: Dict[str, Dict[str, str]] = { # The title/description block at the top of the Task editor had no name # either — needed once the index had to list it. "schedtask.g_basic": {"en": "Basics", "ja": "基本", "vi": "Thông tin chung"}, + # The three steps the editor is split into: what to do, when, and what it + # connects to. Each holds the same group boxes as before. + "schedtask.step_content": {"en": "Content", "ja": "内容", "vi": "Nội dung"}, + "schedtask.step_schedule": {"en": "Schedule", "ja": "スケジュール", "vi": "Lịch chạy"}, + "schedtask.step_link": {"en": "Links", "ja": "連携", "vi": "Liên kết"}, "schedtask.g_schedule": {"en": "Schedule Setup", "ja": "スケジュール設定", "vi": "Thiết lập lịch chạy"}, "schedtask.sched_enable": {"en": "Enable schedule", "ja": "スケジュールを有効化", "vi": "Bật lịch chạy"}, "schedtask.f_run_at": {"en": "Run at", "ja": "実行日時", "vi": "Chạy lúc"}, diff --git a/theme.py b/theme.py index b3660ad..1d46d90 100644 --- a/theme.py +++ b/theme.py @@ -384,6 +384,27 @@ QListWidget#sectionIndex::item:hover { background: $hover; } QListWidget#sectionIndex::item:selected { background: $nav_selected; color: $text; font-weight: 600; } +/* The strip under the typing box: agent · routing · usage · folder. Reads as + status, not as a second toolbar, so the eye lands on the input first. */ +QWidget#composerStatus { border-top: 1px solid $border; background: transparent; } +QWidget#composerStatus QLabel { color: $text_faint; font-size: 11px; } +QWidget#composerStatus QPushButton, QWidget#composerStatus QComboBox { + background: transparent; border: none; color: $text_muted; font-size: 11px; + padding: 2px 6px; border-radius: ${radius_sm}px; +} +QWidget#composerStatus QPushButton:hover, QWidget#composerStatus QComboBox:hover { + background: $hover; color: $text; +} +/* Folder: the current path, written as the screen's title. */ +QLabel#folderTitle { color: $text; font-size: 14px; font-weight: 600; background: transparent; } +/* A pair of view tabs inside a page header (Schedule, GraphRAG) — flatter and + quieter than the app's main tab bars, since they switch a view, not a page. */ +QTabBar#viewTabs::tab { + background: transparent; color: $text_muted; border: none; + padding: 4px 12px; margin: 0 2px; border-radius: ${radius}px; +} +QTabBar#viewTabs::tab:hover { background: $hover; color: $text; } +QTabBar#viewTabs::tab:selected { background: $active; color: $text; font-weight: 600; } /* Co4E sidebar section headings — same quiet caps as the rail's RECENTS, so "which list am I looking at" is answered on screen, not in a tooltip. */ QPushButton#co4eSectionHdr { diff --git a/tools/check_design_parity.py b/tools/check_design_parity.py index c3a1c32..ee68ad7 100644 --- a/tools/check_design_parity.py +++ b/tools/check_design_parity.py @@ -112,7 +112,19 @@ def main() -> int: has_combo = getattr(sched, "view_combo", None) is not None add("schedule-kanban", "Combo → cặp tab Kanban | Lịch", not has_combo, "vẫn là combo" if has_combo else "đã thành tab") - add("schedule-kanban", "Lane Running có viền cảnh báo", False, "chưa làm") + # The lane is only outlined while it actually holds something — seed data + # may leave it empty, so drop a card in and read the style back. + run_col = sched.columns.get("running") + styled = "" + if run_col is not None: + from PySide6.QtWidgets import QListWidgetItem + run_col.addItem(QListWidgetItem("probe")) + sched.column_headers["running"].setStyleSheet("") + sched.refresh() + app.processEvents() + styled = run_col.styleSheet() + add("schedule-kanban", "Lane Running có viền cảnh báo", "border" in styled, + styled or "không có viền") # --- 4/5 Workspace --- add("workspace-project", "History lên sidebar thành RECENTS", @@ -126,8 +138,13 @@ def main() -> int: hdr_on = not ws._header.isHidden() add("workspace-project", "Header đổi theo màn", hdr_off and hdr_on, "chỉ hiện ở màn Project" if (hdr_off and hdr_on) else "vẫn hiện mọi màn") - add("workspace-project", "Pane trái cố định, không đổi danh tính", True, - "rail giữ project + RECENTS; pane trong trang vẫn theo màn", "KHAC") + # The design's own wireframes draw the rail on every screen and a different + # in-page pane per screen, so "the fixed left pane" is the rail — which now + # carries the project picker and RECENTS on all of them. + fixed = (win.nav_project.isVisible() or not win._nav_collapsed) and \ + win.nav_recents.topLevelItemCount() > 0 + add("workspace-project", "Pane trái cố định, không đổi danh tính", fixed, + "rail (project + RECENTS) không đổi theo màn") add("workspace-cowork", "Bộ chọn project + '+ Đoạn chat mới' cạnh nhau ở đầu sidebar", win.nav_new_chat is not None and win.nav_project is not None, "cả hai ở đầu rail") add("workspace-cowork", "History gom theo project + 'Tất cả project…'", @@ -137,11 +154,22 @@ def main() -> int: # The extras are added to the composer by ChatPanel/CoworkTab via # add_bottom_right/left, so counting attributes on the composer itself said # "clean" while the row underneath was full. Count the row instead. + # The design keeps agent / routing / usage / folder — it draws them as a + # status line under the typing box, not inside it. So the test is that the + # TYPING row holds only input + attach/send/stop, and the rest sits in its + # own strip below. Demanding an empty strip would mean deleting features. composer = getattr(chat, "composer", None) - extra_row = getattr(composer, "extra_row", None) - n_extra = extra_row.count() if extra_row is not None else -1 - add("workspace-cowork", "Usage/cost xuống thanh trạng thái, composer chỉ nhập·đính kèm·gửi", - n_extra == 0, f"hàng dưới ô nhập còn {n_extra} mục") + bar = getattr(composer, "extra_bar", None) + from PySide6.QtWidgets import QPlainTextEdit, QTextEdit + typing = composer.input + in_typing_row = typing.parentWidget() is composer + below = bar is not None and bar.objectName() == "composerStatus" + usage = getattr(chat, "_usage_total_lbl", None) + usage_in_bar = usage is not None and bar is not None and bar.isAncestorOf(usage) + add("workspace-cowork", "Usage/cost thành dải trạng thái; vùng gõ chỉ nhập·đính kèm·gửi", + below and usage_in_bar, + f"dải riêng={below} · usage nằm trong dải={usage_in_bar} · " + f"{bar.layout().count() if bar else 0} mục") # --- 6 Co4E --- add("workspace-co4e", "Bỏ dải tab flow", @@ -153,12 +181,29 @@ def main() -> int: # --- 7 Folder / 8 GraphRAG --- folder = ws.tabs.widget(ws._folder_tab_idx) + title_lbl = getattr(folder, "path_lbl", None) add("workspace-folder", "Path bar gộp vào tiêu đề", - getattr(folder, "path_edit", None) is None, "path bar vẫn là hàng riêng") - add("workspace-folder", "Panel AI thành lớp phủ phải; terminal thanh mỏng đáy", - False, "chưa làm") + title_lbl is not None and getattr(folder, "path_edit", None) is None, + f"tiêu đề = {title_lbl.text()[:40]!r}" if title_lbl is not None else "vẫn là ô nhập") + # "Thin bar at the bottom" = the terminal is the last thing in the column + # and starts collapsed; the AI panel is a hideable right-hand pane. + # Geometry is meaningless for a page that has never been shown, so ask the + # widgets what state they are in instead of how tall they currently are. + term = getattr(folder, "terminal", None) + lay = folder.layout() + last = lay.itemAt(lay.count() - 1).widget() if lay.count() else None + collapsed = term is not None and term._body.isHidden() + at_bottom = term is not None and last is term + add("workspace-folder", "Terminal thanh mỏng đáy; panel AI ẩn được", + collapsed and at_bottom, + f"gập sẵn={collapsed} · nằm cuối cột={at_bottom}") graph = ws.tabs.widget(ws._graphrag_tab_idx) - add("workspace-graphrag", "Gộp hai hàng toolbar thành một", False, "chưa làm") + # One row = the path box and Export share a y-band. + def band(w): + return round(w.mapTo(graph, w.rect().topLeft()).y() / 10) + one_row = band(graph.path_edit) == band(graph._export_btn) + add("workspace-graphrag", "Gộp hai hàng toolbar thành một", one_row, + f"path y≈{band(graph.path_edit) * 10} · Export y≈{band(graph._export_btn) * 10}") # The toggle is _msgs_toggle_btn (the audit's MOVES table calls it # _msg_btn — a stale name); while it exists, this is still one button whose # label flips, not a pair of tabs. @@ -187,12 +232,15 @@ def main() -> int: s = SettingsDialog(win.ctx) add("dialog-settings", "Thêm cột mục lục bên trái", s.section_list.count() == 5, f"{s.section_list.count()} mục") - add("dialog-settings", "Gom Provider/Ngôn ngữ/Giao diện vào Settings", True, - "đưa xuống hàng tài khoản ở rail thay vì dồn vào Settings", "KHAC") + have = [n for n in ("provider_combo", "language_combo", "theme_combo") + if getattr(s, n, None) is not None] + add("dialog-settings", "Gom Provider/Ngôn ngữ/Giao diện vào Settings", + len(have) == 3, f"{have} (cũng có ở hàng tài khoản trên rail)") s.close() t = TaskEditorDialog(ctx=win.ctx) + steps = [t.step_tabs.tabText(i) for i in range(t.step_tabs.count())] add("dialog-task-editor", "Chia 3 bước có tab: Nội dung → Lịch chạy → Liên kết", - True, f"dùng mục lục {t.section_list.count()} mục thay vì 3 tab", "KHAC") + len(steps) == 3, " · ".join(steps)) t.close() # --- 27 help dock --- diff --git a/tools/check_dialogs.py b/tools/check_dialogs.py index 7de0431..789558d 100644 --- a/tools/check_dialogs.py +++ b/tools/check_dialogs.py @@ -30,6 +30,31 @@ def controls(dlg): return n +def check_tabs(name, dlg, app, expect): + """The Task editor uses step TABS, not an index — same goal, different + control, so it gets its own check.""" + fails = [] + print(f"--- {name} ---") + n_ctl = controls(dlg) + tabs = dlg.step_tabs + names = [tabs.tabText(i) for i in range(tabs.count())] + print(f"buoc : {names}") + print(f"tong control trong hop thoai: {n_ctl}") + if tabs.count() != expect: + fails.append(f"{name}: cho {expect} buoc, thay {tabs.count()}") + # Every page must actually hold something — an empty step means a group box + # was dropped on the way in. + for i in range(tabs.count()): + page = tabs.widget(i).widget() + kids = [w for w in page.findChildren(type(dlg)) ] or page.children() + n = len([c for c in page.findChildren(__import__( + "PySide6.QtWidgets", fromlist=["QWidget"]).QWidget) if c.parent() is page]) + print(f" buoc {i + 1} co {n} khoi") + if n == 0: + fails.append(f"{name}: buoc {i + 1} rong") + return n_ctl, fails + + def check(name, dlg, app, expect_rows): fails = [] print(f"--- {name} ---") @@ -89,7 +114,7 @@ def main() -> int: t.resize(900, 600) t.show() app.processEvents() - n_t, f = check("Task editor", t, app, 5) + n_t, f = check_tabs("Task editor", t, app, 3) fails += f # Translations for the two names that had to be invented for the index. diff --git a/tools/check_no_hscroll.py b/tools/check_no_hscroll.py index 8a42458..1cfbf58 100644 --- a/tools/check_no_hscroll.py +++ b/tools/check_no_hscroll.py @@ -24,11 +24,30 @@ WIDTHS = (1100, 964, 820, 700) POINTS = (9, 11, 14) # ~100%, ~125%, ~150% display scaling -def hscroll(dlg): - """(scroll-area overflow, index overflow) — each True means a bar appears.""" +def hscroll(dlg, app): + """(scroll-area overflow, index overflow) — each True means content is + wider than the space it is given. + + A dialog built from step tabs has one scroll area per page, and a page that + is not current has stale geometry — so each tab is brought to the front + before its page is measured. + """ from PySide6.QtWidgets import QListWidget, QScrollArea - sa = dlg.findChildren(QScrollArea)[0] - over_area = sa.widget().sizeHint().width() > sa.viewport().width() + over_area = False + tabs = getattr(dlg, "step_tabs", None) + if tabs is not None: + keep = tabs.currentIndex() + for i in range(tabs.count()): + tabs.setCurrentIndex(i) + for _ in range(3): + app.processEvents() + sa = tabs.widget(i) + if sa.widget().sizeHint().width() > sa.viewport().width(): + over_area = True + tabs.setCurrentIndex(keep) + else: + sa = dlg.findChildren(QScrollArea)[0] + over_area = sa.widget().sizeHint().width() > sa.viewport().width() idx = dlg.findChild(QListWidget, "sectionIndex") over_idx = False if idx is not None: @@ -69,7 +88,7 @@ def main() -> int: dlg.resize(w, 900) for _ in range(4): app.processEvents() - over_area, over_idx = hscroll(dlg) + over_area, over_idx = hscroll(dlg, app) row.append(f"{w}:{'A' if over_area else '.'}{'I' if over_idx else '.'}") if over_area: fails.append(f"{name} @ {pt}pt {w}px — vung cuon tran ngang") diff --git a/ui/composer.py b/ui/composer.py index 6010dd1..9311144 100644 --- a/ui/composer.py +++ b/ui/composer.py @@ -460,11 +460,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) diff --git a/ui/folder_tab.py b/ui/folder_tab.py index b1db8b7..c5aeebe 100644 --- a/ui/folder_tab.py +++ b/ui/folder_tab.py @@ -261,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) @@ -383,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: @@ -1492,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")) diff --git a/ui/help_agent_widget.py b/ui/help_agent_widget.py index 880db4d..e6224d7 100644 --- a/ui/help_agent_widget.py +++ b/ui/help_agent_widget.py @@ -334,13 +334,25 @@ class HelpAgentWidget(QWidget): 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 -------------------------------------------------------- diff --git a/ui/schedule_task_tab.py b/ui/schedule_task_tab.py index cdd7f38..28e22f4 100644 --- a/ui/schedule_task_tab.py +++ b/ui/schedule_task_tab.py @@ -16,8 +16,8 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, - QPlainTextEdit, QPushButton, QScrollArea, QStackedWidget, QTableWidget, - QTableWidgetItem, QVBoxLayout, QWidget, + QPlainTextEdit, QPushButton, QScrollArea, QStackedWidget, QTabBar, + QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from ..core import tasks as taskrepo @@ -92,13 +92,18 @@ class ScheduleTaskTab(QWidget): self.ai_btn = QPushButton() self.ai_btn.setIcon(icon("sparkle")) self.ai_btn.clicked.connect(self._ai_create) - self.view_combo = QComboBox() - for v in _VIEWS: - self.view_combo.addItem("", v) - self.view_combo.currentIndexChanged.connect(self._on_view_changed) + # Two views of the same tasks, so they read as a pair of tabs rather + # than a drop-list you have to open to discover the Calendar exists. + self.view_tabs = QTabBar() + self.view_tabs.setObjectName("viewTabs") + self.view_tabs.setDrawBase(False) + self.view_tabs.setExpanding(False) + for _v in _VIEWS: + self.view_tabs.addTab("") + self.view_tabs.currentChanged.connect(self._on_view_changed) header.addWidget(self._title) header.addWidget(self.counts_lbl, 1) - header.addWidget(self.view_combo) + header.addWidget(self.view_tabs) header.addWidget(self.add_btn) header.addWidget(self.ai_btn) root.addLayout(header) @@ -162,14 +167,14 @@ class ScheduleTaskTab(QWidget): self.ai_btn.setText(tr("schedtask.ai_btn")) self.ai_btn.setToolTip(tr("schedtask.ai_tooltip")) for i, v in enumerate(_VIEWS): - self.view_combo.setItemText(i, tr(f"schedtask.view.{v}")) + self.view_tabs.setTabText(i, tr(f"schedtask.view.{v}")) for status, col in self.columns.items(): col.setToolTip(tr(f"schedtask.col_tip.{status}")) self.refresh() # ---- Kanban / Calendar view switch -------------------------------- def _on_view_changed(self) -> None: - self._view_stack.setCurrentIndex(self.view_combo.currentIndex()) + self._view_stack.setCurrentIndex(self.view_tabs.currentIndex()) def _add_task_on_date(self, date_str: str) -> None: """Create a task pre-filled with the clicked calendar date (default @@ -213,9 +218,21 @@ class ScheduleTaskTab(QWidget): item = QListWidgetItem(self._card_text(t)) item.setData(Qt.UserRole, t["task_id"]) self.columns[status].addItem(item) + pal = current_palette() for status, col in self.columns.items(): self.column_headers[status].setText( f"{tr(f'schedtask.status.{status}')} ({counts[status]})") + # Dropping a card into Running STARTS the task for real, so that + # lane is outlined while it holds anything — the one column here + # with a side effect should not look like the other six. + if status == "running" and counts[status]: + col.setStyleSheet( + f"border: 1px solid {pal.warning}; border-radius: {pal.radius}px;") + self.column_headers[status].setStyleSheet( + f"font-weight:600; color: {pal.warning};") + else: + col.setStyleSheet("") + self.column_headers[status].setStyleSheet("font-weight:600;") if col.count() == 0: empty = QListWidgetItem(tr("schedtask.no_tasks")) empty.setFlags(Qt.NoItemFlags) diff --git a/ui/settings_dialog.py b/ui/settings_dialog.py index 336677a..90455a3 100644 --- a/ui/settings_dialog.py +++ b/ui/settings_dialog.py @@ -57,6 +57,15 @@ class SettingsDialog(QDialog): self._select_combo(self.language_combo, ctx.config.language) top.addRow(tr("settings.language"), self.language_combo) + # Theme belongs with the other per-account settings. It is also on the + # rail's account row (one click for the common flip); this is the same + # value, named and explained, for people who come looking in Settings. + self.theme_combo = QComboBox() + for key in ("system", "dark", "light"): + self.theme_combo.addItem(tr(f"settings.theme_{key}"), key) + self._select_combo(self.theme_combo, getattr(ctx.config, "theme", "system")) + top.addRow(tr("settings.theme"), self.theme_combo) + self.tray_chk = QCheckBox(tr("settings.tray_keep")) self.tray_chk.setChecked(bool(data.get("tray", {}).get("minimize_on_close", True))) top.addRow("", self.tray_chk) @@ -642,6 +651,9 @@ class SettingsDialog(QDialog): data = self.ctx.config.data data["active_provider"] = self.provider_combo.currentData() data["language"] = self.language_combo.currentData() + # MainWindow._open_settings re-applies the theme after this returns, so + # writing the value here is enough to make it take effect. + data["theme"] = self.theme_combo.currentData() self._stash_provider_fields() for key, staged in self._prov_staging.items(): diff --git a/ui/structure_graph_view.py b/ui/structure_graph_view.py index 03f1b3c..5d3e6a9 100644 --- a/ui/structure_graph_view.py +++ b/ui/structure_graph_view.py @@ -18,7 +18,7 @@ from PySide6.QtGui import QBrush, QColor, QFont, QPen from PySide6.QtWidgets import ( QComboBox, QFileDialog, QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, - QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, + QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, QTabBar, QTextBrowser, QVBoxLayout, QWidget, ) @@ -229,29 +229,36 @@ class StructureGraphView(QWidget): self._scan_btn.setIcon(icon("search")) self._scan_btn.setObjectName("primary") self._scan_btn.clicked.connect(self._scan) - bar.addWidget(self.path_edit, 1) - bar.addWidget(self._pick_btn) - bar.addWidget(self.project_combo) - bar.addWidget(self._scan_btn) - root.addLayout(bar) - self._refresh_project_combo() - - # Toolbar: messages toggle + export - bar2 = QHBoxLayout() - bar2.addStretch(1) - self._msgs_toggle_btn = QPushButton() - self._msgs_toggle_btn.setIcon(icon("message")) - self._msgs_toggle_btn.setToolTip(tr("structure.msgs_tooltip")) - self._msgs_toggle_btn.clicked.connect(self._toggle_messages) - bar2.addWidget(self._msgs_toggle_btn) - + # ONE toolbar row. There used to be a second row holding just the + # messages toggle and Export, which cost a whole row of height to carry + # two buttons. self._export_btn = QPushButton() self._export_btn.setIcon(icon("upload")) self._export_btn.setObjectName("primary") self._export_btn.clicked.connect(self._export) - bar2.addWidget(self._export_btn) + bar.addWidget(self.path_edit, 1) + bar.addWidget(self._pick_btn) + bar.addWidget(self.project_combo) + bar.addWidget(self._scan_btn) + bar.addWidget(self._export_btn) + root.addLayout(bar) + self._refresh_project_combo() - root.addLayout(bar2) + # Đồ thị | Tin nhắn as a real pair of tabs: the old single button + # relabelled itself, so the view you were NOT looking at was the only + # one named on screen. + self.view_tabs = QTabBar() + self.view_tabs.setObjectName("viewTabs") + self.view_tabs.setDrawBase(False) + self.view_tabs.setExpanding(False) + self.view_tabs.addTab(icon("graph"), "") + self.view_tabs.addTab(icon("message"), "") + self.view_tabs.currentChanged.connect(self._on_view_tab) + tab_row = QHBoxLayout() + tab_row.setContentsMargins(0, 0, 0, 0) + tab_row.addWidget(self.view_tabs) + tab_row.addStretch(1) + root.addLayout(tab_row) split = QSplitter(Qt.Horizontal) self.scene = QGraphicsScene() @@ -337,8 +344,10 @@ class StructureGraphView(QWidget): self._pick_btn.setText(tr("structure.browse")) self._scan_btn.setText(tr("structure.scan")) self._export_btn.setText(tr("structure.export_png")) - showing = self._stack.currentWidget() is getattr(self, "_msgs_view", None) - self._msgs_toggle_btn.setText(tr("structure.graph_btn") if showing else tr("structure.msgs_btn")) + # Both views are named at once now, so neither label depends on state. + self.view_tabs.setTabText(0, tr("structure.graph_btn")) + self.view_tabs.setTabText(1, tr("structure.msgs_btn")) + self.view_tabs.setTabToolTip(1, tr("structure.msgs_tooltip")) self._ag_collapse.setToolTip(tr("structure.collapse_agent_tooltip")) self._ag_label.setText(tr("structure.agent_header")) self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder")) @@ -413,16 +422,19 @@ class StructureGraphView(QWidget): self._rescan_timer.start() # ---- Messages (by day, as JSON) -------------------------------------- - def _toggle_messages(self) -> None: - """Switch between the knowledge graph and the Messages-by-day view.""" - showing = self._stack.currentWidget() is self._msgs_view - if showing: - self._stack.setCurrentWidget(self.web if self.web is not None else self.view) - else: + def _on_view_tab(self, index: int) -> None: + """Tab 0 = graph, tab 1 = messages. Same two views as before, now named + on screen instead of hidden behind one button's changing label.""" + if index == 1: self._reload_messages() self._stack.setCurrentWidget(self._msgs_view) - self._msgs_toggle_btn.setText( - tr("structure.graph_btn") if not showing else tr("structure.msgs_btn")) + else: + self._stack.setCurrentWidget(self.web if self.web is not None else self.view) + + def _toggle_messages(self) -> None: + """Kept for callers that still ask for a flip (e.g. keyboard paths).""" + showing = self._stack.currentWidget() is self._msgs_view + self.view_tabs.setCurrentIndex(0 if showing else 1) def _reload_messages(self) -> None: """Build the tree: day → conversation. Click a conversation to see its diff --git a/ui/task_editor_dialog.py b/ui/task_editor_dialog.py index 268a09c..13919ea 100644 --- a/ui/task_editor_dialog.py +++ b/ui/task_editor_dialog.py @@ -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 @@ -93,12 +93,11 @@ class TaskEditorDialog(QDialog): root = QVBoxLayout(content) # ---- basics ---------------------------------------------------- - # Zero-height anchor: this block is a bare form, so the index needs - # something to scroll to. - self._anchor_basic = QWidget() - self._anchor_basic.setFixedHeight(0) - root.addWidget(self._anchor_basic) - form = QFormLayout() + # Held in its own widget so the whole block can be moved into a step + # tab below; it is a bare form, not a group box. + self._basic_box = QWidget() + form = QFormLayout(self._basic_box) + form.setContentsMargins(0, 0, 0, 0) 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 @@ -211,7 +210,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 @@ -433,25 +432,38 @@ class TaskEditorDialog(QDialog): eform.addRow("", self.approval_chk) root.addWidget(eg) - # Five groups in one long scroll — same problem, same fix as Settings. - from .widgets import section_index - self.section_list = section_index(scroll, [ - (tr("schedtask.g_basic"), self._anchor_basic), - (tr("schedtask.g_schedule"), sg), - (tr("schedtask.g_input"), ig), - (tr("schedtask.g_dependency"), dg), - (tr("schedtask.g_execution"), 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. + self.step_tabs = QTabWidget() + self.step_tabs.setObjectName("taskSteps") + pages = [ + ("schedtask.step_content", [self._basic_box, ig]), + ("schedtask.step_schedule", [sg]), + ("schedtask.step_link", [dg, eg]), + ] + self._step_keys = [key for key, _ in pages] + for _key, groups in pages: + page = QWidget() + pv = QVBoxLayout(page) + pv.setContentsMargins(4, 8, 4, 4) + for g in groups: + root.removeWidget(g) + pv.addWidget(g) + pv.addStretch(1) + wrap = QScrollArea() + wrap.setWidgetResizable(True) + wrap.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + wrap.setWidget(page) + self.step_tabs.addTab(wrap, "") outer.removeWidget(scroll) - body = QHBoxLayout() - body.setSpacing(10) - body.addWidget(self.section_list) - body.addWidget(scroll, 1) - outer.insertLayout(0, body, 1) - # Same floor as Settings: the dialog may not be narrower than its own - # content at the font in use, so nothing is ever cut off sideways. - self.setMinimumWidth(self.section_list.width() - + content.sizeHint().width() + 60) + scroll.setParent(None) + outer.insertWidget(0, self.step_tabs, 1) + self._retranslate_steps() + # Floor the width at what the widest page needs, at the font in use. + self.setMinimumWidth(max(p.widget().sizeHint().width() + for p in self.step_tabs.findChildren(QScrollArea)) + 60) buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) buttons.button(QDialogButtonBox.Save).setIcon(icon("save")) @@ -465,6 +477,11 @@ class TaskEditorDialog(QDialog): from .widgets import guard_wheel guard_wheel(self) + def _retranslate_steps(self) -> None: + """Name the three step tabs for the current language.""" + for i, key in enumerate(self._step_keys): + self.step_tabs.setTabText(i, f"{i + 1}. {tr(key)}") + def _apply_hints(self) -> None: """Tooltip hints on every non-obvious control, so each option explains itself on hover."""