From 977daf064377c41e3db50fc89ec21c133515e4e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enes=20=C3=96z?= <118854526+en970@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:07:08 +0300 Subject: [PATCH] Improve edge-docked window behavior (#2779) * Make edge-docked windows resizable Add draggable resize seams for left and right docked windows. Keep the main chat area from getting too narrow and remember each window's dock width. * Show emoji shortcodes as icons by default Keep text-only emoji mode opt-in so model output like :blush: goes through the normal emoji renderer. * Fix dock resize seams and left dock layout Hide the resize seam when another floating modal is open, and keep the left-docked window from covering the chat area. * Keep narrow modal tabs usable * Fix split layout with both edge docks * Fix left snap after right dock * Enable left edge snap for all windows * Tighten dock resize handle observers * Use edge docking for settings window --- static/app.js | 10 +- static/js/modalSnap.js | 335 +++++++++++++++++++++++++++++++++++++--- static/js/settings.js | 2 +- static/js/windowDrag.js | 10 +- static/style.css | 120 ++++++++++++-- 5 files changed, 434 insertions(+), 43 deletions(-) diff --git a/static/app.js b/static/app.js index 5621ef7dd..be94aef4c 100644 --- a/static/app.js +++ b/static/app.js @@ -2497,7 +2497,7 @@ function initializeEventListeners() { }; // Keys hidden by default on first run (no localStorage yet) - const UI_VIS_DEFAULT_OFF = new Set(['models-section', 'rag-toggle-btn']); + const UI_VIS_DEFAULT_OFF = new Set(['models-section', 'rag-toggle-btn', 'text-emojis']); // Keys that need admin to toggle off (reserved for future use) const UI_VIS_ADMIN_ONLY = new Set([]); @@ -2525,11 +2525,9 @@ function initializeEventListeners() { document.querySelectorAll('.section[draggable]').forEach(el => { el.setAttribute('draggable', dragEnabled ? 'true' : 'false'); }); - // Text-only emojis toggle. Default is ON (the checkbox defaults to - // checked because text-emojis isn't in UI_VIS_DEFAULT_OFF), so treat - // an absent value as enabled — otherwise the toggle looked on at - // startup but the effect only activated after the user flipped it. - applyTextEmojis(state['text-emojis'] !== false); + // Text-only emojis toggle. Default is OFF so model-emitted shortcodes + // like `:blush:` render through the normal monochrome emoji path. + applyTextEmojis(state['text-emojis'] === true); // Hide thinking sections toggle (show-thinking: checked=show, unchecked=hide) document.body.classList.toggle('hide-thinking', state['show-thinking'] === false); } diff --git a/static/js/modalSnap.js b/static/js/modalSnap.js index f3085bed6..e7cce55dd 100644 --- a/static/js/modalSnap.js +++ b/static/js/modalSnap.js @@ -5,8 +5,8 @@ // emailLibrary.js / documentLibrary.js / galleryEditor.js). While docked: // - the modal-content lives at `right: 0; top: 0; bottom: 0` with a // viewport-fraction width -// - body gets `right-dock-active` + `--right-dock-w` so the chat / -// doc panel / notes pane underneath reserves room via padding-right +// - body gets `right-dock-active` + `--right-dock-w` so the workspace +// underneath reserves room for the fixed side panel // - if the remaining chat width would drop under 380px, the wide // sidebar auto-collapses to the icon rail (mirrors notes-view UX) // @@ -21,6 +21,14 @@ const SNAP_PX = 60; const UNSNAP_PX = 80; const MIN_CHAT_WIDTH = 380; const EMAIL_DOC_SPLIT_WIDTH_KEY = 'odysseus-email-doc-split-width'; +const EDGE_DOCK_WIDTH_KEY_PREFIX = 'odysseus-edge-dock-width'; +const MIN_EDGE_DOCK_WIDTH = 320; + +let _edgeDockHandlePositioner = null; + +function _positionEdgeDockResizeHandles() { + try { _edgeDockHandlePositioner && _edgeDockHandlePositioner(); } catch (_) {} +} function _dockClassForSide(side) { return side === 'left' ? 'modal-left-docked' : 'modal-right-docked'; @@ -48,6 +56,7 @@ export function clearDockSide(side, owner = null) { if (side === 'left') { try { window._restoreSidebarIfRouteCollapsed?.(); } catch (_) {} } + _positionEdgeDockResizeHandles(); } // Default dock width: ~38% of viewport, clamped to a reasonable band. @@ -55,6 +64,78 @@ function _defaultDockWidth() { return Math.min(640, Math.max(420, Math.round(window.innerWidth * 0.38))); } +function _dockWidthStorageKey(modal, content, side) { + const id = modal?.id || content?.id || content?.dataset?.modalId || ''; + return id ? `${EDGE_DOCK_WIDTH_KEY_PREFIX}:${side}:${id}` : null; +} + +function _storedDockWidth(modal, content, side) { + const key = _dockWidthStorageKey(modal, content, side); + if (!key) return null; + try { + const n = parseFloat(localStorage.getItem(key) || ''); + return Number.isFinite(n) && n > 0 ? n : null; + } catch (_) { + return null; + } +} + +function _saveDockWidth(modal, content, side, width) { + const key = _dockWidthStorageKey(modal, content, side); + if (!key) return; + try { localStorage.setItem(key, String(Math.round(width))); } catch (_) {} +} + +function _minEdgeDockWidth() { + return window.innerWidth < 900 ? 280 : MIN_EDGE_DOCK_WIDTH; +} + +function _activeDockWidth(side) { + if (side !== 'left' && side !== 'right') return 0; + const cls = side === 'left' ? 'left-dock-active' : 'right-dock-active'; + if (!document.body.classList.contains(cls)) return 0; + const prop = side === 'left' ? '--left-dock-w' : '--right-dock-w'; + const raw = getComputedStyle(document.documentElement).getPropertyValue(prop); + const n = parseFloat(raw || ''); + return Number.isFinite(n) && n > 0 ? n : 0; +} + +function _clampDockWidthToSpace(width, min, max) { + const floor = Math.min(min, Math.max(220, Math.round(max))); + const ceiling = Math.max(floor, Math.round(max)); + return Math.min(ceiling, Math.max(floor, Math.round(width))); +} + +function _clampRightDockWidth(width) { + const min = _minEdgeDockWidth(); + const navRight = _leftNavRight(); + const leftDockW = _activeDockWidth('left'); + const maxByChat = window.innerWidth - navRight - leftDockW - MIN_CHAT_WIDTH; + const max = Math.min(Math.round(window.innerWidth * 0.82), maxByChat); + return _clampDockWidthToSpace(width, min, max); +} + +function _clampLeftDockWidth(width, left = _leftNavRight()) { + const min = _minEdgeDockWidth(); + const rightDockW = _activeDockWidth('right'); + const available = Math.max(0, window.innerWidth - left - rightDockW); + const max = Math.min(Math.round(available * 0.82), available - MIN_CHAT_WIDTH); + return _clampDockWidthToSpace(width, min, max); +} + +function _resolveRightDockWidth(modal, content) { + return _clampRightDockWidth(content?._userDockWidth || _storedDockWidth(modal, content, 'right') || _defaultDockWidth()); +} + +function _resolveLeftDockWidth(content, left = _leftNavRight()) { + return _clampLeftDockWidth(content?._userDockWidth || _storedDockWidth(content?._dockOwner, content, 'left') || _resolveEmailDocSplitWidth(content, left), left); +} + +function _isEmailDockOwner(owner) { + const id = owner?.id || ''; + return id === 'email-lib-modal' || id.startsWith('email-reader-') || owner?.classList?.contains('email-window-modal'); +} + function _showSnapHint(on, side = 'right') { const cls = side === 'left' ? 'modal-snap-hint-left' : 'modal-snap-hint-right'; let hint = document.querySelector('.' + cls); @@ -85,7 +166,7 @@ function _shouldAutoCollapseSidebar(dockW) { const rl = (rail && window.getComputedStyle(rail).display !== 'none') ? rail.getBoundingClientRect().width : 0; - const remaining = window.innerWidth - sb - rl - dockW; + const remaining = window.innerWidth - sb - rl - _activeDockWidth('left') - dockW; return remaining < MIN_CHAT_WIDTH; } @@ -154,7 +235,7 @@ function _applyEmailDocSplitGeometry(left, emailWidth) { if (!docPane || window.innerWidth <= 768) return; docPane.style.setProperty('position', 'fixed', 'important'); docPane.style.setProperty('left', `${x}px`, 'important'); - docPane.style.setProperty('right', '0px', 'important'); + docPane.style.setProperty('right', 'var(--right-dock-w, 0px)', 'important'); docPane.style.setProperty('top', '0px', 'important'); docPane.style.setProperty('bottom', '0px', 'important'); docPane.style.setProperty('width', 'auto', 'important'); @@ -196,7 +277,9 @@ function _resolveEmailDocSplitWidth(content, left) { function _anchorLeftDock(content) { if (!content || content._dockSide !== 'left') return; const left = _leftNavRight(); - const w = _resolveEmailDocSplitWidth(content, left); + const w = document.body.classList.contains('doc-view') + ? _resolveEmailDocSplitWidth(content, left) + : _resolveLeftDockWidth(content, left); content.style.left = left + 'px'; content.style.width = w + 'px'; content.style.maxWidth = w + 'px'; @@ -205,14 +288,17 @@ function _anchorLeftDock(content) { // the doc-pane becomes position:fixed starting at the email's right edge. // No flex/max-width fighting; the doc just owns the right side from the // email's right edge to the viewport edge — they touch flush, no gap. - const docOpen = document.body.classList.contains('doc-view'); + const docOpen = document.body.classList.contains('doc-view') && _isEmailDockOwner(content._dockOwner); if (docOpen) { if (!document.body.classList.contains('email-doc-split-active')) { document.body.classList.add('email-doc-split-active'); } + document.documentElement.style.setProperty('--left-dock-w', '0px'); _applyEmailDocSplitGeometry(left, w); } else if (document.body.classList.contains('email-doc-split-active')) { _clearEmailDocSplitGeometry(); + } else { + document.documentElement.style.setProperty('--left-dock-w', w + 'px'); } } @@ -316,19 +402,21 @@ function _applyDockInternal(modal, side, dockClass) { content.style.margin = '0'; let w; if (side === 'left') { - // Email-style left dock: collapse the sidebar to the icon rail, then - // OVERLAY the window beside the rail, covering the chat area. We anchor - // at the rail's right edge (so it sits to the RIGHT of the rail — not - // left of the sidebar) and DON'T reserve body padding (so it covers the - // chat rather than pushing it), leaving the right side free for the doc. + // Left dock: collapse the sidebar to the icon rail, then pin the window + // beside the rail. Normal left docks reserve their width so chat shrinks; + // the email+document split keeps its existing overlay geometry. _collapseSidebarToRail(); content._preDockSnapshot.collapsedSidebar = true; content.style.right = 'auto'; content._dockSide = 'left'; + content._dockOwner = modal; _anchorLeftDock(content); w = parseFloat(content.style.width) || 0; document.body.classList.add('left-dock-active'); - document.documentElement.style.setProperty('--left-dock-w', '0px'); // overlay, no push + document.documentElement.style.setProperty( + '--left-dock-w', + document.body.classList.contains('email-doc-split-active') ? '0px' : w + 'px', + ); // Re-anchor the email when the sidebar is toggled (expanded/collapsed) so // the nav slides the window over instead of growing on top of it. Also // re-anchor when the document editor pane appears/disappears (signaled by @@ -406,7 +494,7 @@ function _applyDockInternal(modal, side, dockClass) { }; } } else { - w = _defaultDockWidth(); + w = _resolveRightDockWidth(modal, content); content.style.left = 'auto'; content.style.right = '0'; content.style.width = w + 'px'; @@ -419,6 +507,8 @@ function _applyDockInternal(modal, side, dockClass) { } } content._dockSide = side; + content._dockOwner = modal; + _positionEdgeDockResizeHandles(); // Watch for the docked modal disappearing (removed from DOM or hidden // via .hidden class) and clean up the body padding + sidebar in that // case. Without this, closing a docked window leaves a phantom strip @@ -498,7 +588,9 @@ function _onDockedModalGone(modal, dockClass) { } delete _c._preDockSnapshot; delete _c._dockSide; + delete _c._dockOwner; } + _positionEdgeDockResizeHandles(); } function _expandSidebarFromRail() { @@ -526,6 +618,7 @@ export function clearRightDock(modal, cx, cy, dockClass) { _clearEmailDocSplitGeometry(); } delete content._dockSide; + delete content._dockOwner; _disconnectLeftDockObservers(content); const snap = content._preDockSnapshot; // Re-expand the wide sidebar if we collapsed it — but only if the @@ -571,6 +664,7 @@ export function clearRightDock(modal, cx, cy, dockClass) { content.style.top = (typeof targetTop === 'number') ? targetTop + 'px' : targetTop; delete content._preDockSnapshot; delete content._dockSuspended; + _positionEdgeDockResizeHandles(); } // Temporarily release a docked modal's body push (chat returns to full @@ -604,6 +698,7 @@ export function suspendDock(modal) { modal.classList.remove('email-snap-left'); _clearEmailDocSplitGeometry(); delete content._dockSide; + delete content._dockOwner; delete content._dockSuspended; return null; } @@ -614,6 +709,7 @@ export function suspendDock(modal) { _expandSidebarFromRail(); } content._dockSuspended = side; + _positionEdgeDockResizeHandles(); return side; } @@ -641,15 +737,11 @@ export function makeRightDockController(modal, dockClass = 'modal-right-docked') return makeEdgeDockController(modal, 'right', dockClass); } -// Read live rail+sidebar width — used as the LEFT "edge" for snap -// detection, since the visible left boundary the user can drag to is -// the nav, not x=0 (the rail covers 0..48 and the wide sidebar covers -// 0..~290 when open). +// Read the current visible left-nav edge for snap detection. Use measured +// geometry instead of CSS vars because the sidebar can auto-collapse during a +// dock operation while --sidebar-w is still settling. function _leftNavWidth() { - const rs = getComputedStyle(document.documentElement); - const rail = parseInt(rs.getPropertyValue('--icon-rail-w') || '48', 10) || 0; - const sb = parseInt(rs.getPropertyValue('--sidebar-w') || '0', 10) || 0; - return rail + sb; + return _leftNavRight(); } // Generic edge-snap controller. `side` is 'left' or 'right'. Same pattern @@ -692,6 +784,207 @@ export function makeEdgeDockController(modal, side = 'right', dockClass) { }; } +(function _initEdgeDockResizeHandles() { + if (typeof document === 'undefined') return; + if (!document.body) { + document.addEventListener('DOMContentLoaded', _initEdgeDockResizeHandles, { once: true }); + return; + } + + const handles = { + left: document.createElement('div'), + right: document.createElement('div'), + }; + const _setStyle = (el, prop, value) => { + if (el.style[prop] !== value) el.style[prop] = value; + }; + const _hideHandle = (handle) => _setStyle(handle, 'display', 'none'); + + for (const side of ['left', 'right']) { + const handle = handles[side]; + handle.className = `edge-dock-resize-handle edge-dock-resize-handle-${side}`; + handle.style.position = 'fixed'; + handle.style.top = '0'; + handle.style.bottom = '0'; + handle.style.width = '10px'; + handle.style.cursor = 'col-resize'; + handle.style.background = 'linear-gradient(to right, transparent 0 3px, color-mix(in srgb, var(--accent, var(--red)) 35%, transparent) 3px 7px, transparent 7px 10px)'; + handle.style.pointerEvents = 'auto'; + handle.style.touchAction = 'none'; + handle.style.display = 'none'; + handle.title = 'Drag to resize docked window'; + document.body.appendChild(handle); + } + + const _isUsableDockOwner = (owner) => { + if (!owner || !owner.isConnected) return false; + if (owner.classList?.contains('hidden')) return false; + if (owner.style?.display === 'none') return false; + const nodes = _resolveDockNodes(owner); + const content = nodes?.content; + if (!content || !content.isConnected) return false; + if (content.classList?.contains('hidden')) return false; + if (content.style?.display === 'none') return false; + const r = content.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }; + + const _activeDockOwner = (side) => { + const cls = _dockClassForSide(side); + const all = Array.from(document.querySelectorAll(`.${cls}`)); + for (const owner of all.reverse()) { + if (_isUsableDockOwner(owner)) return owner; + } + return null; + }; + + const _zIndexFor = (el, fallback = 250) => { + const raw = el ? window.getComputedStyle(el).zIndex : ''; + const n = parseInt(raw, 10); + return Number.isFinite(n) ? n : fallback; + }; + + const _hasVisibleFloatingModal = (owner) => { + const all = Array.from(document.querySelectorAll('.modal:not(.hidden):not(.modal-minimized)')); + return all.some((modal) => { + if (!modal || modal === owner) return false; + if (owner?.contains?.(modal) || modal.contains?.(owner)) return false; + if (modal.classList.contains('modal-left-docked') + || modal.classList.contains('modal-right-docked') + || modal.classList.contains('email-snap-left')) return false; + if (modal.style.display === 'none') return false; + const content = _resolveDockNodes(modal)?.content; + const r = content?.getBoundingClientRect?.(); + return !!r && r.width > 0 && r.height > 0; + }); + }; + + const _setWidth = (owner, side, clientX) => { + const nodes = _resolveDockNodes(owner); + const content = nodes?.content; + if (!content) return 0; + let w = 0; + if (side === 'right') { + w = _clampRightDockWidth(window.innerWidth - clientX); + content._userDockWidth = w; + content.style.left = 'auto'; + content.style.right = '0'; + content.style.width = w + 'px'; + content.style.maxWidth = w + 'px'; + document.body.classList.add('right-dock-active'); + document.documentElement.style.setProperty('--right-dock-w', w + 'px'); + if (_shouldAutoCollapseSidebar(w)) { + _collapseSidebarToRail(); + if (content._preDockSnapshot) content._preDockSnapshot.collapsedSidebar = true; + } + } else { + const left = _leftNavRight(); + w = _clampLeftDockWidth(clientX - left, left); + content._userDockWidth = w; + content._emailDocSplitUserW = w; + content.style.left = left + 'px'; + content.style.right = 'auto'; + content.style.width = w + 'px'; + content.style.maxWidth = w + 'px'; + document.body.classList.add('left-dock-active'); + document.documentElement.style.setProperty( + '--left-dock-w', + document.body.classList.contains('email-doc-split-active') ? '0px' : w + 'px', + ); + } + _positionEdgeDockResizeHandles(); + return w; + }; + + _edgeDockHandlePositioner = () => { + const splitOwnsLeftSeam = document.body.classList.contains('email-doc-split-active') + && document.body.classList.contains('doc-view') + && window.innerWidth > 768; + for (const side of ['left', 'right']) { + const handle = handles[side]; + if (window.innerWidth <= 768 || (side === 'left' && splitOwnsLeftSeam)) { + _hideHandle(handle); + continue; + } + const owner = _activeDockOwner(side); + const content = owner && _resolveDockNodes(owner)?.content; + if (!content) { + _hideHandle(handle); + continue; + } + if (_hasVisibleFloatingModal(owner)) { + _hideHandle(handle); + continue; + } + const r = content.getBoundingClientRect(); + const x = side === 'right' ? r.left : r.right; + if (!Number.isFinite(x) || x <= 0 || x >= window.innerWidth) { + _hideHandle(handle); + continue; + } + _setStyle(handle, 'display', 'block'); + _setStyle(handle, 'left', (x - 5) + 'px'); + _setStyle(handle, 'zIndex', String(_zIndexFor(owner) + 1)); + } + }; + + for (const side of ['left', 'right']) { + const handle = handles[side]; + handle.addEventListener('pointerdown', (e) => { + if (handle.style.display === 'none') return; + const owner = _activeDockOwner(side); + if (!owner) return; + e.preventDefault(); + e.stopPropagation(); + handle.setPointerCapture?.(e.pointerId); + const nodes = _resolveDockNodes(owner); + const content = nodes?.content; + const prevCursor = document.body.style.cursor; + const prevUserSelect = document.body.style.userSelect; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + document.body.classList.add('edge-dock-resizing'); + _setWidth(owner, side, e.clientX); + const onMove = (ev) => { + ev.preventDefault(); + _setWidth(owner, side, ev.clientX); + }; + const onUp = (ev) => { + try { handle.releasePointerCapture?.(e.pointerId); } catch (_) {} + document.removeEventListener('pointermove', onMove, true); + document.removeEventListener('pointerup', onUp, true); + document.removeEventListener('pointercancel', onUp, true); + document.body.classList.remove('edge-dock-resizing'); + document.body.style.cursor = prevCursor; + document.body.style.userSelect = prevUserSelect; + const finalW = side === 'right' + ? parseFloat(document.documentElement.style.getPropertyValue('--right-dock-w')) || content?.getBoundingClientRect?.().width || 0 + : content?.getBoundingClientRect?.().width || 0; + if (finalW) _saveDockWidth(owner, content, side, finalW); + ev.preventDefault(); + }; + document.addEventListener('pointermove', onMove, true); + document.addEventListener('pointerup', onUp, true); + document.addEventListener('pointercancel', onUp, true); + }); + } + + new MutationObserver(_positionEdgeDockResizeHandles).observe(document.body, { attributes: true, attributeFilter: ['class'] }); + new MutationObserver(_positionEdgeDockResizeHandles).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }); + let raf = 0; + const schedulePosition = () => { + if (raf) return; + raf = requestAnimationFrame(() => { + raf = 0; + _positionEdgeDockResizeHandles(); + }); + }; + new MutationObserver(schedulePosition).observe(document.body, { childList: true }); + window.addEventListener('resize', _positionEdgeDockResizeHandles); + window.addEventListener('odysseus:modal-opened', _positionEdgeDockResizeHandles); + _positionEdgeDockResizeHandles(); +})(); + (function _initSplitSeamIndicator() { if (typeof document === 'undefined') return; const stripe = document.createElement('div'); diff --git a/static/js/settings.js b/static/js/settings.js index 068cd80e2..f9e76558c 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -53,7 +53,7 @@ function initDrag() { content, header, skipSelector: 'button, input, select, .theme-opacity-wrap', - enableDock: false, + enableDock: true, }); } diff --git a/static/js/windowDrag.js b/static/js/windowDrag.js index 7c16a531f..5e7cb0c9d 100644 --- a/static/js/windowDrag.js +++ b/static/js/windowDrag.js @@ -93,11 +93,11 @@ export function makeWindowDraggable(modal, options = {}) { } const rightDock = enableDock ? makeEdgeDockController(modal, 'right') : null; - // Left dock is opt-in (enableLeftDock). For most windows it's off — the - // sidebar lives on the left, so a left dock collides with it. The email - // window enables it so you can park the message on the left and read it - // while replying in the document on the right. - const leftDock = (enableDock && options.enableLeftDock) ? makeEdgeDockController(modal, 'left') : null; + // Left dock is enabled by default too. modalSnap collapses the wide sidebar + // and anchors the panel beside the icon rail, so it no longer collides with + // the navigation. Callers can still pass enableLeftDock:false for a special + // modal that should only dock right. + const leftDock = (enableDock && options.enableLeftDock !== false) ? makeEdgeDockController(modal, 'left') : null; // Per-drag state, reset on mousedown. let dragging = false; diff --git a/static/style.css b/static/style.css index 2c79b51df..c7a21637c 100644 --- a/static/style.css +++ b/static/style.css @@ -97,9 +97,9 @@ html, body { overflow-x: hidden; height: 100%; margin: 0; overscroll-behavior: n body { background-color: var(--bg); color: var(--fg); - /* Animate the dock push BOTH ways. Keeping the transition on the base body - (not on .right/left-dock-active) means removing the class on undock also - animates padding back to 0 — otherwise the chat snapped back instantly. */ + /* Keep the base padding transition for older layout paths that still adjust + the body directly. Edge docks reserve workspace room on the flex panes + below so left + right docks can coexist without skewing the whole body. */ transition: padding-left 160ms cubic-bezier(0.22, 0.61, 0.36, 1), padding-right 160ms cubic-bezier(0.22, 0.61, 0.36, 1); font-family: var(--font-family, 'Fira Code', monospace); @@ -1773,6 +1773,8 @@ body.bg-pattern-sparkles { min-width:0; margin-top:8px; margin-bottom: 0; + transition: margin-left 160ms cubic-bezier(0.22, 0.61, 0.36, 1), + margin-right 160ms cubic-bezier(0.22, 0.61, 0.36, 1); } .chat-meta { font-size:12px; color:color-mix(in srgb, var(--fg) 60%, transparent); margin-bottom:6px; } .chat-history { @@ -4939,6 +4941,15 @@ body.bg-pattern-sparkles { pointer-events:auto; animation: modal-enter 0.25s ease-out both; } + .memory-modal-content, + .tasks-modal-content, + .preset-modal-content, + #cookbook-modal .modal-content, + #theme-popup, + .doclib-modal-content, + .gallery-modal-content { + container-type: inline-size; + } .modal-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; cursor:grab; user-select:none; @@ -14843,7 +14854,7 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn { body.email-doc-split-active.doc-view .doc-editor-pane { position: fixed !important; left: var(--email-doc-split-right-x, 420px) !important; - right: 0 !important; + right: var(--right-dock-w, 0px) !important; top: 0 !important; bottom: 0 !important; width: auto !important; @@ -14864,15 +14875,21 @@ body [data-act="from-sender"] { display: none !important; } -/* Snap-to-right docking. A modal dragged to the right edge becomes a - docked side panel (mirrors Notes/Doc panels). Body reserves space via - padding-right so the chat / notes / doc panel underneath shrinks to - fit instead of being hidden behind the panel. */ +/* Edge docking. Docked panels are fixed to the viewport edge; the workspace + panes reserve room with margins so left + right docks can be active at the + same time without skewing the entire body box. */ body.right-dock-active { - padding-right: var(--right-dock-w, 0px); + padding-right: 0; } body.left-dock-active { - padding-left: var(--left-dock-w, 0px); + padding-left: 0; +} +body.left-dock-active:not(.email-doc-split-active) .chat-container { + margin-left: var(--left-dock-w, 0px); +} +body.right-dock-active .chat-container, +body.right-dock-active:not(.email-doc-split-active) .doc-editor-pane { + margin-right: var(--right-dock-w, 0px); } .modal.modal-right-docked { align-items: stretch; @@ -23192,6 +23209,89 @@ input.settings-select::placeholder { color: color-mix(in srgb, var(--fg) 35%, tr opacity: 1; border-bottom-color: var(--red); } + +/* Narrow modal tab strips should stay on one row. Resized docked windows can + be much narrower than the viewport, so this cannot live only in mobile media + queries. */ +.cookbook-tabs, +.memory-tabs, +.admin-tabs, +.lib-tabs, +.gallery-tabs, +.preset-tabs { + flex-wrap: nowrap !important; + overflow-x: auto !important; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + scrollbar-width: none; +} +.cookbook-tabs::-webkit-scrollbar, +.memory-tabs::-webkit-scrollbar, +.admin-tabs::-webkit-scrollbar, +.lib-tabs::-webkit-scrollbar, +.gallery-tabs::-webkit-scrollbar, +.preset-tabs::-webkit-scrollbar { + display: none; +} +.cookbook-tabs > *, +.memory-tabs > *, +.admin-tabs > *, +.lib-tabs > *, +.gallery-tabs > *, +.preset-tabs > * { + flex: 0 0 auto; +} +.cookbook-tab, +.memory-tab, +.admin-tab, +.lib-tab, +.gallery-tab, +.preset-tab { + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + line-height: 1; +} +.gallery-tab { + gap: 6px; +} + +@container (max-width: 360px) { + .cookbook-tab:has(svg), + .memory-tab:has(svg), + .admin-tab:has(svg), + .lib-tab:has(svg), + .gallery-tab:has(svg), + .preset-tab:has(svg) { + width: 34px; + min-width: 34px; + padding-left: 0; + padding-right: 0; + font-size: 0; + } + + .cookbook-tab:has(svg) svg, + .memory-tab:has(svg) svg, + .admin-tab:has(svg) svg, + .lib-tab:has(svg) svg, + .gallery-tab:has(svg) svg, + .preset-tab:has(svg) svg { + width: 14px; + height: 14px; + margin-right: 0 !important; + vertical-align: middle !important; + } + + .memory-tab:has(svg) .memory-count, + .gallery-tab:has(svg) .gallery-tab-label, + .gallery-tab:has(svg) .gallery-tab-close, + .cookbook-tab:has(svg) .cookbook-tab-count, + .preset-tab:has(svg) .preset-count { + display: none !important; + } +} /* Icon + label layout inside each tab. */ .gallery-tab { display: inline-flex;