From 3bf418a8c637822297dae4376c39fd1bfadfe99e Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 26 Sep 2026 00:19:32 -0700 Subject: [PATCH 1/6] feat(desktop): integrate the title bar into the sidebar top strip On macOS the main window gets TitleBarStyle::Overlay + hidden_title with the traffic lights pinned at a fixed logical position, so the webview draws to the top of the window. The dashboard's new sidebar top strip reserves the lights' inset, carries a Codex-like collapse toggle that folds the sidebar to a rail (persisted, Cmd/Ctrl+B), and the provider usage strip moves up to share the title-bar row. Both strips move the window on drag and zoom on double-click through plugin:window commands granted per origin; the bundled bootstrap and update pages draw their own matching strip. Windows and Linux keep native decorations. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../capabilities/dashboard-titlebar.json | 13 ++ desktop/src-tauri/capabilities/default.json | 2 + desktop/src-tauri/src/lib.rs | 20 ++- desktop/src-tauri/src/window.rs | 37 +++++ desktop/ui/index.html | 25 ++++ desktop/ui/update.html | 20 +++ gui/src/App.tsx | 27 +++- gui/src/components/app-titlebar.css | 85 +++++++++++ gui/src/components/app-titlebar.tsx | 42 ++++++ gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/vi.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/icons.tsx | 1 + gui/src/lib/window-chrome.ts | 61 ++++++++ gui/src/use-sidebar-collapse.ts | 55 ++++++++ gui/tests/app-titlebar.test.tsx | 133 ++++++++++++++++++ structure/desktop-shell.md | 12 ++ 24 files changed, 543 insertions(+), 10 deletions(-) create mode 100644 desktop/src-tauri/capabilities/dashboard-titlebar.json create mode 100644 gui/src/components/app-titlebar.css create mode 100644 gui/src/components/app-titlebar.tsx create mode 100644 gui/src/lib/window-chrome.ts create mode 100644 gui/src/use-sidebar-collapse.ts create mode 100644 gui/tests/app-titlebar.test.tsx diff --git a/desktop/src-tauri/capabilities/dashboard-titlebar.json b/desktop/src-tauri/capabilities/dashboard-titlebar.json new file mode 100644 index 00000000000..1f649eed08e --- /dev/null +++ b/desktop/src-tauri/capabilities/dashboard-titlebar.json @@ -0,0 +1,13 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "dashboard-titlebar", + "description": "Integrated title bar: the loopback dashboard's top strips drag and double-click-zoom the main window", + "windows": ["main"], + "remote": { + "urls": ["http://127.0.0.1:*"] + }, + "permissions": [ + "core:window:allow-start-dragging", + "core:window:allow-toggle-maximize" + ] +} diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 622143b1fa4..cb83863e407 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -8,6 +8,8 @@ "core:window:allow-show", "core:window:allow-hide", "core:window:allow-set-title", + "core:window:allow-start-dragging", + "core:window:allow-toggle-maximize", "opener:default", "autostart:default" ] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 90955564f19..f96fdde6d47 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -285,7 +285,7 @@ pub fn run() { // or started, so every state below has somewhere to be reported. A login launch stays // hidden until the tray verdict, because R1 shows it after all when there turns out to // be nowhere to hide. - let window = + let builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into())) .title("OpenCodex") .inner_size(1100.0, 720.0) @@ -308,8 +308,22 @@ pub fn run() { window.is_visible().unwrap_or(false), ); } - }) - .build()?; + }); + // The integrated title bar: macOS keeps its traffic lights but draws them over the + // webview, so the dashboard's sidebar top strip reserves the space they land in + // (`app-titlebar.css` keeps it aligned with this position) and the strips move or + // zoom the window through `plugin:window` commands granted by + // `capabilities/dashboard-titlebar.json`. Windows and Linux keep the native title + // bar — the shell ships no min/max/close widgets of its own — while the sidebar-top + // layout applies unchanged. + #[cfg(target_os = "macos")] + let builder = builder + .title_bar_style(tauri::TitleBarStyle::Overlay) + .hidden_title(true) + .traffic_light_position(tauri::Position::Logical(tauri::LogicalPosition::new( + 18.0, 16.0, + ))); + let window = builder.build()?; window::configure(&window); if startup::LaunchOrigin::detect() == startup::LaunchOrigin::User { window::show(&window); diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index f53cf068261..6e03e0c3fcd 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -256,4 +256,41 @@ mod tests { .expect("default capability is JSON"); assert!(default.get("remote").is_none()); } + + /// The overlay title bar is moved and zoomed from the page: the dashboard's top strips call + /// `plugin:window|start_dragging` and `plugin:window|toggle_maximize` from the loopback + /// origin, and the bundled pages do the same from the app origin. The grants are pinned to + /// the `main` window and to exactly the two commands each origin needs. + #[test] + fn the_titlebar_commands_are_granted_on_each_origin() { + let titlebar: serde_json::Value = + serde_json::from_str(include_str!("../capabilities/dashboard-titlebar.json")) + .expect("dashboard-titlebar capability is JSON"); + assert_eq!(titlebar["windows"], serde_json::json!(["main"])); + assert_eq!( + titlebar["remote"]["urls"], + serde_json::json!(["http://127.0.0.1:*"]) + ); + assert_eq!( + titlebar["permissions"], + serde_json::json!([ + "core:window:allow-start-dragging", + "core:window:allow-toggle-maximize" + ]) + ); + + let default: serde_json::Value = + serde_json::from_str(include_str!("../capabilities/default.json")) + .expect("default capability is JSON"); + let permissions = default["permissions"].as_array().expect("permissions list"); + for permission in [ + "core:window:allow-start-dragging", + "core:window:allow-toggle-maximize", + ] { + assert!( + permissions.contains(&serde_json::json!(permission)), + "default capability grants {permission}" + ); + } + } } diff --git a/desktop/ui/index.html b/desktop/ui/index.html index ba46c917900..c8a33696f88 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -87,9 +87,17 @@ .spinner, #phases li[data-state="active"] .mark::before { animation: breathe 1.6s ease-in-out infinite; } .progress-bar { transition: none; } } + /* + macOS draws its traffic lights over the webview (overlay title bar), so the window + stays movable only if the page provides the grip. This strip is that grip; the + script unhides it on macOS only — every other platform keeps native decorations. + */ + #titlebar { position: fixed; top: 0; left: 0; right: 0; height: 44px; z-index: 1; user-select: none; -webkit-user-select: none; } + #titlebar[hidden] { display: none; } +
@@ -195,6 +203,7 @@

OpenCodex

const takeOver = document.querySelector("#takeOver"); const stayGuest = document.querySelector("#stayGuest"); const surface = document.querySelector("main"); +const titlebar = document.querySelector("#titlebar"); const statusMark = document.querySelector("#statusMark"); const progressBar = document.querySelector("#progressBar"); const stepCount = document.querySelector("#stepCount"); @@ -346,6 +355,22 @@

OpenCodex

} } +// The strip exists so the overlay title bar keeps the window movable on macOS. Other +// platforms keep native decorations, so it stays hidden there. +if (invoke && /Mac OS X|Macintosh/.test(navigator.userAgent)) { + titlebar.hidden = false; + titlebar.addEventListener("mousedown", (event) => { + if (event.button === 0 && event.target === titlebar) { + invoke("plugin:window|start_dragging").catch(() => {}); + } + }); + titlebar.addEventListener("dblclick", (event) => { + if (event.target === titlebar) { + invoke("plugin:window|toggle_maximize").catch(() => {}); + } + }); +} + start(); diff --git a/desktop/ui/update.html b/desktop/ui/update.html index 8d7ae61cc5e..dfaf50137dc 100644 --- a/desktop/ui/update.html +++ b/desktop/ui/update.html @@ -24,9 +24,13 @@ #error { color: #ff8a80; } button.secondary { background: #3a3a3c; color: #f5f5f7; } } + /* Overlay title bar grip on macOS; hidden everywhere else (native decorations). */ + #titlebar { position: fixed; top: 0; left: 0; right: 0; height: 44px; z-index: 1; user-select: none; -webkit-user-select: none; } + #titlebar[hidden] { display: none; } +

