diff --git a/.github/pr-evidence/ui-scale-100.png b/.github/pr-evidence/ui-scale-100.png new file mode 100644 index 00000000..ec326216 Binary files /dev/null and b/.github/pr-evidence/ui-scale-100.png differ diff --git a/.github/pr-evidence/ui-scale-80.png b/.github/pr-evidence/ui-scale-80.png new file mode 100644 index 00000000..b8cb602a Binary files /dev/null and b/.github/pr-evidence/ui-scale-80.png differ diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index fa027314..7285e9a1 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -2413,6 +2413,13 @@ const getMenuTargetWindow = () => { const dispatchMenuAction = (action) => { const target = getMenuTargetWindow(); + // Zoom actions are consumed by the renderer's DOM listener. Sending them + // through both the IPC bridge and the DOM event would invoke the handler + // multiple times because preload fans the IPC event back into both paths. + if (action === 'zoom-in' || action === 'zoom-out' || action === 'zoom-reset') { + dispatchDomEventToWindow(target, 'openchamber:menu-action', action); + return; + } emitToWindow(target, 'openchamber:menu-action', action); dispatchDomEventToWindow(target, 'openchamber:menu-action', action); }; @@ -4963,6 +4970,10 @@ const buildMacMenu = () => { { role: 'minimize' }, { role: 'zoom' }, { type: 'separator' }, + { label: 'Zoom In', accelerator: 'CmdOrCtrl+=', click: () => dispatchAction('zoom-in') }, + { label: 'Zoom Out', accelerator: 'CmdOrCtrl+-', click: () => dispatchAction('zoom-out') }, + { label: 'Reset Zoom', accelerator: 'CmdOrCtrl+0', click: () => dispatchAction('zoom-reset') }, + { type: 'separator' }, { role: 'close' }, ], }, @@ -5076,6 +5087,9 @@ const buildAutoHiddenMenu = () => { label: 'Window', submenu: [ { role: 'minimize' }, + { label: 'Zoom In', accelerator: 'Ctrl+=', click: () => dispatchAction('zoom-in') }, + { label: 'Zoom Out', accelerator: 'Ctrl+-', click: () => dispatchAction('zoom-out') }, + { label: 'Reset Zoom', accelerator: 'Ctrl+0', click: () => dispatchAction('zoom-reset') }, { role: 'togglefullscreen' }, { type: 'separator' }, { role: 'close' }, diff --git a/packages/ui/src/components/browser/BrowserPane.tsx b/packages/ui/src/components/browser/BrowserPane.tsx index 638b9547..216f9448 100644 --- a/packages/ui/src/components/browser/BrowserPane.tsx +++ b/packages/ui/src/components/browser/BrowserPane.tsx @@ -588,6 +588,20 @@ const WebviewBrowser: React.FC = ({ initialUrl, directory, tab } }, []); + React.useEffect(() => { + const handleZoom = (event: Event) => { + if (!(event instanceof CustomEvent)) return; + const action = event.detail; + const webview = webviewRef.current; + if (!webview || document.activeElement !== webview) return; + if (action === 'zoom-in') applyZoom(zoomLevel + ZOOM_STEP); + else if (action === 'zoom-out') applyZoom(zoomLevel - ZOOM_STEP); + else if (action === 'zoom-reset') applyZoom(0); + }; + window.addEventListener('openchamber:zoom', handleZoom); + return () => window.removeEventListener('openchamber:zoom', handleZoom); + }, [applyZoom, zoomLevel]); + const clearBrowsingData = React.useCallback((what: 'cookies' | 'cache') => { void invokeDesktopCommand('desktop_browser_clear_data', { partition: BROWSER_PARTITION, diff --git a/packages/ui/src/components/chat/DraftPresetChips.tsx b/packages/ui/src/components/chat/DraftPresetChips.tsx index 31230b64..0b5bd058 100644 --- a/packages/ui/src/components/chat/DraftPresetChips.tsx +++ b/packages/ui/src/components/chat/DraftPresetChips.tsx @@ -94,7 +94,7 @@ const SortableChip: React.FC<{ {...attributes} {...listeners} onClick={() => onSubmit(item)} - className="group inline-flex touch-none select-none items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground" + className="group inline-flex touch-none select-none items-center gap-1.5 rounded-full border px-3 py-1.5 typography-ui-label text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground" style={chipStyle} title={item.shared ? t('chat.draftStarters.sharedTitle') : undefined} > diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index abccd7d6..5b4c6d29 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -310,7 +310,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) { > {selectedProject.kind === 'chat' - ? {t('chat.chatInput.chooseProject')} + ? {t('chat.chatInput.chooseProject')} : } @@ -498,7 +498,7 @@ export function MobileDraftTargetTriggers( onClick={() => onOpenPicker('project')} > {selectedProject.kind === 'chat' - ? {t('chat.chatInput.chooseProject')} + ? {t('chat.chatInput.chooseProject')} : } diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 0b7eef5d..ed8de6ba 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -52,7 +52,7 @@ import { import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry'; import { isTerminalEventTarget } from '@/lib/terminalFocus'; -const CONTEXT_PANEL_MIN_WIDTH = 380; +const CONTEXT_PANEL_MIN_WIDTH = 320; const CONTEXT_PANEL_MAX_WIDTH = 1400; const CONTEXT_PANEL_DEFAULT_WIDTH = 600; const RESIZE_FOLLOW_INTERVAL_MS = 100; @@ -484,10 +484,21 @@ export const ContextPanel: React.FC = () => { const [availablePanelAreaWidth, setAvailablePanelAreaWidth] = React.useState(null); const activeModeForWidth = activeTab?.mode ?? null; const manualWidth = activeModeForWidth ? panelState?.widthByMode?.[activeModeForWidth] : undefined; + const manualWidthFraction = activeModeForWidth ? panelState?.widthFractionByMode?.[activeModeForWidth] : undefined; const widthFraction = activeModeForWidth ? getContextSurfaceWidthFraction(activeModeForWidth) : 0.5; const widthFallbackBase = availablePanelAreaWidth ?? (typeof window !== 'undefined' ? window.innerWidth : CONTEXT_PANEL_DEFAULT_WIDTH * 2); - const width = clampWidth(manualWidth ?? Math.round(widthFraction * widthFallbackBase)); + const effectiveManualWidth = manualWidthFraction != null && availablePanelAreaWidth != null + ? Math.round(manualWidthFraction * availablePanelAreaWidth) + : manualWidth; + const width = clampWidth(effectiveManualWidth ?? Math.round(widthFraction * widthFallbackBase)); + + // Convert legacy pixel-only preferences to a ratio the first time the + // available area is known, so existing users also get responsive sizing. + React.useEffect(() => { + if (!directoryKey || !activeModeForWidth || manualWidthFraction != null || manualWidth == null || availablePanelAreaWidth == null) return; + setContextPanelWidth(directoryKey, activeModeForWidth, manualWidth, availablePanelAreaWidth); + }, [activeModeForWidth, availablePanelAreaWidth, directoryKey, manualWidth, manualWidthFraction, setContextPanelWidth]); const chatSessionIDs = React.useMemo(() => { const ids: string[] = []; for (const tab of tabs) { @@ -592,6 +603,7 @@ export const ContextPanel: React.FC = () => { // Apply the final width once, letting the regular 200ms width transition // carry the panel to the release position. const finalWidth = clampWidthForDrag(resizingWidthRef.current ?? width); + const availableWidth = resizeAvailableWidthRef.current; resizingWidthRef.current = null; resizeAvailableWidthRef.current = null; if (resizeFollowTimerRef.current !== null) { @@ -600,7 +612,7 @@ export const ContextPanel: React.FC = () => { } document.documentElement.style.cursor = ''; if (directoryKey && activeModeForWidth) { - setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth); + setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth, availableWidth ?? undefined); } setIsResizing(false); activeResizePointerIDRef.current = null; diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 3f2af834..bcca36af 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -1076,7 +1076,9 @@ export const Header: React.FC = () => { // `--oc-titlebar-left-inset` so the sidebar strip can mirror it. const titlebarLeftInset = React.useMemo(() => { if (isDesktopApp && isMacPlatform && !isDesktopWindowFullscreen) { - return '5.5rem'; + // Native traffic lights have a fixed physical footprint. Keep this + // clearance in pixels so shrinking the interface cannot overlap them. + return '88px'; } if (isTabletStandalonePwa) { return 'max(calc(0.75rem + var(--oc-wco-left-inset, 0px)), 5.5rem)'; @@ -1170,8 +1172,10 @@ export const Header: React.FC = () => { // Left inset is handled by the no-drag spacer (see renderDesktop); only // the right inset / titlebar height are owned by the window-controls overlay. paddingRight: 'calc(0.75rem + var(--oc-wco-right-inset, 0px))', - minHeight: 'max(3rem, var(--oc-wco-titlebar-height, 0px))', - height: 'max(3rem, var(--oc-wco-titlebar-height, 0px))', + // Keep the titlebar safe area in physical pixels. Interface zoom may + // shrink rem content, but native macOS traffic lights must never overlap it. + minHeight: 'max(56px, var(--oc-wco-titlebar-height, 0px))', + height: 'max(56px, var(--oc-wco-titlebar-height, 0px))', }; }, [isDesktopApp, isVSCode, usesFramelessChrome, windowControlsSide]); diff --git a/packages/ui/src/components/layout/Sidebar.tsx b/packages/ui/src/components/layout/Sidebar.tsx index 77a7b32d..f18d3a3a 100644 --- a/packages/ui/src/components/layout/Sidebar.tsx +++ b/packages/ui/src/components/layout/Sidebar.tsx @@ -6,7 +6,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; const SIDEBAR_CONTENT_WIDTH = 280; -const SIDEBAR_MIN_WIDTH = 280; +const SIDEBAR_MIN_WIDTH = 168; const SIDEBAR_MAX_WIDTH = 500; interface SidebarProps { diff --git a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx index 84afce61..58883fb5 100644 --- a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx +++ b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx @@ -264,7 +264,8 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { className={cn( 'flex items-center gap-3 bg-background', usesFramelessChrome && windowControlsSide === 'right' ? 'pr-0' : 'pr-3', - hasMacTrafficLights ? 'pl-[5.5rem]' : 'pl-3', + // Native traffic lights are fixed-size OS chrome, not scaled UI. + hasMacTrafficLights ? 'pl-[88px]' : 'pl-3', usesFramelessChrome ? 'h-12' : macosHeaderSizeClass || 'min-h-14', )} style={dragRegionStyle} diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index 36323a3b..7543588a 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -208,7 +208,7 @@ export const MultiRunLauncher: React.FC = ({ const desktopHeaderPaddingClass = React.useMemo(() => { if ((isDesktopApp && isMacPlatform) || isTabletStandalonePwa) { // Match main app header: reserve space for Mac/iPadOS traffic lights. - return 'pl-[5.5rem]'; + return 'pl-[88px]'; } return 'pl-3'; }, [isDesktopApp, isMacPlatform, isTabletStandalonePwa]); diff --git a/packages/ui/src/components/providers/ThemeProvider.tsx b/packages/ui/src/components/providers/ThemeProvider.tsx index 918aa272..3086bab0 100644 --- a/packages/ui/src/components/providers/ThemeProvider.tsx +++ b/packages/ui/src/components/providers/ThemeProvider.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { isTerminalEventTarget } from '@/lib/terminalFocus'; import { useUIStore } from '@/stores/useUIStore'; interface ThemeProviderProps { @@ -16,5 +17,32 @@ export const ThemeProvider: React.FC = ({ children }) => { applyPadding(); }, [fontSize, applyTypography, padding, applyPadding]); + React.useEffect(() => { + const handleZoom = (event: Event) => { + if (!(event instanceof CustomEvent)) return; + const action = event.detail; + if (action !== 'zoom-in' && action !== 'zoom-out' && action !== 'zoom-reset') return; + const active = document.activeElement; + if (active?.tagName === 'WEBVIEW' || active?.closest('webview')) return; + const state = useUIStore.getState(); + const isTerminal = isTerminalEventTarget(active) + || active?.matches('[data-terminal-hidden-input="true"]') === true; + const isEditor = active?.closest('.cm-editor') != null; + if (action === 'zoom-reset') { + if (isTerminal) state.setTerminalFontSize(14); + else if (isEditor) state.setEditorFontSize(13); + else state.setFontSize(100); + } else if (isTerminal) { + state.setTerminalFontSize(state.terminalFontSize + (action === 'zoom-in' ? 1 : -1)); + } else if (isEditor) { + state.setEditorFontSize(state.editorFontSize + (action === 'zoom-in' ? 1 : -1)); + } else { + state.setFontSize(state.fontSize + (action === 'zoom-in' ? 10 : -10)); + } + }; + window.addEventListener('openchamber:zoom', handleZoom); + return () => window.removeEventListener('openchamber:zoom', handleZoom); + }, []); + return <>{children}; }; diff --git a/packages/ui/src/components/session/SessionSwitcherDropdown.tsx b/packages/ui/src/components/session/SessionSwitcherDropdown.tsx index 0cfba1ec..35cc65e4 100644 --- a/packages/ui/src/components/session/SessionSwitcherDropdown.tsx +++ b/packages/ui/src/components/session/SessionSwitcherDropdown.tsx @@ -143,7 +143,7 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP )} > - + {t('sessions.sidebar.header.actions.newSession')} @@ -293,7 +293,7 @@ function SwitcherRow({ session, depth, variant, secondaryMeta, hasChildren, isEx {isExpanded ? : } ) : null} - + {sessionTitle} diff --git a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx index e92a9d2b..f1e85efd 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx @@ -1146,7 +1146,7 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo {...(dragHandleProps?.listeners ?? {})} >
-

+

{group.isArchivedBucket ? ( diff --git a/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx b/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx index 3e29f58c..68ef5ef2 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx +++ b/packages/ui/src/components/session/sidebar/projects/SessionProjectScroller.tsx @@ -425,7 +425,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode { ) : ( <> - + {t('sessions.sidebar.activity.recentTitle')} diff --git a/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx b/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx index 00b0ab08..e49593a4 100644 --- a/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx +++ b/packages/ui/src/components/session/sidebar/projects/sortableItems.tsx @@ -96,7 +96,7 @@ export const ProjectHeaderIdentity: React.FC = ({ )} - {projectLabel} + {projectLabel} ); }; diff --git a/packages/ui/src/components/session/sidebar/recent/SidebarActivitySections.tsx b/packages/ui/src/components/session/sidebar/recent/SidebarActivitySections.tsx index 1f75ff6f..69401ebf 100644 --- a/packages/ui/src/components/session/sidebar/recent/SidebarActivitySections.tsx +++ b/packages/ui/src/components/session/sidebar/recent/SidebarActivitySections.tsx @@ -264,7 +264,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode { {isCollapsed ? : } - {section.title} + {section.title} {section.key === 'chats' && props.onNewChat ? (