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 36792964..acc378d0 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -2308,6 +2308,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:zoom', action); + return; + } emitToWindow(target, 'openchamber:menu-action', action); dispatchDomEventToWindow(target, 'openchamber:menu-action', action); }; @@ -4864,6 +4871,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' }, ], }, @@ -4977,6 +4988,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..b8961bca 100644 --- a/packages/ui/src/components/browser/BrowserPane.tsx +++ b/packages/ui/src/components/browser/BrowserPane.tsx @@ -111,6 +111,7 @@ const WebviewBrowser: React.FC = ({ initialUrl, directory, tab const [isAnnotating, setIsAnnotating] = React.useState(false); const [isWaitingForServer, setIsWaitingForServer] = React.useState(false); const [zoomLevel, setZoomLevel] = React.useState(0); + const zoomLevelRef = React.useRef(0); const [showDeviceBar, setShowDeviceBar] = React.useState(false); const [viewport, setViewport] = React.useState(FILL_VIEWPORT); // Read inside agent actions, which are not re-created when the viewport @@ -580,6 +581,7 @@ const WebviewBrowser: React.FC = ({ initialUrl, directory, tab const applyZoom = React.useCallback((level: number) => { const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level)); + zoomLevelRef.current = next; setZoomLevel(next); try { webviewRef.current?.setZoomLevel(next); @@ -588,6 +590,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(zoomLevelRef.current + ZOOM_STEP); + else if (action === 'zoom-out') applyZoom(zoomLevelRef.current - ZOOM_STEP); + else if (action === 'zoom-reset') applyZoom(0); + }; + window.addEventListener('openchamber:zoom', handleZoom); + return () => window.removeEventListener('openchamber:zoom', handleZoom); + }, [applyZoom]); + 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..3e384f6a 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..a8821181 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) { @@ -509,8 +520,7 @@ export const ContextPanel: React.FC = () => { const chatFrameSrcByTabIDRef = React.useRef>(new Map()); const wasOpenRef = React.useRef(false); - // Tracks the panel area width so fraction-based surface defaults stay - // proportional as the window resizes; manual widths remain fixed px. + // Defaults and manually resized surfaces track the same available area. React.useLayoutEffect(() => { const parent = panelRef.current?.parentElement; if (!parent || typeof ResizeObserver === 'undefined') { @@ -592,6 +602,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 +611,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..45f12066 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)'; 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.test.tsx b/packages/ui/src/components/providers/ThemeProvider.test.tsx new file mode 100644 index 00000000..69c3e8bb --- /dev/null +++ b/packages/ui/src/components/providers/ThemeProvider.test.tsx @@ -0,0 +1,73 @@ +import React, { act } from 'react'; +import { expect, test } from 'bun:test'; +import { Window } from 'happy-dom'; +import { ThemeProvider } from './ThemeProvider'; +import { useUIStore } from '@/stores/useUIStore'; + +test('zoom works without App menu listeners and routes by focused content', async () => { + const dom = new Window({ url: 'http://localhost' }); + const originals = new Map(); + for (const [name, value] of Object.entries({ + window: dom, document: dom.document, navigator: dom.navigator, + Element: dom.Element, HTMLElement: dom.HTMLElement, Node: dom.Node, + Event: dom.Event, CustomEvent: dom.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, + })) { + originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name)); + Object.defineProperty(globalThis, name, { configurable: true, writable: true, value }); + } + const { createRoot } = await import('react-dom/client'); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + useUIStore.setState({ fontSize: 100, terminalFontSize: 14, editorFontSize: 13 }); + const zoom = (action: string) => window.dispatchEvent(new CustomEvent('openchamber:zoom', { detail: action })); + try { + await act(async () => root.render()); + await act(async () => { zoom('zoom-in'); zoom('zoom-in'); }); + expect(useUIStore.getState().fontSize).toBe(120); + expect(document.documentElement.style.fontSize).toBe('120%'); + + const terminal = document.createElement('input'); + terminal.dataset.terminalOwner = 'test-terminal'; + container.append(terminal); + terminal.focus(); + await act(async () => zoom('zoom-in')); + expect(useUIStore.getState().terminalFontSize).toBe(15); + expect(useUIStore.getState().fontSize).toBe(120); + await act(async () => zoom('zoom-reset')); + expect(useUIStore.getState().terminalFontSize).toBe(14); + + const editor = document.createElement('div'); + editor.className = 'cm-editor'; + const editorInput = document.createElement('textarea'); + editor.append(editorInput); + container.append(editor); + editorInput.focus(); + await act(async () => zoom('zoom-out')); + expect(useUIStore.getState().editorFontSize).toBe(12); + expect(useUIStore.getState().fontSize).toBe(120); + + const browser = document.createElement('webview'); + browser.tabIndex = 0; + container.append(browser); + browser.focus(); + expect(document.activeElement).toBe(browser); + await act(async () => zoom('zoom-in')); + expect(useUIStore.getState().fontSize).toBe(120); + + browser.blur(); + await act(async () => zoom('zoom-reset')); + expect(useUIStore.getState().fontSize).toBe(100); + expect(document.documentElement.style.fontSize).toBe(''); + await act(async () => root.unmount()); + zoom('zoom-in'); + expect(useUIStore.getState().fontSize).toBe(100); + } finally { + await act(async () => root.unmount()); + for (const [name, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + await dom.happyDOM.close(); + } +}); 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 ? (