Skip to content
Closed
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
10 changes: 10 additions & 0 deletions docs-site/src/content/docs/guides/desktop-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,16 @@ and launcher installation or repair stays disabled while that ownership remains;
Normal desktop updates replace the bundled CLI with the fixed startup probe. No local
patch needs to be preserved across an update.

## Zoom

On macOS and Linux, Cmd (macOS) or Ctrl (Linux) with `+`, `-` and `0`, or Ctrl with the mouse wheel,
zooms the window between 50% and 300% in 10% steps, and `0` returns to 100%. The sidebar shows the
same level with `-`, `+` and a reset button beside the theme and language rows. The level is
remembered and applied again the next time the app starts. The dashboard comes from the proxy the
app is attached to, so an app attached to an older proxy keeps the earlier behaviour (20% steps, not
remembered, no sidebar control) until that proxy is updated. Windows uses the browser engine's own
zoom, which is not remembered.

## Keeping the proxy running

The app keeps the proxy it started running. When that proxy restarts itself — after
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 @@ -8,6 +8,7 @@
- `.nav-item`은 아이콘 17px, control text, 4px 세로 간격을 사용한다.
- hover와 active는 같은 surface family를 쓰되 active는 semibold로 구분한다.
- 메뉴마다 margin을 직접 추가하지 않고 `.sidebar nav`의 `gap`을 사용한다.
- 데스크톱 레이아웃에서 `.sidebar nav`는 스크롤 영역이고 footer(언어, 테마, 줌, 프록시)는 항상 보인다. 창이 낮거나 페이지 줌으로 CSS 뷰포트가 줄어도 footer 컨트롤에 닿을 수 있어야 한다. 데스크톱 셸의 줌 컨트롤(`.zoom-control`)은 테마 행과 같은 행 높이를 쓴다.

## Buttons

Expand Down
9 changes: 9 additions & 0 deletions gui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import { requestProxyStop } from "./stop-proxy";
import { useCodexRestart } from "./use-codex-restart";
import { confirmAction } from "./action-dialogs";
import { hostOs, isDesktopShell, isExternalLink, openDesktopUpdatePage } from "./lib/desktop-shell";
import { zoomManagedOn } from "./lib/desktop-zoom";
import { useSidebarCollapse } from "./use-sidebar-collapse";
import { useDesktopZoom } from "./use-desktop-zoom";
import { DesktopZoomControl } from "./components/desktop-zoom-control";
import { MainTopStrip, SidebarTopStrip } from "./components/app-titlebar";
import { watchMacTitlebarMetrics, windowChromeHandlers } from "./lib/window-chrome";

