Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 14 additions & 0 deletions desktop/src-tauri/capabilities/dashboard-titlebar.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$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",
"core:window:allow-scale-factor"
]
}
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
21 changes: 18 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,23 @@ 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)
.min_inner_size(360.0, 320.0)
.traffic_light_position(tauri::Position::Logical(tauri::LogicalPosition::new(
18.0, 22.0,
)));
let window = builder.build()?;
window::configure(&window);
if startup::LaunchOrigin::detect() == startup::LaunchOrigin::User {
window::show(&window);
Expand Down
39 changes: 39 additions & 0 deletions desktop/src-tauri/src/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,4 +256,43 @@ 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 dashboard also reads
/// the window's native scale so its traffic-light clearance survives page zoom. Grants are
/// pinned to `main`; the app origin needs only the two window commands.
#[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",
"core:window:allow-scale-factor"
])
);

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}"
);
}
}
}
26 changes: 26 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: 40px; 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,23 @@ <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.
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) {
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
21 changes: 21 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: 40px; 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,22 @@ <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.
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) {
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
1 change: 1 addition & 0 deletions gui/design-system/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- 데스크톱은 232px sidebar와 main content의 2열 구조다.
- 760px 이하에서는 sidebar가 off-canvas drawer로 전환된다.
- macOS 데스크톱에서는 네이티브 신호등과 겹치지 않도록 축소 줌에서 sidebar 폭과 모바일 상단 여백이 커진다.
- `.nav-item`은 아이콘 17px, control text, 4px 세로 간격을 사용한다.
- hover와 active는 같은 surface family를 쓰되 active는 semibold로 구분한다.
- 메뉴마다 margin을 직접 추가하지 않고 `.sidebar nav`의 `gap`을 사용한다.
Expand Down
34 changes: 27 additions & 7 deletions gui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ 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";
import { watchMacTitlebarMetrics } from "./lib/window-chrome";

type Theme = "light" | "dark" | "system";

Expand Down Expand Up @@ -203,6 +206,14 @@ 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 desktopShell = isDesktopShell();
const { collapsed: navCollapsed, toggle: toggleNavCollapse } = useSidebarCollapse({ shortcut: desktopShell });
const desktopMac = desktopShell && hostOs() === "macos";
const appRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (desktopMac && appRef.current) return watchMacTitlebarMetrics(appRef.current);
}, [desktopMac]);
const menuBtnRef = useRef<HTMLButtonElement>(null);
const sidebarRef = useRef<HTMLElement>(null);
const navWasOpen = useRef(false);
Expand Down Expand Up @@ -368,8 +379,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 ref={appRef} 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 @@ -403,6 +420,9 @@ export default function App() {
</div>
</header>
{navOpen && <div className="drawer-scrim" onClick={() => 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. */}
<SidebarTopStrip collapsed={navCollapsed} onToggle={toggleNavCollapse} />
<aside id="app-sidebar" className={`sidebar${navOpen ? " open" : ""}`} ref={sidebarRef} tabIndex={-1}>
<div className="drawer-head">
{brand}
Expand Down Expand Up @@ -496,11 +516,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
Loading
Loading