Per-window zoom (each popped-out window remembers its own size)
Every secondary window you pop out — a session, a project, the vault, the scratchpad — gets its own zoom level that scales the whole window and is remembered next time you open it. Session windows remember per session, so two sessions can come back at two different sizes.
What it is
What it is
Per-window zoom gives every Omniscio secondary window — the ones you pop out into their own desktop window — its own zoom level, like the zoom in a web browser. It scales the whole window (text, icons, images) so the whole thing gets bigger or smaller together, and it is saved per window: close a window and reopen it and its zoom comes back.
The key word is independent. Zooming one pop-out does not touch any other window, and — by design — it never affects the main Omniscio window. The main window keeps the app-wide Text size behavior it always had (Text size is one global setting shared across the app; per-window zoom is separate, per-window, and scales everything, not just text). So you can, say, make a popped-out session window large on a second monitor while the main window stays exactly as it was.
Where to find it
Inside any popped-out window — a session you opened in its own window, a project window, the vault or scratchpad window. The controls are the familiar browser zoom keys, so there is no menu to find.
How it behaves
The controls
Inside any of these windows:
- Ctrl and
+(or=) — zoom in one step. - Ctrl and
-— zoom out one step. - Ctrl and
0— reset to 100%. - Ctrl and the mouse wheel — scroll up to zoom in, down to zoom out.
The numpad + / - / 0 keys work too. There is no Settings control and no
menu — zoom lives entirely in the keyboard and wheel shortcuts, inside the window
you want to change.
The range (75%–150%)
Zoom moves along a seven-step ladder — 75%, 87.5%, 100%, 112.5%, 125%, 137.5%, 150% — so 100% is the middle and you can go three steps smaller or three steps larger. This is deliberately the same scale as the app-wide Text size feature, so it feels like the size control you already know — just per-window, and scaling everything on the window instead of only the text. Stepping past either end simply stays at that end (it won't go below 75% or above 150%).
Saved per window
Each window remembers its own zoom across close and reopen:
- Session pop-outs remember per session — pop out session A at 125% and session B at 90%, and each comes back at its own level next time.
- Project / integration windows remember per project.
- The single-instance windows — KMS, Scratchpad, Quick Launch, Job Monitor, Support Chat, and Clipboard History — each remember their own one zoom.
A brand-new window that you have never zoomed simply opens at its normal 100% — nothing is stored until the first time you actually change the zoom.
Which windows are in scope
Per-window zoom applies to these pop-out / secondary windows:
- Session pop-out (a session opened in its own window via Pop Out)
- Project or integration window ("Open in new window")
- KMS window
- Scratchpad window
- Quick Launch window
- Job Monitor window
- Support Chat window
- Clipboard History window
It does not apply to:
- The main Omniscio window — never gets per-window CSS zoom; it keeps the app-wide Text size instead, and there Ctrl+= / Ctrl+- and Ctrl+mouse-wheel step that Text size (see text-size.md). So Ctrl+scroll works on the main window too — it just steps the shared Text size rather than zooming one window.
- The embedded isolated-session view — this is a session rendered inside the main window (the experimental "Isolated session view" performance mode), so zooming it would zoom part of the main window; it is excluded and never zooms on its own.
- Mobile / the phone web app — zoom here is desktop-window-only; the phone keeps its own separate Mobile text size.
The editor nuance (KMS and Scratchpad)
KMS and Scratchpad hold a rich-text editor, and that editor already uses Ctrl+- for its own action (strikethrough). So per-window zoom is polite about it: while your cursor is inside a text field or the rich-text editor, the keyboard zoom shortcuts step aside so the editor keeps its own Ctrl+- (and Ctrl+=). To zoom one of those windows, either use Ctrl + mouse wheel (which always zooms, everywhere), or click somewhere outside the editor first and then press Ctrl + / -.
For agents
For agents — how it works
- The hook. One shared renderer hook,
useWindowZoom(zoomKey), is mounted once in each in-scope window's root component (DetachedSessionApp,DetachedWindowApp,KmsWindowApp,ScratchpadWindowApp,SupportChatWindowApp,quick-launch.tsx,job-monitor.tsx,clipboard-history-window.tsx). It applies the saved factor as the CSSzoomproperty ondocument.documentElement(style.setProperty('zoom', …)), owns the Ctrl+/-/0keys and Ctrl+wheel, and persists the factor (debounced ~300 ms, with apagehide/unmount flush so a zoom made right before closing still saves). PassingzoomKey = nulldisables the hook entirely — that is how the embedded isolated-view guest opts out (useWindowZoom(embedded ? null : \session:${sessionId}`)`). - Independence is structural. The main window simply never mounts the hook,
and CSS
zoomis per-document, so one window's zoom physically cannot reach another. There is noHostZoomMap, no per-window session partition, and no custom protocol — zero blast radius on auth / storage / cookies. The globalfontSize(Text size) still applies as the base in every window;zoommultiplies on top, exactly like a browser's default font size × page zoom. - The ladder + clamp. The 7-step ladder and the step/clamp helpers live in
/src/shared/window-zoom.ts(ZOOM_STEPS,clampZoom,stepZoom,DEFAULT_ZOOM = 1.0) — the single source of truth the hook, the Main-side clamp, and the tests all derive from. Factors are clamped both on the client and again server-side, and a non-finite value heals to 1.0. - The window-fill. CSS
zoomscales the whole document, so a pop-out shell sized100vhrenders at100vh × zoomand — zoomed out — stops covering a maximized window, exposing the dark window background (the fullscreen zoom-out bug). So the hook writes the factor to a--window-zoomCSS variable (in lock-step withzoom, from one shared value), and each pop-out's top-level shell uses the shared.window-zoom-fillclass —height: calc(100vh / var(--window-zoom, 1))in/src/renderer/src/styles/globals.css— so it spans the window at any zoom (the× zoomrender lands back on the window edge). Height-only (shells fill width viaauto); the1fallback is native100vhfor the main window and any window with no saved zoom, so it is a zero-regression drop-in. It applies to the shells that have a viewport fill (DetachedSessionApp,DetachedWindowApp,KmsWindowApp,ScratchpadWindowApp,SupportChatWindowApp); the fixed/content-sized windows (Quick Launch, Job Monitor, Clipboard History) have no such shell and need nothing. The shared#rootneeds the same compensation. The?window=project/integration and?detached=session pop-outs reuse the mainindex.html#root, which isposition: fixed; overflow: hidden; height: 100dvh— so#rootALSO usesheight: calc(100dvh / var(--window-zoom, 1)). A bare100dvhthere renders at100dvh × zoom, so a zoomed-out#rootshrank to the top-left and CLIPPED the.window-zoom-fillshell, cramming the whole pop-out — header included — into a corner with a dark gap on the right + bottom (the reported Tasks pop-out bug). KMS and Scratchpad use their own roots (#kms-window-root/ their own) with no explicit height, so they were never affected — which is why only#rootneeded the fix, and why the KMS/Scratchpad zoom tests never caught it. - The frameless title strip. The frameless pop-outs (Tasks / project, KMS,
Scratchpad —
titleBarStyle: 'hidden', custom HTML caption buttons viaWindowControls) draw their own in-renderer title strip with custom min/max/close buttons. CSSzoomscales the whole document, so a bare fixed-height strip rendered atheight × zoom(51 px at 125 %, 31 px at 75 %) and the buttons no longer sat on it (the "messed up at the top" bug). Each frameless strip therefore carries the sibling.window-zoom-chromeclass —zoom: calc(1 / var(--window-zoom, 1))in the sameglobals.css— which counter-zooms the strip (net scale = zoom × 1/zoom = 1) so its height, label, KMS tabs, and caption buttons all render at their natural DIP size. The strips:StandaloneWindowTitleBar(Tasks + Scratchpad) andKmsWindowTitleBar(KMS)..window-zoom-fill(the shell) is unchanged and measured-correct at every zoom. - Floating overlays + tooltips. A
position: fixedoverlay (a tooltip bubble, a portaled menu / popover) has itstop/leftresolved in the pre-zoom layout space and then re-scaled by the window'szoomat paint — but it is positioned from agetBoundingClientRect()anchor that is ALREADY zoomed, so an uncorrected overlay lands atanchor × zoom: visibly ABOVE the trigger when zoomed out (the reported Tasks chevron-tooltip "way above the chevron" bug), below it when zoomed in, and the error grows with the anchor's distance down the window. The shared clamp primitivecontainToViewporttherefore clamps in visual space and then DIVIDES the finaltop/leftby the rootzoom— a privaterootZoom(node)that readsgetComputedStyle(<html>).zoom; any missing / non-positive / unparseable value heals to1, so it is a no-op at 100% and in the never-zoomed main window. Every native-title/ aria-label / hotkey tooltip is drawn by one bubble that positions through this helper, so they are all zoom-correct in a pop-out for free. Locked byoverlay-viewport-clamp-contract.mdI2a. (Sibling gaps still open, unreported:capToViewport's size cap andclampToViewport's flip carry the same latent re-scale under zoom — see that contract's CSS-root-zoom known-gap.) - The IPC channels. Two Zod-validated channels move the factor:
window:zoom-get(IPC.WINDOW_ZOOM_GET) returns{ factor: number | null }(null when nothing is saved), andwindow:zoom-set(IPC.WINDOW_ZOOM_SET) persists{ zoomKey, factor }(void). Handlers are in/src/main/ipc/window-zoom-handlers.tsviawrapHandler;zoomKeyis validated by a strict schema (length-capped, conservative charset) andfactormust be finite. - The table. Main persists one row per window identity in a
window_zoomSQLite table (zoom_key TEXT PRIMARY KEY,factor REAL,updated_at TEXT), created by thecreate-window-zoommigration and read/written through/src/main/db/queries-window-zoom.ts(getWindowZoom/saveWindowZoom, anINSERT … ON CONFLICTupsert). ThezoomKeyis the window identity:session:<id>,project-window:<id>, or a fixed singleton key (kms,scratchpad,quicklaunch,jobmonitor,support-chat,clipboard-history).
The invariants (main-window-never-zoomed, per-document keying, null-key exclusion,
server-side clamp, zoom-compensated window-fill, zoom-immune chrome strip) are locked by
the contract at /.claude/memory/contracts/per-window-zoom-contract.md and its guard
tests.
Related
Related
- text-size.md — the app-wide Text size feature (Desktop / Mobile), the sibling model this reuses the ladder from; it changes text everywhere, whereas per-window zoom is per-window and scales everything.
- detached-session-window.md — how a session pops out into its own window (one of the windows this zooms).
- pop-out-project-window.md — popping a whole project or integration panel into its own window.
- isolated-session-view.md — the embedded in-window guest that is deliberately excluded from per-window zoom.
Last verified 2026-09-23