Expand Down Expand Up @@ -216,6 +219,8 @@ export default function App() {
const desktopShell = isDesktopShell();
const { collapsed: navCollapsed, toggle: toggleNavCollapse } = useSidebarCollapse({ shortcut: desktopShell });
const desktopMac = desktopShell && hostOs() === "macos";
const zoomManaged = desktopShell && zoomManagedOn(hostOs());
const desktopZoom = useDesktopZoom({ managed: zoomManaged });
const appRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (desktopMac && appRef.current) return watchMacTitlebarMetrics(appRef.current);
Expand Down Expand Up @@ -488,6 +493,10 @@ export default function App() {
aria-label={`${t("theme.label")}: ${t(THEME_TKEY[theme])}`} title={`${t("theme.label")}: ${t(THEME_TKEY[theme])}`}>
<ThemeIcon /> <span className="mode">{t(THEME_TKEY[theme])}</span>
</button>
{zoomManaged && (
<DesktopZoomControl percent={desktopZoom.percent} canZoomIn={desktopZoom.canZoomIn}
canZoomOut={desktopZoom.canZoomOut} onStep={desktopZoom.step} />
)}
<div className="sidebar-action-row">
<span className="sidebar-action-label">{t("dash.actions")}</span>
<div className="sidebar-action-orbs">
Expand Down
32 changes: 32 additions & 0 deletions gui/src/components/desktop-zoom-control.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { IconMinus, IconPlus } from "../icons";
import { useT } from "../i18n/shared";
import type { ZoomAction } from "../lib/desktop-zoom";

interface DesktopZoomControlProps {
percent: number;
canZoomIn: boolean;
canZoomOut: boolean;
onStep: (action: ZoomAction) => void;
}

/** Sidebar row for the desktop window's page zoom; the same steps as Ctrl/Cmd + plus, minus and zero. */
export function DesktopZoomControl({ percent, canZoomIn, canZoomOut, onStep }: DesktopZoomControlProps) {
const t = useT();
return (
<div className="zoom-control" role="group" aria-label={t("zoom.label")}>
<span className="zoom-control__label">{t("zoom.label")}</span>
<button type="button" className="zoom-control__btn" disabled={!canZoomOut}
onClick={() => onStep("out")} aria-label={t("zoom.out")} title={t("zoom.out")}>
<IconMinus aria-hidden />
</button>
<button type="button" className="zoom-control__value" onClick={() => onStep("reset")}
aria-label={t("zoom.reset")} title={t("zoom.reset")}>
{percent}%
</button>
<button type="button" className="zoom-control__btn" disabled={!canZoomIn}
onClick={() => onStep("in")} aria-label={t("zoom.in")} title={t("zoom.in")}>
<IconPlus aria-hidden />
</button>
</div>
);
}
4 changes: 4 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ export const de: Record<TKey, string> = {
"theme.light": "Hell",
"theme.dark": "Dunkel",
"theme.system": "System",
"zoom.label": "Zoom",
"zoom.out": "Verkleinern",
"zoom.in": "Vergrößern",
"zoom.reset": "Zoom auf 100 % zurücksetzen",
"lang.label": "Sprache",
"lang.nativeName": "Deutsch",
"provider.name.commandCodeAuth": "Command Code - Auth",
Expand Down
4 changes: 4 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ export const en = {
"theme.light": "Light",
"theme.dark": "Dark",
"theme.system": "System",
"zoom.label": "Zoom",
"zoom.out": "Zoom out",
"zoom.in": "Zoom in",
"zoom.reset": "Reset zoom to 100%",
"lang.label": "Language",
"lang.nativeName": "English",
"provider.name.commandCodeAuth": "Command Code - Auth",
Expand Down
4 changes: 4 additions & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ export const fr: Record<TKey, string> = {
"theme.light": "Clair",
"theme.dark": "Sombre",
"theme.system": "Système",
"zoom.label": "Zoom",
"zoom.out": "Zoom arrière",
"zoom.in": "Zoom avant",
"zoom.reset": "Réinitialiser le zoom à 100 %",
"lang.label": "Langue",
"lang.nativeName": "Français",
"provider.name.commandCodeAuth": "Command Code - Auth",
Expand Down
4 changes: 4 additions & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@ export const ja: Record<TKey, string> = {
"theme.light": "ライト",
"theme.dark": "ダーク",
"theme.system": "システム",
"zoom.label": "表示倍率",
"zoom.out": "縮小",
"zoom.in": "拡大",
"zoom.reset": "倍率を100%に戻す",
"lang.label": "言語",
"lang.nativeName": "日本語",
"provider.name.commandCodeAuth": "Command Code - Auth",
Expand Down
4 changes: 4 additions & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ export const ko: Record<TKey, string> = {
"theme.light": "라이트",
"theme.dark": "다크",
"theme.system": "시스템",
"zoom.label": "확대/축소",
"zoom.out": "축소",
"zoom.in": "확대",
"zoom.reset": "확대/축소를 100%로 재설정",
"lang.label": "언어",
"lang.nativeName": "한국어",
"provider.name.commandCodeAuth": "Command Code - Auth",
Expand Down
4 changes: 4 additions & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@ export const ru: Record<TKey, string> = {
"theme.light": "Светлая",
"theme.dark": "Тёмная",
"theme.system": "Системная",
"zoom.label": "Масштаб",
"zoom.out": "Уменьшить",
"zoom.in": "Увеличить",
"zoom.reset": "Сбросить масштаб до 100 %",
"lang.label": "Язык",
"lang.nativeName": "Русский",
"provider.name.commandCodeAuth": "Command Code - Auth",
Expand Down
4 changes: 4 additions & 0 deletions gui/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ export const tr: Record<TKey, string> = {
"theme.light": "Açık",
"theme.dark": "Koyu",
"theme.system": "Sistem",
"zoom.label": "Yakınlaştırma",
"zoom.out": "Uzaklaştır",
"zoom.in": "Yakınlaştır",
"zoom.reset": "Yakınlaştırmayı %100'e sıfırla",
"lang.label": "Dil",
"lang.nativeName": "Türkçe",
"provider.name.commandCodeAuth": "Command Code - Auth",
Expand Down
4 changes: 4 additions & 0 deletions gui/src/i18n/vi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ export const vi: Record<TKey, string> = {
"theme.light": "Sáng",
"theme.dark": "Tối",
"theme.system": "Hệ thống",
"zoom.label": "Thu phóng",
"zoom.out": "Thu nhỏ",
"zoom.in": "Phóng to",
"zoom.reset": "Đặt lại thu phóng về 100%",
"lang.label": "Ngôn ngữ",
"lang.nativeName": "Tiếng Việt",
"provider.name.commandCodeAuth": "Command Code - Auth",
Expand Down
4 changes: 4 additions & 0 deletions gui/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ export const zhTW: Record<TKey, string> = {
"theme.light": "淺色",
"theme.dark": "深色",
"theme.system": "跟隨系統",
"zoom.label": "縮放",
"zoom.out": "縮小",
"zoom.in": "放大",
"zoom.reset": "重設縮放為 100%",
"lang.label": "語言",
"errorBoundary.title": "頁面載入失敗",
"errorBoundary.message": "此部分在渲染時發生錯誤。請重新載入後再試。",
Expand Down
4 changes: 4 additions & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ export const zh: Record<TKey, string> = {
"theme.light": "浅色",
"theme.dark": "深色",
"theme.system": "跟随系统",
"zoom.label": "缩放",
"zoom.out": "缩小",
"zoom.in": "放大",
"zoom.reset": "将缩放重置为 100%",
"lang.label": "语言",
"lang.nativeName": "中文",
"provider.name.commandCodeAuth": "Command Code - Auth",
Expand Down
1 change: 1 addition & 0 deletions gui/src/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const IconHardDrive = (p: P) => (<svg {...S(p)}><path d="M22 12H2"/><path
export const IconCheck = (p: P) => (<svg {...S(p)}><path d="m20 6-11 11-5-5"/></svg>);
export const IconX = (p: P) => (<svg {...S(p)}><path d="M18 6 6 18M6 6l12 12"/></svg>);
export const IconPlus = (p: P) => (<svg {...S(p)}><path d="M12 5v14M5 12h14"/></svg>);
export const IconMinus = (p: P) => (<svg {...S(p)}><path d="M5 12h14"/></svg>);
export const IconRefresh = (p: P) => (<svg {...S(p)}><path d="M21 12a9 9 0 0 1-9 9 9.8 9.8 0 0 1-6.7-2.7L3 16M3 21v-5h5M3 12a9 9 0 0 1 9-9 9.8 9.8 0 0 1 6.7 2.7L21 8M21 3v5h-5"/></svg>);
export const IconPause = (p: P) => (<svg {...S(p)}><path d="M8 5v14M16 5v14"/></svg>);
export const IconPlay = (p: P) => (<svg {...S(p)}><path d="m7 4 13 8-13 8Z"/></svg>);
Expand Down
121 changes: 121 additions & 0 deletions gui/src/lib/desktop-zoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* Page zoom for the desktop shell, owned by the dashboard.
*
* Tauri's own zoom-hotkey polyfill keeps the level in a script variable that starts at 1 on
* every page load: a restart forgot the level, and so did the hop from the bootstrap page to
* the dashboard, after which the first shortcut jumped from the real level to 120%. The
* dashboard therefore keeps the level itself, remembers it, and drives the webview through the
* one command `desktop/src-tauri/capabilities/dashboard-zoom.json` already grants to this
* origin. Outside the shell the global is absent and every call is a no-op.
*/
import type { HostOs } from "./desktop-shell";

export const ZOOM_STORAGE_KEY = "ocx-desktop-zoom";
export const ZOOM_MIN = 0.5;
export const ZOOM_MAX = 3;
export const ZOOM_STEP = 0.1;
export const ZOOM_DEFAULT = 1;

export type ZoomAction = "in" | "out" | "reset";

declare global {
interface Window {
__TAURI_INTERNALS__?: {
invoke?: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
};
}
}

/**
* Whether the dashboard drives zoom on this host. Windows keeps WebView2's native zoom, which
* the shell leaves enabled there, so a second owner would only disagree with it.
*/
export function zoomManagedOn(os: HostOs): boolean {
return os === "macos" || os === "linux";
}

/** Whole percent steps keep repeated presses from drifting into 1.2000000000000002. */
export function clampZoom(value: number): number {
if (!Number.isFinite(value)) return ZOOM_DEFAULT;
const bounded = Math.min(Math.max(value, ZOOM_MIN), ZOOM_MAX);
return Math.round(bounded * 100) / 100;
}

export function stepZoom(current: number, action: ZoomAction): number {
if (action === "reset") return ZOOM_DEFAULT;
return clampZoom(current + (action === "in" ? ZOOM_STEP : -ZOOM_STEP));
}

export function zoomPercent(zoom: number): number {
return Math.round(zoom * 100);
}

export function readSavedZoom(
storage: Pick<Storage, "getItem"> | undefined = typeof localStorage === "undefined" ? undefined : localStorage,
): number {
try {
const raw = storage?.getItem(ZOOM_STORAGE_KEY);
if (raw === null || raw === undefined) return ZOOM_DEFAULT;
return clampZoom(Number(raw));
} catch {
return ZOOM_DEFAULT;
}
}

export function writeSavedZoom(
zoom: number,
storage: Pick<Storage, "setItem"> | undefined = typeof localStorage === "undefined" ? undefined : localStorage,
): void {
try {
storage?.setItem(ZOOM_STORAGE_KEY, String(zoom));
} catch {
// A storageless context still zooms for the session.
}
}

/**
* The key combinations the shell always answered to: Cmd on macOS, Ctrl elsewhere, with plus,
* minus and zero. `=` is accepted because plus shares its key on most layouts.
*/
export function zoomKeyAction(
event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey">,
os: HostOs,
): ZoomAction | null {
const modifier = os === "macos" ? event.metaKey : event.ctrlKey;
if (!modifier) return null;
if (event.key === "-" || event.key === "_") return "out";
if (event.key === "=" || event.key === "+") return "in";
if (event.key === "0") return "reset";
return null;
}

/** Distance a Ctrl + wheel gesture must add up to before it steps once. */
export const WHEEL_STEP_PX = 50;

/**
* Ctrl + wheel, as before: up zooms in. A mouse notch is one step, while a touchpad pinch
* arrives as many tiny deltas, so the distance accumulates instead of stepping on every event.
*/
export function accumulateWheel(
accumulated: number,
deltaY: number,
): { accumulated: number; action: ZoomAction | null } {
if (!Number.isFinite(deltaY) || deltaY === 0) return { accumulated, action: null };
// A change of direction starts a new gesture rather than cancelling the last one.
const carried = Math.sign(accumulated) === Math.sign(deltaY) ? accumulated : 0;
const total = carried + deltaY;
if (Math.abs(total) < WHEEL_STEP_PX) return { accumulated: total, action: null };
return { accumulated: 0, action: total < 0 ? "in" : "out" };
}

/** Asks the shell to set the webview zoom. Resolves false when there is no shell to ask. */
export async function applyWebviewZoom(zoom: number): Promise<boolean> {
const invoke = window.__TAURI_INTERNALS__?.invoke ?? window.__TAURI__?.core?.invoke;
if (!invoke) return false;
try {
await invoke("plugin:webview|set_webview_zoom", { value: zoom });
return true;
} catch {
return false;
}
}
1 change: 1 addition & 0 deletions gui/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import "./styles/lazycodex-role-models.css";
import "./styles/claude-desktop-picker.css";
import "./styles/anthropic-reset-grants.css";
import "./styles/star-onboarding.css";
import "./styles/sidebar-zoom.css";
import "./styles/protocol-evidence.css";
import "./pages/tray.css";

Expand Down
34 changes: 34 additions & 0 deletions gui/src/styles/sidebar-zoom.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/* ── Desktop shell: sidebar page-zoom control and a footer that stays reachable ──
Kept out of styles.css, which is under a file-size cap that only moves down. Loaded after it
(see main.tsx), so these rules follow the base .sidebar and .sidebar nav rules in the cascade. */

/* Same row metrics as .theme-toggle so the language, theme and zoom rows read as one group. */
.zoom-control { display: flex; align-items: center; gap: 4px; padding: 4px 10px; width: 100%; color: var(--muted); font-size: var(--text-control); }
.zoom-control__label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zoom-control__btn, .zoom-control__value {
display: inline-flex; align-items: center; justify-content: center;
height: 26px; padding: 0; border: none; border-radius: var(--radius-sm);
background: none; color: inherit; font: inherit; cursor: pointer;
transition: background var(--motion-fast), color var(--motion-fast);
}
.zoom-control__btn { width: 26px; }
.zoom-control__btn svg { width: 14px; height: 14px; }
.zoom-control__value { min-width: 3.25rem; padding: 0 6px; font-variant-numeric: tabular-nums; }
.zoom-control__btn:hover:not(:disabled), .zoom-control__value:hover { background: var(--accent-soft); color: var(--text); }
.zoom-control__btn:focus-visible, .zoom-control__value:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; }
.zoom-control__btn:disabled { opacity: 0.5; cursor: default; }

/* Page zoom shrinks the CSS viewport, so a zoomed or short window is shorter than the menu plus
the footer. On the desktop layout the menu scrolls and the footer (language, theme, zoom, proxy)
stays reachable; the narrow layout keeps its drawer, which already scrolls as a whole. The
padding and the equal negative margin keep focus rings at the edges from being clipped by the
scroll box without moving anything. */
@media (min-width: 761px) {
.sidebar nav {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding: 4px;
margin: -4px;
}
}
Loading
Loading