Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions desktop/src-tauri/capabilities/dashboard-titlebar.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
2 changes: 2 additions & 0 deletions desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
Expand Down
20 changes: 17 additions & 3 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
Expand Down
37 changes: 37 additions & 0 deletions desktop/src-tauri/src/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
}
}
}
25 changes: 25 additions & 0 deletions desktop/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
</style>
</head>
<body>
<div id="titlebar" hidden></div>
<main data-state="running" aria-busy="true">
<div class="brand">
<span class="brand-logo" aria-hidden="true"></span>
Expand Down Expand Up @@ -195,6 +203,7 @@ <h1 class="brand-name">OpenCodex</h1>
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");
Expand Down Expand Up @@ -346,6 +355,22 @@ <h1 class="brand-name">OpenCodex</h1>
}
}

// 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();
</script>
</body>
Expand Down
20 changes: 20 additions & 0 deletions desktop/ui/update.html
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
</style>
</head>
<body>
<div id="titlebar" hidden></div>
<main>
<h1>OpenCodex update</h1>
<p id="state" role="status" aria-live="polite">Reading update status…</p>
Expand All @@ -39,6 +43,7 @@ <h1>OpenCodex update</h1>
</main>
<script nonce="__TAURI_SCRIPT_NONCE__">
const invoke = window.__TAURI__?.core?.invoke;
const titlebar = document.querySelector("#titlebar");
const state = document.querySelector("#state");
const error = document.querySelector("#error");
const check = document.querySelector("#check");
Expand Down Expand Up @@ -116,6 +121,21 @@ <h1>OpenCodex update</h1>
try { await call("return_to_dashboard"); }
catch (cause) { showError(cause); back.disabled = false; }
});
// 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(() => {});
}
});
}
if (!invoke) { state.textContent = "Open this page from the OpenCodex desktop app."; check.disabled = true; install.disabled = true; back.disabled = true; }
else void refresh("update_status");
</script>
Expand Down
27 changes: 20 additions & 7 deletions gui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<HTMLButtonElement>(null);
const sidebarRef = useRef<HTMLElement>(null);
const navWasOpen = useRef(false);
Expand Down Expand Up @@ -368,8 +374,14 @@ export default function App() {
</button>
);

const quotaSummary = targetsSettled && page !== "startup" && (!targets.connected || sharedSessionReady) && (
<ErrorBoundary key={sharedBase} pageName={t("quotaSummary.aria")} title={t("errorBoundary.title")} message={t("errorBoundary.message")} detailsLabel={t("errorBoundary.details")} reloadLabel={t("errorBoundary.reload")}>
<QuotaSummaryBar apiBase={sharedBase} />
</ErrorBoundary>
);

return (
<div className="app">
<div className={`app${desktopShell ? " app--desktop" : ""}${desktopMac ? " app--macos" : ""}${navCollapsed ? " app--nav-collapsed" : ""}`}>
<DesktopStarOnboarding apiBase={sharedBase} enabled={targetsSettled && !targets.connected} />
{actionFeedback && (
<ToastNotice tone={actionFeedback.tone} onDismiss={() => setActionFeedback(null)} dismissLabel={t("common.close")}>
Expand Down Expand Up @@ -404,6 +416,7 @@ export default function App() {
</header>
{navOpen && <div className="drawer-scrim" onClick={() => setNavOpen(false)} aria-hidden="true" />}
<aside id="app-sidebar" className={`sidebar${navOpen ? " open" : ""}`} ref={sidebarRef} tabIndex={-1}>
<SidebarTopStrip collapsed={navCollapsed} onToggle={toggleNavCollapse} />
<div className="drawer-head">
{brand}
<button type="button" className="menu-toggle drawer-close" onClick={() => setNavOpen(false)}
Expand Down Expand Up @@ -496,11 +509,11 @@ export default function App() {
</aside>

<main className="main" inert={navOpen}>
{targetsSettled && page !== "startup" && (!targets.connected || sharedSessionReady) && (
<ErrorBoundary key={sharedBase} pageName={t("quotaSummary.aria")} title={t("errorBoundary.title")} message={t("errorBoundary.message")} detailsLabel={t("errorBoundary.details")} reloadLabel={t("errorBoundary.reload")}>
<QuotaSummaryBar apiBase={sharedBase} />
</ErrorBoundary>
)}
{/* 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 ? <MainTopStrip>{quotaSummary}</MainTopStrip> : quotaSummary}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the Combos height rules when wrapping the quota bar

When the desktop shell opens Models → Combos with quota data available, this wrapper makes .quota-summary-bar no longer a direct child of .main. The existing rules in gui/src/components/quota-summary-bar/quota-summary-bar.css:268-278 therefore stop matching, leaving .main-inner--combos at height: 100dvh; the new 40px title-bar sibling then makes the page one title-bar taller than the viewport and clips or scrolls the bottom of the workspace. Update those selectors for .main-top or otherwise size the Combos shell to the remaining height.

Useful? React with 👍 / 👎.

{/*
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
Expand Down
85 changes: 85 additions & 0 deletions gui/src/components/app-titlebar.css
Original file line number Diff line number Diff line change
@@ -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; }
}
42 changes: 42 additions & 0 deletions gui/src/components/app-titlebar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="sidebar-top" {...windowChromeHandlers()}>
<button
type="button"
className="sidebar-collapse"
onClick={onToggle}
aria-expanded={!collapsed}
aria-controls="app-sidebar"
aria-label={label}
title={label}
>
<IconPanelLeft />
</button>
</div>
);
}

export function MainTopStrip({ children }: { children?: ReactNode }) {
return (
<div className="main-top" {...windowChromeHandlers()}>
{children}
</div>
);
}
2 changes: 2 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,8 @@ export const de: Record<TKey, string> = {
"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.",
Expand Down
Loading
Loading