OpenCodex update

Reading update status…

@@ -39,6 +43,7 @@

OpenCodex update

diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 7e6b055eff2..812351aab33 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -28,7 +28,9 @@ import { useAppRouteState } from "./use-app-route-state"; import { requestProxyStop } from "./stop-proxy"; import { useCodexRestart } from "./use-codex-restart"; import { confirmAction } from "./action-dialogs"; -import { isDesktopShell, isExternalLink, openDesktopUpdatePage } from "./lib/desktop-shell"; +import { hostOs, isDesktopShell, isExternalLink, openDesktopUpdatePage } from "./lib/desktop-shell"; +import { useSidebarCollapse } from "./use-sidebar-collapse"; +import { MainTopStrip, SidebarTopStrip } from "./components/app-titlebar"; type Theme = "light" | "dark" | "system"; @@ -203,6 +205,10 @@ export default function App() { // Narrow screens: the sidebar becomes an off-canvas drawer behind a hamburger toggle. const [navOpen, setNavOpen] = useState(false); + // Codex-style rail collapse on wide screens, persisted; Cmd/Ctrl+B toggles too. + const { collapsed: navCollapsed, toggle: toggleNavCollapse } = useSidebarCollapse(); + const desktopShell = isDesktopShell(); + const desktopMac = desktopShell && hostOs() === "macos"; const menuBtnRef = useRef(null); const sidebarRef = useRef(null); const navWasOpen = useRef(false); @@ -368,8 +374,14 @@ export default function App() { ); + const quotaSummary = targetsSettled && page !== "startup" && (!targets.connected || sharedSessionReady) && ( + + + + ); + return ( -
+
{actionFeedback && ( setActionFeedback(null)} dismissLabel={t("common.close")}> @@ -404,6 +416,7 @@ export default function App() { {navOpen &&
setNavOpen(false)} aria-hidden="true" />}
- {targetsSettled && page !== "startup" && (!targets.connected || sharedSessionReady) && ( - - - - )} + {/* Inside the desktop shell the strip is the integrated title bar's right half — + draggable, at the very top of the window, level with the traffic lights — so it + exists even while the bar inside it does not. The browser dashboard keeps the + bar as it was: no strip, no reserved row. */} + {desktopShell ? {quotaSummary} : quotaSummary} {/* Combos is full-bleed, unlike every other surface, and it is reachable only as a Models tab. `.main-inner` is App's element, so App is the only place that diff --git a/gui/src/components/app-titlebar.css b/gui/src/components/app-titlebar.css new file mode 100644 index 00000000000..b128fd02f82 --- /dev/null +++ b/gui/src/components/app-titlebar.css @@ -0,0 +1,85 @@ +/* + Integrated title bar (Codex-style): the sidebar's top strip holds the window's traffic + lights and the collapse toggle; the main column's top strip carries the quota summary + on the same row. Desktop shell only moves the chrome — the strips render everywhere so + the collapse toggle exists in the plain browser dashboard too, where its drag wiring + is a no-op. + + Sizes this file owns: + --titlebar-h strip height; the shell parks the traffic lights at y=16 inside it + --tl-inset macOS-only gap the lights occupy: 18px lead + 52px of lights + 10px air + --rail-w collapsed sidebar width +*/ + +.app { --titlebar-h: 44px; --tl-inset: 80px; --rail-w: 52px; } + +.sidebar-top { + display: flex; + align-items: center; + height: var(--titlebar-h, 44px); + flex: 0 0 auto; + /* Bleed over the sidebar's own padding so the strip is edge-to-edge draggable. */ + margin: -18px -14px 0; + padding: 0 10px 0 14px; + user-select: none; + -webkit-user-select: none; +} + +.app--macos .sidebar-top { + padding-left: var(--tl-inset, 80px); +} + +.sidebar-collapse { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + flex: 0 0 auto; + padding: 0; + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--muted); + cursor: pointer; + transition: background var(--motion-fast), color var(--motion-fast); +} +.sidebar-collapse:hover { background: var(--accent-soft); color: var(--text); } +.sidebar-collapse:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; } +.sidebar-collapse svg { width: 18px; height: 18px; } + +.main-top { + position: sticky; + top: 0; + z-index: var(--z-sticky); + display: flex; + align-items: stretch; + min-height: var(--titlebar-h, 44px); + background: var(--bg); + border-bottom: 1px solid var(--border); + user-select: none; + -webkit-user-select: none; +} +/* The quota bar fills the strip; its own border rides inside the strip's. */ +.main-top > .quota-summary-bar { + flex: 1 1 auto; + min-width: 0; + position: static; + border-bottom: none; +} + +/* Collapsed rail: only the top strip survives — lights + toggle, exactly like Codex. */ +@media (min-width: 761px) { + .app { transition: grid-template-columns 160ms ease; } + /* Double class beats the base `.app` columns regardless of stylesheet order. */ + .app.app--nav-collapsed { grid-template-columns: var(--rail-w) 1fr; } + .app--nav-collapsed .sidebar > :not(.sidebar-top) { display: none; } + .app--nav-collapsed .sidebar-top { padding-left: 12px; } + .app--macos.app--nav-collapsed { --rail-w: 122px; } + .app--macos.app--nav-collapsed .sidebar-top { padding-left: var(--tl-inset, 80px); } +} + +/* The mobile drawer keeps its own chrome; the strip would duplicate the drawer head. */ +@media (max-width: 760px) { + .sidebar-top { display: none; } +} diff --git a/gui/src/components/app-titlebar.tsx b/gui/src/components/app-titlebar.tsx new file mode 100644 index 00000000000..ac5771c8b29 --- /dev/null +++ b/gui/src/components/app-titlebar.tsx @@ -0,0 +1,42 @@ +/** + * The integrated title bar's two strips, laid out the way the Codex desktop app is: + * traffic lights live in the sidebar's top strip next to a collapse toggle, and the + * main column's top strip carries the quota summary on the same row. + * + * `SidebarTopStrip` is the sidebar's first child and the only child that survives a + * collapse; `MainTopStrip` wraps whatever sits at the top of `.main`. Both spread the + * window-chrome handlers, which no-op outside the desktop shell. + */ +import type { ReactNode } from "react"; +import { IconPanelLeft } from "../icons"; +import { useT } from "../i18n/shared"; +import { windowChromeHandlers } from "../lib/window-chrome"; +import "./app-titlebar.css"; + +export function SidebarTopStrip({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) { + const t = useT(); + const label = collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar"); + return ( +
+ +
+ ); +} + +export function MainTopStrip({ children }: { children?: ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 627d1cf186d..2be9e6b24f8 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1359,6 +1359,8 @@ export const de: Record = { "nav.openMenu": "Menü öffnen", "nav.closeMenu": "Menü schließen", "nav.goHome": "Zum Dashboard", + "nav.collapseSidebar": "Seitenleiste einklappen", + "nav.expandSidebar": "Seitenleiste ausklappen", "pws.refreshAllQuotas": "Alle Kontingente aktualisieren", "pws.quotaRefreshDone": "Kontingentprüfung abgeschlossen", "integrations.subtitle": "Clients mit opencodex verbinden, Zugangsdaten verwalten und Client-Konfigurationen wiederherstellen.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 572ce773d93..6e614cd9d45 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1906,6 +1906,8 @@ export const en = { "nav.openMenu": "Open menu", "nav.closeMenu": "Close menu", "nav.goHome": "Go to dashboard", + "nav.collapseSidebar": "Collapse sidebar", + "nav.expandSidebar": "Expand sidebar", "pws.refreshAllQuotas": "Refresh all quotas", "pws.quotaRefreshDone": "Quota check complete", "integrations.subtitle": "Connect clients to opencodex, manage credentials, and restore client configuration.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 49eb914b879..8b1012a1797 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1879,6 +1879,8 @@ export const fr: Record = { "nav.openMenu": "Ouvrir le menu", "nav.closeMenu": "Fermer le menu", "nav.goHome": "Aller au tableau de bord", + "nav.collapseSidebar": "Replier la barre latérale", + "nav.expandSidebar": "Déplier la barre latérale", "pws.refreshAllQuotas": "Actualiser tous les quotas", "pws.quotaRefreshDone": "Vérification des quotas terminée", "integrations.subtitle": "Connectez des clients à opencodex, gérez les identifiants et restaurez la configuration des clients.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 2260207f58d..34df0413060 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1802,6 +1802,8 @@ export const ja: Record = { "nav.openMenu": "メニューを開く", "nav.closeMenu": "メニューを閉じる", "nav.goHome": "ダッシュボードへ移動", + "nav.collapseSidebar": "サイドバーを折りたたむ", + "nav.expandSidebar": "サイドバーを展開", "pws.refreshAllQuotas": "すべてのクォータを更新", "pws.quotaRefreshDone": "クォータの確認が完了しました", "integrations.subtitle": "クライアントを opencodex に接続し、認証情報の管理とクライアント設定の復元を行います。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 958721d62f6..40d09ec0115 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1395,6 +1395,8 @@ export const ko: Record = { "nav.openMenu": "메뉴 열기", "nav.closeMenu": "메뉴 닫기", "nav.goHome": "대시보드로 이동", + "nav.collapseSidebar": "사이드바 접기", + "nav.expandSidebar": "사이드바 펼치기", "pws.refreshAllQuotas": "전체 할당량 갱신", "pws.quotaRefreshDone": "할당량 조회를 마쳤습니다", "integrations.subtitle": "클라이언트를 opencodex에 연결하고 자격 증명과 설정 복원을 관리합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 4e2211d4699..6f4a0a42778 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1874,6 +1874,8 @@ export const ru: Record = { "nav.openMenu": "Открыть меню", "nav.closeMenu": "Закрыть меню", "nav.goHome": "Перейти к панели", + "nav.collapseSidebar": "Свернуть боковую панель", + "nav.expandSidebar": "Развернуть боковую панель", "pws.refreshAllQuotas": "Обновить все квоты", "pws.quotaRefreshDone": "Проверка квот завершена", "integrations.subtitle": "Подключайте клиенты к opencodex, управляйте учётными данными и восстанавливайте конфигурацию клиентов.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 586bd869ac8..563823498a4 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1893,6 +1893,8 @@ export const tr: Record = { "nav.openMenu": "Menüyü aç", "nav.closeMenu": "Menüyü kapat", "nav.goHome": "Panoya git", + "nav.collapseSidebar": "Kenar çubuğunu daralt", + "nav.expandSidebar": "Kenar çubuğunu genişlet", "pws.refreshAllQuotas": "Tüm kotaları yenile", "pws.quotaRefreshDone": "Kota kontrolü tamamlandı", "integrations.subtitle": "İstemcileri opencodex'e bağlayın, kimlik bilgilerini yönetin.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index ec8ada3e6ac..8ce937847a7 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1861,6 +1861,8 @@ export const vi: Record = { "nav.openMenu": "Mở menu", "nav.closeMenu": "Đóng menu", "nav.goHome": "Đi tới bảng điều khiển", + "nav.collapseSidebar": "Thu gọn thanh bên", + "nav.expandSidebar": "Mở rộng thanh bên", "pws.refreshAllQuotas": "Làm mới tất cả hạn mức", "pws.quotaRefreshDone": "Đã kiểm tra hạn mức xong", "integrations.subtitle": "Kết nối các client với opencodex, quản lý thông tin xác thực, và khôi phục cấu hình client.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index e6ee16e47c1..c92a5c5d5c4 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1566,6 +1566,8 @@ export const zhTW: Record = { "nav.openMenu": "開啟選單", "nav.closeMenu": "關閉選單", "nav.goHome": "前往儀表板", + "nav.collapseSidebar": "收合側邊欄", + "nav.expandSidebar": "展開側邊欄", "pws.refreshAllQuotas": "重新整理所有額度", "pws.quotaRefreshDone": "額度檢查完成", "codexAuth.mainAccount": "主帳號", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 0eed9c08c8c..69ef71c1ab5 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1376,6 +1376,8 @@ export const zh: Record = { "nav.openMenu": "打开菜单", "nav.closeMenu": "关闭菜单", "nav.goHome": "前往仪表板", + "nav.collapseSidebar": "收起侧边栏", + "nav.expandSidebar": "展开侧边栏", "pws.refreshAllQuotas": "刷新全部额度", "pws.quotaRefreshDone": "额度检查完成", "integrations.subtitle": "将客户端连接到 opencodex,管理凭据并恢复客户端配置。", diff --git a/gui/src/icons.tsx b/gui/src/icons.tsx index 70afe03db9f..8aafcb220ac 100644 --- a/gui/src/icons.tsx +++ b/gui/src/icons.tsx @@ -13,6 +13,7 @@ export const IconBoxes = (p: P) => (); export const IconList = (p: P) => (); export const IconMenu = (p: P) => (); +export const IconPanelLeft = (p: P) => (); export const IconTerminal = (p: P) => (); export const IconActivity = (p: P) => (); export const IconHardDrive = (p: P) => (); diff --git a/gui/src/lib/window-chrome.ts b/gui/src/lib/window-chrome.ts new file mode 100644 index 00000000000..e566aeeca9a --- /dev/null +++ b/gui/src/lib/window-chrome.ts @@ -0,0 +1,61 @@ +/** + * Integrated title bar plumbing. + * + * Inside the Tauri desktop shell the window has no native title bar: macOS draws its + * traffic lights over the webview, so the strips at the top of the sidebar and of the + * main column are what a person grabs to move the window. They call the shell through + * `plugin:window` commands — granted to the loopback origin by + * `desktop/src-tauri/capabilities/dashboard-titlebar.json` — on the `__TAURI__` global + * that `withGlobalTauri` injects. Outside the shell (a plain browser dashboard) the + * global is absent and every call is a no-op. + */ +import type { MouseEvent as ReactMouseEvent } from "react"; + +declare global { + interface Window { + __TAURI__?: { + core?: { invoke?: (command: string, args?: Record) => Promise }; + }; + } +} + +type WindowCommand = "plugin:window|start_dragging" | "plugin:window|toggle_maximize"; + +function windowCommand(command: WindowCommand): void { + try { + void window.__TAURI__?.core?.invoke?.(command).catch(() => {}); + } catch { + // Not a shell surface. + } +} + +/** + * Elements a drag must not start on: the strip wraps the quota chips (links) and their + * paging buttons, which have to stay clickable. + */ +const INTERACTIVE_SELECTOR = + "a, button, input, select, textarea, summary, [role='button'], [role='link'], [contenteditable]"; + +function isInteractiveTarget(target: EventTarget | null): boolean { + return target instanceof Element && target.closest(INTERACTIVE_SELECTOR) !== null; +} + +/** + * Spread onto a top-strip element: press-and-move drags the window, a double click on + * empty strip toggles zoom — the two behaviors a native title bar would give it. + */ +export function windowChromeHandlers(): { + onMouseDown: (event: ReactMouseEvent) => void; + onDoubleClick: (event: ReactMouseEvent) => void; +} { + return { + onMouseDown: (event) => { + if (event.button !== 0 || isInteractiveTarget(event.target)) return; + windowCommand("plugin:window|start_dragging"); + }, + onDoubleClick: (event) => { + if (isInteractiveTarget(event.target)) return; + windowCommand("plugin:window|toggle_maximize"); + }, + }; +} diff --git a/gui/src/use-sidebar-collapse.ts b/gui/src/use-sidebar-collapse.ts new file mode 100644 index 00000000000..52b68633771 --- /dev/null +++ b/gui/src/use-sidebar-collapse.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useState } from "react"; + +const COLLAPSED_KEY = "ocx-sidebar-collapsed"; + +export function readSidebarCollapsed( + storage: Pick | undefined = typeof localStorage === "undefined" ? undefined : localStorage, +): boolean { + try { + return storage?.getItem(COLLAPSED_KEY) === "1"; + } catch { + return false; + } +} + +function writeSidebarCollapsed(collapsed: boolean): void { + try { + localStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0"); + } catch { + // A storageless context still toggles for the session. + } +} + +function isEditableTarget(target: EventTarget | null): boolean { + return ( + target instanceof HTMLElement && + (target.isContentEditable || target.closest("input, textarea, select, [contenteditable]") !== null) + ); +} + +/** + * Codex-style rail collapse: the sidebar shrinks to its top strip — traffic lights and + * the toggle — and the remembered answer survives restarts. Cmd/Ctrl+B toggles too, the + * shortcut every sibling app trained into the same hands. + */ +export function useSidebarCollapse(): { collapsed: boolean; toggle: () => void } { + const [collapsed, setCollapsed] = useState(readSidebarCollapsed); + const toggle = useCallback(() => { + setCollapsed((current) => { + writeSidebarCollapsed(!current); + return !current; + }); + }, []); + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return; + if (event.key !== "b" && event.key !== "B") return; + if (isEditableTarget(event.target)) return; + event.preventDefault(); + toggle(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [toggle]); + return { collapsed, toggle }; +} diff --git a/gui/tests/app-titlebar.test.tsx b/gui/tests/app-titlebar.test.tsx new file mode 100644 index 00000000000..f84192f6ebf --- /dev/null +++ b/gui/tests/app-titlebar.test.tsx @@ -0,0 +1,133 @@ +/** + * The integrated title bar's collapse toggle: the sidebar shrinks to a rail that keeps + * the traffic lights and the toggle, the answer is persisted, and Cmd/Ctrl+B flips it. + */ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { SidebarTopStrip } from "../src/components/app-titlebar"; +import { useSidebarCollapse } from "../src/use-sidebar-collapse"; +import { LanguageProvider } from "../src/i18n/provider"; + +const globals = ["document", "window", "navigator", "localStorage", "HTMLElement", "Element", "IS_REACT_ACT_ENVIRONMENT"] as const; +const WINDOW_EVENT_STUB: { event: undefined } = { event: undefined }; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; + +function Probe() { + const { collapsed, toggle } = useSidebarCollapse(); + return ( +
+ +
+ ); +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win, "event", { configurable: true, writable: true, value: undefined }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + HTMLElement: { configurable: true, value: win.HTMLElement }, + Element: { configurable: true, value: win.Element }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + await act(async () => { + for (let i = 0; i < 5; i++) { + await new Promise((r) => setTimeout(r, 0)); + await Promise.resolve(); + } + }); + for (const key of globals) { + let value = previous[key]; + if (key === "window") { + if (value == null || typeof value !== "object") { + value = WINDOW_EVENT_STUB; + } else if (!Object.prototype.hasOwnProperty.call(value, "event")) { + try { + Object.defineProperty(value, "event", { configurable: true, writable: true, value: undefined }); + } catch { + value = WINDOW_EVENT_STUB; + } + } + } + Object.defineProperty(globalThis, key, { configurable: true, value }); + } +}); + +async function mountProbe() { + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + return host.querySelector(".app")!; +} + +test("the toggle collapses the sidebar to its rail and persists the choice", async () => { + const app = await mountProbe(); + expect(app.className).not.toContain("app--nav-collapsed"); + + const toggle = host.querySelector(".sidebar-collapse") as HTMLButtonElement; + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + expect(toggle.getAttribute("aria-controls")).toBe("app-sidebar"); + // Icon-only control: the accessible name is the i18n label, not icon internals. + expect(toggle.getAttribute("aria-label")).toBe("Collapse sidebar"); + + await act(async () => { toggle.click(); }); + expect(app.className).toContain("app--nav-collapsed"); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(toggle.getAttribute("aria-label")).toBe("Expand sidebar"); + expect(win.localStorage.getItem("ocx-sidebar-collapsed")).toBe("1"); +}); + +test("a stored collapse survives remount", async () => { + win.localStorage.setItem("ocx-sidebar-collapsed", "1"); + const app = await mountProbe(); + expect(app.className).toContain("app--nav-collapsed"); +}); + +test("Cmd/Ctrl+B toggles the rail and skips text fields", async () => { + const app = await mountProbe(); + const press = (init: { key: string; metaKey?: boolean; ctrlKey?: boolean }) => + win.dispatchEvent(new win.KeyboardEvent("keydown", { ...init, bubbles: true })); + + await act(async () => { press({ key: "b", metaKey: true }); }); + expect(app.className).toContain("app--nav-collapsed"); + + await act(async () => { press({ key: "b", ctrlKey: true }); }); + expect(app.className).not.toContain("app--nav-collapsed"); + + // A bare B and an editable target must both be ignored. + const input = win.document.createElement("input"); + win.document.body.appendChild(input as never); + await act(async () => { press({ key: "b" }); }); + expect(app.className).not.toContain("app--nav-collapsed"); + await act(async () => { + input.dispatchEvent(new win.KeyboardEvent("keydown", { key: "b", metaKey: true, bubbles: true })); + }); + expect(app.className).not.toContain("app--nav-collapsed"); +}); diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 408dc8f70a3..3db4ab2a88d 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -27,6 +27,18 @@ on macOS and Linux Tauri injects a keydown polyfill that calls `set_webview_zoom is loaded, including the loopback dashboard. `capabilities/dashboard-zoom.json` grants that single command to the main window for `http://127.0.0.1:*`, and a test in `window.rs` pins its shape. +The main window carries an integrated title bar on macOS: the builder sets +`TitleBarStyle::Overlay` with `hidden_title`, so the webview draws to the top of the window and +the traffic lights land inside it at a fixed `traffic_light_position`. The layout that receives +them is the GUI's: the dashboard keeps a top strip across the sidebar and the main area, reserves +the lights' inset on macOS only, and moves or zooms the window through `plugin:window` commands. +`capabilities/dashboard-titlebar.json` grants `start_dragging` and `toggle_maximize` to `main` +for the loopback origin, the same test pins it, and `capabilities/default.json` grants the pair +on the app origin because the bundled bootstrap and update pages draw their own matching strip — +a page with an overlay title bar and no strip cannot be dragged or zoomed at all. Windows and +Linux keep the native title bar: the shell ships no min/max/close widgets of its own, and the +sidebar-top layout applies unchanged beneath it. + ## Startup, quit and the tray The window is created and shown before anything is registered, resolved, probed or started, and From ee2f867d2eff7fc7f3f31c62a154386478f1508b Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 26 Sep 2026 00:45:53 -0700 Subject: [PATCH 2/6] fix(desktop): write the collapsed flag outside the state updater React may run an updater without committing, so the localStorage write moves to an effect on the collapsed value (react-doctor's no-side-effect-in-state-updater-function gate). Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gui/src/use-sidebar-collapse.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/gui/src/use-sidebar-collapse.ts b/gui/src/use-sidebar-collapse.ts index 52b68633771..0bbc22b8cea 100644 --- a/gui/src/use-sidebar-collapse.ts +++ b/gui/src/use-sidebar-collapse.ts @@ -34,12 +34,11 @@ function isEditableTarget(target: EventTarget | null): boolean { */ export function useSidebarCollapse(): { collapsed: boolean; toggle: () => void } { const [collapsed, setCollapsed] = useState(readSidebarCollapsed); - const toggle = useCallback(() => { - setCollapsed((current) => { - writeSidebarCollapsed(!current); - return !current; - }); - }, []); + const toggle = useCallback(() => setCollapsed((current) => !current), []); + // Updaters may run without a commit, so the write follows the render instead. + useEffect(() => { + writeSidebarCollapsed(collapsed); + }, [collapsed]); useEffect(() => { const onKey = (event: KeyboardEvent) => { if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return; From 7d64a1c1930453c1715fb619c28da14778fa827a Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 26 Sep 2026 01:51:26 -0700 Subject: [PATCH 3/6] feat(desktop): single 40px title row; collapsed sidebar leaves the layout entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - One shared top row: sidebar-top strip and main-top strip are both exactly --titlebar-h (40px), traffic lights vertically centered in it via traffic_light_position(18,14); toggle, quota badges and the bottom border share that line. - Collapsed is not a rail: .app--nav-collapsed drops the sidebar to a 0px column; the strip is a direct .app child fixed to the top-left (the sidebar's backdrop-filter would clip a fixed in-sidebar strip to 0px). - .main pinned to grid column 2 while collapsed so auto-placement cannot squeeze it into the empty sidebar track. - desktop update/bootstrap pages: guard navigator before UA-sniffing so the update surface works on non-mac webviews; harness gained a userAgent param and coverage for the macOS grip branch. - useSidebarCollapse({ shortcut }) — Cmd/Ctrl+B stays desktop-only so the browser dashboard keeps Chrome's bookmark-bar shortcut. - Mobile (<=760px): mobile-topbar reserves the lights inset so the traffic lights can't overlap the hamburger. - Pin test: lib.rs traffic_light_position, CSS row/inset/clear values and the sidebar grid column width are asserted in one place so they can't drift. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- desktop/src-tauri/src/lib.rs | 2 +- desktop/ui/index.html | 5 +- desktop/ui/update.html | 5 +- gui/src/App.tsx | 6 +- gui/src/components/app-titlebar.css | 96 +++++++++++++++----- gui/src/use-sidebar-collapse.ts | 14 ++- gui/tests/app-titlebar.test.tsx | 52 +++++++++-- tests/clients/desktop-update-surface.test.ts | 25 ++++- 8 files changed, 159 insertions(+), 46 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f96fdde6d47..3afd338daf9 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -321,7 +321,7 @@ pub fn run() { .title_bar_style(tauri::TitleBarStyle::Overlay) .hidden_title(true) .traffic_light_position(tauri::Position::Logical(tauri::LogicalPosition::new( - 18.0, 16.0, + 18.0, 14.0, ))); let window = builder.build()?; window::configure(&window); diff --git a/desktop/ui/index.html b/desktop/ui/index.html index c8a33696f88..bbf7a2da929 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -92,7 +92,7 @@ stays movable only if the page provides the grip. This strip is that grip; the script unhides it on macOS only — every other platform keeps native decorations. */ - #titlebar { position: fixed; top: 0; left: 0; right: 0; height: 44px; z-index: 1; user-select: none; -webkit-user-select: none; } + #titlebar { position: fixed; top: 0; left: 0; right: 0; height: 40px; z-index: 1; user-select: none; -webkit-user-select: none; } #titlebar[hidden] { display: none; } @@ -357,7 +357,8 @@

OpenCodex

// The strip exists so the overlay title bar keeps the window movable on macOS. Other // platforms keep native decorations, so it stays hidden there. -if (invoke && /Mac OS X|Macintosh/.test(navigator.userAgent)) { +const macWebview = typeof navigator === "object" && /Mac OS X|Macintosh/.test(navigator.userAgent || ""); +if (invoke && macWebview) { titlebar.hidden = false; titlebar.addEventListener("mousedown", (event) => { if (event.button === 0 && event.target === titlebar) { diff --git a/desktop/ui/update.html b/desktop/ui/update.html index dfaf50137dc..22ca486c13e 100644 --- a/desktop/ui/update.html +++ b/desktop/ui/update.html @@ -25,7 +25,7 @@ button.secondary { background: #3a3a3c; color: #f5f5f7; } } /* Overlay title bar grip on macOS; hidden everywhere else (native decorations). */ - #titlebar { position: fixed; top: 0; left: 0; right: 0; height: 44px; z-index: 1; user-select: none; -webkit-user-select: none; } + #titlebar { position: fixed; top: 0; left: 0; right: 0; height: 40px; z-index: 1; user-select: none; -webkit-user-select: none; } #titlebar[hidden] { display: none; } @@ -123,7 +123,8 @@

OpenCodex update

}); // The strip exists so the overlay title bar keeps the window movable on macOS. Other // platforms keep native decorations, so it stays hidden there. -if (invoke && /Mac OS X|Macintosh/.test(navigator.userAgent)) { +const macWebview = typeof navigator === "object" && /Mac OS X|Macintosh/.test(navigator.userAgent || ""); +if (invoke && macWebview) { titlebar.hidden = false; titlebar.addEventListener("mousedown", (event) => { if (event.button === 0 && event.target === titlebar) { diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 812351aab33..b9a690c15bf 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -206,8 +206,8 @@ export default function App() { // Narrow screens: the sidebar becomes an off-canvas drawer behind a hamburger toggle. const [navOpen, setNavOpen] = useState(false); // Codex-style rail collapse on wide screens, persisted; Cmd/Ctrl+B toggles too. - const { collapsed: navCollapsed, toggle: toggleNavCollapse } = useSidebarCollapse(); const desktopShell = isDesktopShell(); + const { collapsed: navCollapsed, toggle: toggleNavCollapse } = useSidebarCollapse({ shortcut: desktopShell }); const desktopMac = desktopShell && hostOs() === "macos"; const menuBtnRef = useRef(null); const sidebarRef = useRef(null); @@ -415,8 +415,10 @@ export default function App() {
{navOpen &&
setNavOpen(false)} aria-hidden="true" />} + {/* Fixed to the window's top-left; kept outside .sidebar so the sidebar's + backdrop-filter containing block can't clip it to 0 width when collapsed. */} +