fix(ui): integrate responsive interface scale (#3423)
Keep normal web headers at 48px, send native zoom directly to the focused main or Mini Chat window, and restore walkthrough widths through validated persisted ratios. Validated 40 context-panel tests, the focus-aware ThemeProvider regression, UI type-check and Electron syntax checks. Runtime smoke and combined workspace validation follow before publication.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -2308,6 +2308,13 @@ const getMenuTargetWindow = () => {
|
|||||||
|
|
||||||
const dispatchMenuAction = (action) => {
|
const dispatchMenuAction = (action) => {
|
||||||
const target = getMenuTargetWindow();
|
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);
|
emitToWindow(target, 'openchamber:menu-action', action);
|
||||||
dispatchDomEventToWindow(target, 'openchamber:menu-action', action);
|
dispatchDomEventToWindow(target, 'openchamber:menu-action', action);
|
||||||
};
|
};
|
||||||
@@ -4864,6 +4871,10 @@ const buildMacMenu = () => {
|
|||||||
{ role: 'minimize' },
|
{ role: 'minimize' },
|
||||||
{ role: 'zoom' },
|
{ role: 'zoom' },
|
||||||
{ type: 'separator' },
|
{ 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' },
|
{ role: 'close' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -4977,6 +4988,9 @@ const buildAutoHiddenMenu = () => {
|
|||||||
label: 'Window',
|
label: 'Window',
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: 'minimize' },
|
{ 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' },
|
{ role: 'togglefullscreen' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ role: 'close' },
|
{ role: 'close' },
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab
|
|||||||
const [isAnnotating, setIsAnnotating] = React.useState(false);
|
const [isAnnotating, setIsAnnotating] = React.useState(false);
|
||||||
const [isWaitingForServer, setIsWaitingForServer] = React.useState(false);
|
const [isWaitingForServer, setIsWaitingForServer] = React.useState(false);
|
||||||
const [zoomLevel, setZoomLevel] = React.useState(0);
|
const [zoomLevel, setZoomLevel] = React.useState(0);
|
||||||
|
const zoomLevelRef = React.useRef(0);
|
||||||
const [showDeviceBar, setShowDeviceBar] = React.useState(false);
|
const [showDeviceBar, setShowDeviceBar] = React.useState(false);
|
||||||
const [viewport, setViewport] = React.useState<BrowserViewport>(FILL_VIEWPORT);
|
const [viewport, setViewport] = React.useState<BrowserViewport>(FILL_VIEWPORT);
|
||||||
// Read inside agent actions, which are not re-created when the viewport
|
// Read inside agent actions, which are not re-created when the viewport
|
||||||
@@ -580,6 +581,7 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab
|
|||||||
|
|
||||||
const applyZoom = React.useCallback((level: number) => {
|
const applyZoom = React.useCallback((level: number) => {
|
||||||
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level));
|
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level));
|
||||||
|
zoomLevelRef.current = next;
|
||||||
setZoomLevel(next);
|
setZoomLevel(next);
|
||||||
try {
|
try {
|
||||||
webviewRef.current?.setZoomLevel(next);
|
webviewRef.current?.setZoomLevel(next);
|
||||||
@@ -588,6 +590,20 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ 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') => {
|
const clearBrowsingData = React.useCallback((what: 'cookies' | 'cache') => {
|
||||||
void invokeDesktopCommand('desktop_browser_clear_data', {
|
void invokeDesktopCommand('desktop_browser_clear_data', {
|
||||||
partition: BROWSER_PARTITION,
|
partition: BROWSER_PARTITION,
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ const SortableChip: React.FC<{
|
|||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
onClick={() => onSubmit(item)}
|
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}
|
style={chipStyle}
|
||||||
title={item.shared ? t('chat.draftStarters.sharedTitle') : undefined}
|
title={item.shared ? t('chat.draftStarters.sharedTitle') : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
|||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-1.5">
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
{selectedProject.kind === 'chat'
|
{selectedProject.kind === 'chat'
|
||||||
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
|
? <span className="truncate typography-ui-label">{t('chat.chatInput.chooseProject')}</span>
|
||||||
: <ProjectLabel project={selectedProject} theme={theme} />}
|
: <ProjectLabel project={selectedProject} theme={theme} />}
|
||||||
<Icon name="arrow-down-s" className="size-4 shrink-0 opacity-50" />
|
<Icon name="arrow-down-s" className="size-4 shrink-0 opacity-50" />
|
||||||
</span>
|
</span>
|
||||||
@@ -498,7 +498,7 @@ export function MobileDraftTargetTriggers(
|
|||||||
onClick={() => onOpenPicker('project')}
|
onClick={() => onOpenPicker('project')}
|
||||||
>
|
>
|
||||||
{selectedProject.kind === 'chat'
|
{selectedProject.kind === 'chat'
|
||||||
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
|
? <span className="truncate typography-ui-label">{t('chat.chatInput.chooseProject')}</span>
|
||||||
: <ProjectLabel project={selectedProject} theme={theme} />}
|
: <ProjectLabel project={selectedProject} theme={theme} />}
|
||||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import {
|
|||||||
import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry';
|
import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry';
|
||||||
import { isTerminalEventTarget } from '@/lib/terminalFocus';
|
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_MAX_WIDTH = 1400;
|
||||||
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
|
const CONTEXT_PANEL_DEFAULT_WIDTH = 600;
|
||||||
const RESIZE_FOLLOW_INTERVAL_MS = 100;
|
const RESIZE_FOLLOW_INTERVAL_MS = 100;
|
||||||
@@ -484,10 +484,21 @@ export const ContextPanel: React.FC = () => {
|
|||||||
const [availablePanelAreaWidth, setAvailablePanelAreaWidth] = React.useState<number | null>(null);
|
const [availablePanelAreaWidth, setAvailablePanelAreaWidth] = React.useState<number | null>(null);
|
||||||
const activeModeForWidth = activeTab?.mode ?? null;
|
const activeModeForWidth = activeTab?.mode ?? null;
|
||||||
const manualWidth = activeModeForWidth ? panelState?.widthByMode?.[activeModeForWidth] : undefined;
|
const manualWidth = activeModeForWidth ? panelState?.widthByMode?.[activeModeForWidth] : undefined;
|
||||||
|
const manualWidthFraction = activeModeForWidth ? panelState?.widthFractionByMode?.[activeModeForWidth] : undefined;
|
||||||
const widthFraction = activeModeForWidth ? getContextSurfaceWidthFraction(activeModeForWidth) : 0.5;
|
const widthFraction = activeModeForWidth ? getContextSurfaceWidthFraction(activeModeForWidth) : 0.5;
|
||||||
const widthFallbackBase = availablePanelAreaWidth
|
const widthFallbackBase = availablePanelAreaWidth
|
||||||
?? (typeof window !== 'undefined' ? window.innerWidth : CONTEXT_PANEL_DEFAULT_WIDTH * 2);
|
?? (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 chatSessionIDs = React.useMemo(() => {
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
@@ -509,8 +520,7 @@ export const ContextPanel: React.FC = () => {
|
|||||||
const chatFrameSrcByTabIDRef = React.useRef<Map<string, EmbeddedSessionChatURLCacheEntry>>(new Map());
|
const chatFrameSrcByTabIDRef = React.useRef<Map<string, EmbeddedSessionChatURLCacheEntry>>(new Map());
|
||||||
const wasOpenRef = React.useRef(false);
|
const wasOpenRef = React.useRef(false);
|
||||||
|
|
||||||
// Tracks the panel area width so fraction-based surface defaults stay
|
// Defaults and manually resized surfaces track the same available area.
|
||||||
// proportional as the window resizes; manual widths remain fixed px.
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const parent = panelRef.current?.parentElement;
|
const parent = panelRef.current?.parentElement;
|
||||||
if (!parent || typeof ResizeObserver === 'undefined') {
|
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
|
// Apply the final width once, letting the regular 200ms width transition
|
||||||
// carry the panel to the release position.
|
// carry the panel to the release position.
|
||||||
const finalWidth = clampWidthForDrag(resizingWidthRef.current ?? width);
|
const finalWidth = clampWidthForDrag(resizingWidthRef.current ?? width);
|
||||||
|
const availableWidth = resizeAvailableWidthRef.current;
|
||||||
resizingWidthRef.current = null;
|
resizingWidthRef.current = null;
|
||||||
resizeAvailableWidthRef.current = null;
|
resizeAvailableWidthRef.current = null;
|
||||||
if (resizeFollowTimerRef.current !== null) {
|
if (resizeFollowTimerRef.current !== null) {
|
||||||
@@ -600,7 +611,7 @@ export const ContextPanel: React.FC = () => {
|
|||||||
}
|
}
|
||||||
document.documentElement.style.cursor = '';
|
document.documentElement.style.cursor = '';
|
||||||
if (directoryKey && activeModeForWidth) {
|
if (directoryKey && activeModeForWidth) {
|
||||||
setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth);
|
setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth, availableWidth ?? undefined);
|
||||||
}
|
}
|
||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
activeResizePointerIDRef.current = null;
|
activeResizePointerIDRef.current = null;
|
||||||
|
|||||||
@@ -1076,7 +1076,9 @@ export const Header: React.FC = () => {
|
|||||||
// `--oc-titlebar-left-inset` so the sidebar strip can mirror it.
|
// `--oc-titlebar-left-inset` so the sidebar strip can mirror it.
|
||||||
const titlebarLeftInset = React.useMemo(() => {
|
const titlebarLeftInset = React.useMemo(() => {
|
||||||
if (isDesktopApp && isMacPlatform && !isDesktopWindowFullscreen) {
|
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) {
|
if (isTabletStandalonePwa) {
|
||||||
return 'max(calc(0.75rem + var(--oc-wco-left-inset, 0px)), 5.5rem)';
|
return 'max(calc(0.75rem + var(--oc-wco-left-inset, 0px)), 5.5rem)';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
|
||||||
const SIDEBAR_CONTENT_WIDTH = 280;
|
const SIDEBAR_CONTENT_WIDTH = 280;
|
||||||
const SIDEBAR_MIN_WIDTH = 280;
|
const SIDEBAR_MIN_WIDTH = 168;
|
||||||
const SIDEBAR_MAX_WIDTH = 500;
|
const SIDEBAR_MAX_WIDTH = 500;
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
|
|||||||
@@ -264,7 +264,8 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 bg-background',
|
'flex items-center gap-3 bg-background',
|
||||||
usesFramelessChrome && windowControlsSide === 'right' ? 'pr-0' : 'pr-3',
|
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',
|
usesFramelessChrome ? 'h-12' : macosHeaderSizeClass || 'min-h-14',
|
||||||
)}
|
)}
|
||||||
style={dragRegionStyle}
|
style={dragRegionStyle}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
const desktopHeaderPaddingClass = React.useMemo(() => {
|
const desktopHeaderPaddingClass = React.useMemo(() => {
|
||||||
if ((isDesktopApp && isMacPlatform) || isTabletStandalonePwa) {
|
if ((isDesktopApp && isMacPlatform) || isTabletStandalonePwa) {
|
||||||
// Match main app header: reserve space for Mac/iPadOS traffic lights.
|
// Match main app header: reserve space for Mac/iPadOS traffic lights.
|
||||||
return 'pl-[5.5rem]';
|
return 'pl-[88px]';
|
||||||
}
|
}
|
||||||
return 'pl-3';
|
return 'pl-3';
|
||||||
}, [isDesktopApp, isMacPlatform, isTabletStandalonePwa]);
|
}, [isDesktopApp, isMacPlatform, isTabletStandalonePwa]);
|
||||||
|
|||||||
@@ -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<string, PropertyDescriptor | undefined>();
|
||||||
|
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(<ThemeProvider><input aria-label="composer" /></ThemeProvider>));
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { isTerminalEventTarget } from '@/lib/terminalFocus';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
|
||||||
interface ThemeProviderProps {
|
interface ThemeProviderProps {
|
||||||
@@ -16,5 +17,32 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
|||||||
applyPadding();
|
applyPadding();
|
||||||
}, [fontSize, applyTypography, padding, 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}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon name="chat-new" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
<Icon name="chat-new" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||||
<span className="truncate text-[14px] font-normal leading-tight text-foreground">
|
<span className="truncate typography-ui-label font-normal leading-tight text-foreground">
|
||||||
{t('sessions.sidebar.header.actions.newSession')}
|
{t('sessions.sidebar.header.actions.newSession')}
|
||||||
</span>
|
</span>
|
||||||
</BaseMenu.Item>
|
</BaseMenu.Item>
|
||||||
@@ -293,7 +293,7 @@ function SwitcherRow({ session, depth, variant, secondaryMeta, hasChildren, isEx
|
|||||||
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
|
{isExpanded ? <Icon name="arrow-down-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-right-s" className="h-3.5 w-3.5" />}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className={cn('truncate text-[14px] font-normal leading-tight', isActive ? 'text-primary' : 'text-foreground')}>
|
<span className={cn('truncate typography-ui-label font-normal leading-tight', isActive ? 'text-primary' : 'text-foreground')}>
|
||||||
{sessionTitle}
|
{sessionTitle}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1146,7 +1146,7 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
|
|||||||
{...(dragHandleProps?.listeners ?? {})}
|
{...(dragHandleProps?.listeners ?? {})}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex flex-1 flex-col justify-center gap-0.5 overflow-hidden">
|
<div className="min-w-0 flex flex-1 flex-col justify-center gap-0.5 overflow-hidden">
|
||||||
<p className="text-[14px] font-normal truncate text-foreground/92">
|
<p className="typography-ui-label font-normal truncate text-foreground/92">
|
||||||
{group.isArchivedBucket ? (
|
{group.isArchivedBucket ? (
|
||||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
|
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
|
||||||
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Icon name="history" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80" />
|
<Icon name="history" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80" />
|
||||||
<span className="truncate text-[14px] font-semibold lowercase text-foreground">
|
<span className="truncate typography-ui-label font-semibold lowercase text-foreground">
|
||||||
{t('sessions.sidebar.activity.recentTitle')}
|
{t('sessions.sidebar.activity.recentTitle')}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const ProjectHeaderIdentity: React.FC<ProjectHeaderIdentityProps> = ({
|
|||||||
<Icon name="folder" className={cn('h-3.5 w-3.5 text-muted-foreground/80', iconVisibilityClassName)} style={iconColor ? { color: iconColor } : undefined} />
|
<Icon name="folder" className={cn('h-3.5 w-3.5 text-muted-foreground/80', iconVisibilityClassName)} style={iconColor ? { color: iconColor } : undefined} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-[14px] font-semibold lowercase text-foreground">{projectLabel}</span>
|
<span className="truncate typography-ui-label font-semibold lowercase text-foreground">{projectLabel}</span>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
|||||||
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
|
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[14px] font-semibold lowercase text-foreground">{section.title}</span>
|
<span className="typography-ui-label font-semibold lowercase text-foreground">{section.title}</span>
|
||||||
</button>
|
</button>
|
||||||
{section.key === 'chats' && props.onNewChat ? (
|
{section.key === 'chats' && props.onNewChat ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by
|
|||||||
rail until a tab of their mode exists, and stay visible for as long as one
|
rail until a tab of their mode exists, and stay visible for as long as one
|
||||||
does — they must not disappear while in use.
|
does — they must not disappear while in use.
|
||||||
- `defaultWidthFraction` is the panel width as a fraction of the content area,
|
- `defaultWidthFraction` is the panel width as a fraction of the content area,
|
||||||
used until the user manually resizes that surface (manual widths are stored
|
used until the user manually resizes that surface. Manual widths are stored
|
||||||
per mode in `useUIStore.contextPanelByDirectory[dir].widthByMode`).
|
per mode in `useUIStore.contextPanelByDirectory[dir].widthFractionByMode`;
|
||||||
|
`widthByMode` retains the last pixel size until the available area is known.
|
||||||
|
Every surface, including walkthrough, restores both values on reload.
|
||||||
- Rail order is user-reorderable and persisted globally in
|
- Rail order is user-reorderable and persisted globally in
|
||||||
`useUIStore.contextRailOrder`; `sortContextSurfaces` applies it on top of the
|
`useUIStore.contextRailOrder`; `sortContextSurfaces` applies it on top of the
|
||||||
registry's default order and appends any missing surfaces.
|
registry's default order and appends any missing surfaces.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useUIStore } from './useUIStore';
|
|||||||
const getContextPanelTabs = (directory: string) => useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
|
const getContextPanelTabs = (directory: string) => useUIStore.getState().contextPanelByDirectory[directory]?.tabs ?? [];
|
||||||
|
|
||||||
const getTerminalTab = (directory: string) => getContextPanelTabs(directory).find((tab) => tab.mode === 'terminal');
|
const getTerminalTab = (directory: string) => getContextPanelTabs(directory).find((tab) => tab.mode === 'terminal');
|
||||||
|
const originalPersistOptions = useUIStore.persist.getOptions();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useUIStore.setState({ contextPanelByDirectory: {}, contextRailOrder: [] });
|
useUIStore.setState({ contextPanelByDirectory: {}, contextRailOrder: [] });
|
||||||
@@ -180,6 +181,48 @@ describe('useUIStore context panel tabs', () => {
|
|||||||
expect(tabs.some((tab) => tab.mode === 'plan')).toBe(true);
|
expect(tabs.some((tab) => tab.mode === 'plan')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('drops invalid persisted context-panel width fractions', async () => {
|
||||||
|
const directory = '/repo';
|
||||||
|
useUIStore.persist.setOptions({ storage: {
|
||||||
|
getItem: () => ({
|
||||||
|
version: 20,
|
||||||
|
state: {
|
||||||
|
contextPanelByDirectory: {
|
||||||
|
[directory]: {
|
||||||
|
isOpen: true,
|
||||||
|
expanded: false,
|
||||||
|
widthByMode: { walkthrough: 800 },
|
||||||
|
widthFractionByMode: {
|
||||||
|
diff: 0,
|
||||||
|
file: 1.25,
|
||||||
|
context: Number.NaN,
|
||||||
|
plan: '0.4',
|
||||||
|
chat: 0.4,
|
||||||
|
walkthrough: 0.8,
|
||||||
|
},
|
||||||
|
touchedAt: 1,
|
||||||
|
activeTabId: null,
|
||||||
|
tabs: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
setItem: () => undefined,
|
||||||
|
removeItem: () => undefined,
|
||||||
|
} });
|
||||||
|
|
||||||
|
try {
|
||||||
|
useUIStore.setState(useUIStore.getInitialState(), true);
|
||||||
|
await useUIStore.persist.rehydrate();
|
||||||
|
|
||||||
|
const panel = useUIStore.getState().contextPanelByDirectory[directory];
|
||||||
|
expect(panel?.widthFractionByMode).toEqual({ chat: 0.4, walkthrough: 0.8 });
|
||||||
|
expect(panel?.widthByMode.walkthrough).toBe(800);
|
||||||
|
} finally {
|
||||||
|
useUIStore.persist.setOptions(originalPersistOptions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('drops a persisted saved-plan tab carrying an owner but no plan id', () => {
|
test('drops a persisted saved-plan tab carrying an owner but no plan id', () => {
|
||||||
const directory = '/repo';
|
const directory = '/repo';
|
||||||
const persisted = {
|
const persisted = {
|
||||||
@@ -672,9 +715,30 @@ describe('useUIStore per-surface panel widths', () => {
|
|||||||
|
|
||||||
const state = useUIStore.getState().contextPanelByDirectory[directory];
|
const state = useUIStore.getState().contextPanelByDirectory[directory];
|
||||||
expect(state?.widthByMode.diff).toBe(700);
|
expect(state?.widthByMode.diff).toBe(700);
|
||||||
expect(state?.widthByMode.git).toBe(380);
|
expect(state?.widthByMode.git).toBe(320);
|
||||||
expect(state?.widthByMode.browser).toBe(undefined);
|
expect(state?.widthByMode.browser).toBe(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('captures the clamped width as a responsive ratio when the panel area is known', () => {
|
||||||
|
useUIStore.getState().openContextPanelTab(directory, { mode: 'diff' });
|
||||||
|
useUIStore.getState().setContextPanelWidth(directory, 'diff', 100, 1000);
|
||||||
|
useUIStore.getState().setContextPanelWidth(directory, 'git', 700, 1000);
|
||||||
|
|
||||||
|
const state = useUIStore.getState().contextPanelByDirectory[directory];
|
||||||
|
expect(state?.widthByMode.diff).toBe(320);
|
||||||
|
expect(state?.widthFractionByMode.diff).toBe(0.32);
|
||||||
|
expect(state?.widthFractionByMode.git).toBe(0.7);
|
||||||
|
expect(state?.widthFractionByMode.browser).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pixel resize without a valid area replaces the previous ratio', () => {
|
||||||
|
const store = useUIStore.getState();
|
||||||
|
store.setContextPanelWidth(directory, 'walkthrough', 800, 1000);
|
||||||
|
store.setContextPanelWidth(directory, 'walkthrough', 600, Number.POSITIVE_INFINITY);
|
||||||
|
const panel = useUIStore.getState().contextPanelByDirectory[directory];
|
||||||
|
expect(panel?.widthByMode.walkthrough).toBe(600);
|
||||||
|
expect(panel?.widthFractionByMode.walkthrough).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useUIStore contextRailOrder', () => {
|
describe('useUIStore contextRailOrder', () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { z } from 'zod';
|
||||||
import { devtools, persist } from 'zustand/middleware';
|
import { devtools, persist } from 'zustand/middleware';
|
||||||
import type { SidebarSection } from '@/constants/sidebar';
|
import type { SidebarSection } from '@/constants/sidebar';
|
||||||
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
||||||
@@ -16,7 +17,12 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
|||||||
import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch';
|
import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch';
|
||||||
|
|
||||||
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch' | 'commit';
|
export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch' | 'commit';
|
||||||
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'linear' | 'notes' | 'terminal';
|
const contextPanelModeSchema = z.enum(['diff', 'walkthrough', 'file', 'context', 'plan', 'chat', 'browser', 'git', 'pr', 'linear', 'notes', 'terminal']);
|
||||||
|
export type ContextPanelMode = z.infer<typeof contextPanelModeSchema>;
|
||||||
|
const persistedPanelWidthsSchema = z.object({
|
||||||
|
widthByMode: z.record(z.string(), z.number().finite().optional().catch(undefined)).catch({}),
|
||||||
|
widthFractionByMode: z.record(z.string(), z.number().positive().max(1).optional().catch(undefined)).catch({}),
|
||||||
|
});
|
||||||
export type MermaidRenderingMode = 'svg' | 'ascii';
|
export type MermaidRenderingMode = 'svg' | 'ascii';
|
||||||
export type UserMessageRenderingMode = 'markdown' | 'plain';
|
export type UserMessageRenderingMode = 'markdown' | 'plain';
|
||||||
export type ChatRenderMode = 'sorted' | 'live';
|
export type ChatRenderMode = 'sorted' | 'live';
|
||||||
@@ -146,9 +152,12 @@ type ContextPanelDirectoryState = {
|
|||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
tabs: ContextPanelTab[];
|
tabs: ContextPanelTab[];
|
||||||
activeTabId: string | null;
|
activeTabId: string | null;
|
||||||
// Manual per-surface widths (px), populated only by user resize; surfaces
|
// Legacy pixel widths and the last resize value, used until the panel's
|
||||||
// without an entry fall back to their registry defaultWidthFraction.
|
// available area is known and a responsive ratio can be captured.
|
||||||
widthByMode: Partial<Record<ContextPanelMode, number>>;
|
widthByMode: Partial<Record<ContextPanelMode, number>>;
|
||||||
|
// Ratios captured when a user resizes a surface. These remain responsive
|
||||||
|
// across window sizes while widthByMode preserves older persisted values.
|
||||||
|
widthFractionByMode: Partial<Record<ContextPanelMode, number>>;
|
||||||
touchedAt: number;
|
touchedAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -203,12 +212,12 @@ const isLegacyDefaultTemplates = (value: unknown): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CONTEXT_PANEL_DEFAULT_WIDTH = 380;
|
const CONTEXT_PANEL_DEFAULT_WIDTH = 380;
|
||||||
const CONTEXT_PANEL_MIN_WIDTH = 380;
|
const CONTEXT_PANEL_MIN_WIDTH = 320;
|
||||||
const CONTEXT_PANEL_MAX_WIDTH = 1400;
|
const CONTEXT_PANEL_MAX_WIDTH = 1400;
|
||||||
/** Per surface, not per panel: see clampContextPanelTabs. */
|
/** Per surface, not per panel: see clampContextPanelTabs. */
|
||||||
const CONTEXT_PANEL_MAX_TABS = 12;
|
const CONTEXT_PANEL_MAX_TABS = 12;
|
||||||
const CONTEXT_PANEL_MAX_LABEL_LENGTH = 120;
|
const CONTEXT_PANEL_MAX_LABEL_LENGTH = 120;
|
||||||
const LEFT_SIDEBAR_MIN_WIDTH = 280;
|
const LEFT_SIDEBAR_MIN_WIDTH = 168;
|
||||||
/** Separates browser tabs opened in the same millisecond. */
|
/** Separates browser tabs opened in the same millisecond. */
|
||||||
let browserTabSequence = 0;
|
let browserTabSequence = 0;
|
||||||
|
|
||||||
@@ -513,6 +522,7 @@ const touchContextPanelState = (prev?: ContextPanelDirectoryState): ContextPanel
|
|||||||
tabs: [],
|
tabs: [],
|
||||||
activeTabId: null,
|
activeTabId: null,
|
||||||
widthByMode: {},
|
widthByMode: {},
|
||||||
|
widthFractionByMode: {},
|
||||||
touchedAt: Date.now(),
|
touchedAt: Date.now(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -703,16 +713,13 @@ const sanitizeContextPanelByDirectory = (
|
|||||||
// Legacy single `width` values are intentionally dropped: widths are now
|
// Legacy single `width` values are intentionally dropped: widths are now
|
||||||
// per-surface, seeded from registry defaults until the user resizes.
|
// per-surface, seeded from registry defaults until the user resizes.
|
||||||
const widthByMode: Partial<Record<ContextPanelMode, number>> = {};
|
const widthByMode: Partial<Record<ContextPanelMode, number>> = {};
|
||||||
if (candidate.widthByMode && typeof candidate.widthByMode === 'object') {
|
const widthFractionByMode: Partial<Record<ContextPanelMode, number>> = {};
|
||||||
for (const [mode, value] of Object.entries(candidate.widthByMode as Record<string, unknown>)) {
|
const savedWidths = persistedPanelWidthsSchema.parse(rawState);
|
||||||
if (
|
for (const mode of contextPanelModeSchema.options) {
|
||||||
(mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'linear' || mode === 'notes' || mode === 'terminal')
|
const pixels = savedWidths.widthByMode[mode];
|
||||||
&& typeof value === 'number'
|
const fraction = savedWidths.widthFractionByMode[mode];
|
||||||
&& Number.isFinite(value)
|
if (pixels !== undefined) widthByMode[mode] = clampContextPanelWidth(pixels);
|
||||||
) {
|
if (fraction !== undefined) widthFractionByMode[mode] = fraction;
|
||||||
widthByMode[mode] = clampContextPanelWidth(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
next[directory] = {
|
next[directory] = {
|
||||||
@@ -721,6 +728,7 @@ const sanitizeContextPanelByDirectory = (
|
|||||||
tabs: clampedTabs,
|
tabs: clampedTabs,
|
||||||
activeTabId: resolveActiveContextPanelTabID(clampedTabs, resolvedActiveTabId),
|
activeTabId: resolveActiveContextPanelTabID(clampedTabs, resolvedActiveTabId),
|
||||||
widthByMode,
|
widthByMode,
|
||||||
|
widthFractionByMode,
|
||||||
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
|
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
|
||||||
? candidate.touchedAt
|
? candidate.touchedAt
|
||||||
: Date.now(),
|
: Date.now(),
|
||||||
@@ -993,7 +1001,7 @@ interface UIStore {
|
|||||||
closeContextPanelTabs: (directory: string, tabIds: readonly string[]) => void;
|
closeContextPanelTabs: (directory: string, tabIds: readonly string[]) => void;
|
||||||
closeContextPanel: (directory: string) => void;
|
closeContextPanel: (directory: string) => void;
|
||||||
toggleContextPanelExpanded: (directory: string) => void;
|
toggleContextPanelExpanded: (directory: string) => void;
|
||||||
setContextPanelWidth: (directory: string, mode: ContextPanelMode, width: number) => void;
|
setContextPanelWidth: (directory: string, mode: ContextPanelMode, width: number, availableWidth?: number) => void;
|
||||||
setNotesPanelHeight: (height: number) => void;
|
setNotesPanelHeight: (height: number) => void;
|
||||||
setWorkStatusSectionExpanded: (sectionId: string, expanded: boolean) => void;
|
setWorkStatusSectionExpanded: (sectionId: string, expanded: boolean) => void;
|
||||||
setWorkStatusScrollTop: (scrollTop: number) => void;
|
setWorkStatusScrollTop: (scrollTop: number) => void;
|
||||||
@@ -1741,7 +1749,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
setContextPanelWidth: (directory, mode, width) => {
|
setContextPanelWidth: (directory, mode, width, availableWidth) => {
|
||||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||||
if (!normalizedDirectory) {
|
if (!normalizedDirectory) {
|
||||||
return;
|
return;
|
||||||
@@ -1750,14 +1758,22 @@ export const useUIStore = create<UIStore>()(
|
|||||||
set((state) => {
|
set((state) => {
|
||||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||||
const current = touchContextPanelState(prev);
|
const current = touchContextPanelState(prev);
|
||||||
|
const clampedWidth = clampContextPanelWidth(width);
|
||||||
|
const widthFractionByMode = { ...current.widthFractionByMode };
|
||||||
|
if (availableWidth !== undefined && Number.isFinite(availableWidth) && availableWidth > 0) {
|
||||||
|
widthFractionByMode[mode] = Math.min(1, clampedWidth / availableWidth);
|
||||||
|
} else {
|
||||||
|
delete widthFractionByMode[mode];
|
||||||
|
}
|
||||||
const byDirectory = {
|
const byDirectory = {
|
||||||
...state.contextPanelByDirectory,
|
...state.contextPanelByDirectory,
|
||||||
[normalizedDirectory]: {
|
[normalizedDirectory]: {
|
||||||
...current,
|
...current,
|
||||||
widthByMode: {
|
widthByMode: {
|
||||||
...current.widthByMode,
|
...current.widthByMode,
|
||||||
[mode]: clampContextPanelWidth(width),
|
[mode]: clampedWidth,
|
||||||
},
|
},
|
||||||
|
widthFractionByMode,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2140,21 +2156,20 @@ export const useUIStore = create<UIStore>()(
|
|||||||
|
|
||||||
const entries = Object.entries(SEMANTIC_TYPOGRAPHY) as Array<[SemanticTypographyKey, string]>;
|
const entries = Object.entries(SEMANTIC_TYPOGRAPHY) as Array<[SemanticTypographyKey, string]>;
|
||||||
|
|
||||||
// Default must be SEMANTIC_TYPOGRAPHY (from CSS). Remove overrides.
|
// Scale the root rem unit so regular utility classes, icons, spacing,
|
||||||
|
// and semantic typography all respond to the same interface setting.
|
||||||
if (scale === 1) {
|
if (scale === 1) {
|
||||||
|
root.style.removeProperty('font-size');
|
||||||
for (const [key] of entries) {
|
for (const [key] of entries) {
|
||||||
root.style.removeProperty(getTypographyVariable(key));
|
root.style.removeProperty(getTypographyVariable(key));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, baseValue] of entries) {
|
root.style.fontSize = `${scale * 100}%`;
|
||||||
const numericValue = parseFloat(baseValue);
|
|
||||||
if (!Number.isFinite(numericValue)) {
|
// The variables remain authored in rem and inherit the root scale.
|
||||||
continue;
|
for (const [key] of entries) root.style.removeProperty(getTypographyVariable(key));
|
||||||
}
|
|
||||||
root.style.setProperty(getTypographyVariable(key), `${numericValue * scale}rem`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
applyPadding: () => {
|
applyPadding: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user