fix(ui): keep layout responsive at every interface scale

This commit is contained in:
khafaji-ahmed
2026-09-08 19:56:49 -04:00
parent 05915e2859
commit e12a0ebd64
21 changed files with 197 additions and 35 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+14
View File
@@ -2413,6 +2413,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:menu-action', 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);
}; };
@@ -4963,6 +4970,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' },
], ],
}, },
@@ -5076,6 +5087,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' },
@@ -588,6 +588,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(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') => { 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) {
@@ -592,6 +603,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 +612,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;
+7 -3
View File
@@ -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)';
@@ -1170,8 +1172,10 @@ export const Header: React.FC = () => {
// Left inset is handled by the no-drag spacer (see renderDesktop); only // Left inset is handled by the no-drag spacer (see renderDesktop); only
// the right inset / titlebar height are owned by the window-controls overlay. // the right inset / titlebar height are owned by the window-controls overlay.
paddingRight: 'calc(0.75rem + var(--oc-wco-right-inset, 0px))', paddingRight: 'calc(0.75rem + var(--oc-wco-right-inset, 0px))',
minHeight: 'max(3rem, var(--oc-wco-titlebar-height, 0px))', // Keep the titlebar safe area in physical pixels. Interface zoom may
height: 'max(3rem, var(--oc-wco-titlebar-height, 0px))', // 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]); }, [isDesktopApp, isVSCode, usesFramelessChrome, windowControlsSide]);
@@ -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]);
@@ -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
+10 -1
View File
@@ -91,7 +91,10 @@ type MenuAction =
| 'previous-project' | 'previous-project'
| 'next-project' | 'next-project'
| 'help-dialog' | 'help-dialog'
| 'download-logs'; | 'download-logs'
| 'zoom-in'
| 'zoom-out'
| 'zoom-reset';
export const useMenuActions = ( export const useMenuActions = (
onToggleMemoryDebug?: () => void onToggleMemoryDebug?: () => void
@@ -270,6 +273,12 @@ export const useMenuActions = (
break; break;
} }
case 'zoom-in':
case 'zoom-out':
case 'zoom-reset':
window.dispatchEvent(new CustomEvent('openchamber:zoom', { detail: action }));
break;
case 'theme-light': case 'theme-light':
setThemeMode('light'); setThemeMode('light');
break; break;
@@ -15,8 +15,9 @@ 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`;
the legacy `widthByMode` field is retained only for migration compatibility.
- 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: [] });
@@ -173,6 +174,46 @@ 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: {},
widthFractionByMode: {
diff: 0,
file: 1.25,
context: Number.NaN,
plan: '0.4',
chat: 0.4,
},
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 });
} 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 = {
@@ -665,9 +706,21 @@ 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);
});
}); });
describe('useUIStore contextRailOrder', () => { describe('useUIStore contextRailOrder', () => {
+39 -13
View File
@@ -149,6 +149,9 @@ type ContextPanelDirectoryState = {
// Manual per-surface widths (px), populated only by user resize; surfaces // Manual per-surface widths (px), populated only by user resize; surfaces
// without an entry fall back to their registry defaultWidthFraction. // without an entry fall back to their registry defaultWidthFraction.
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 +206,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 +516,7 @@ const touchContextPanelState = (prev?: ContextPanelDirectoryState): ContextPanel
tabs: [], tabs: [],
activeTabId: null, activeTabId: null,
widthByMode: {}, widthByMode: {},
widthFractionByMode: {},
touchedAt: Date.now(), touchedAt: Date.now(),
}; };
}; };
@@ -671,6 +675,7 @@ const sanitizeContextPanelByDirectory = (
tabs?: unknown; tabs?: unknown;
activeTabId?: unknown; activeTabId?: unknown;
widthByMode?: unknown; widthByMode?: unknown;
widthFractionByMode?: unknown;
touchedAt?: unknown; touchedAt?: unknown;
mode?: unknown; mode?: unknown;
targetPath?: unknown; targetPath?: unknown;
@@ -714,6 +719,20 @@ const sanitizeContextPanelByDirectory = (
} }
} }
} }
const widthFractionByMode: Partial<Record<ContextPanelMode, number>> = {};
if (candidate.widthFractionByMode && typeof candidate.widthFractionByMode === 'object') {
for (const [mode, value] of Object.entries(candidate.widthFractionByMode as Record<string, unknown>)) {
if (
(mode === 'diff' || mode === 'file' || mode === 'context' || mode === 'plan' || mode === 'chat' || mode === 'browser' || mode === 'git' || mode === 'pr' || mode === 'linear' || mode === 'notes' || mode === 'terminal')
&& typeof value === 'number'
&& Number.isFinite(value)
&& value > 0
&& value <= 1
) {
widthFractionByMode[mode] = value;
}
}
}
next[directory] = { next[directory] = {
isOpen: candidate.isOpen === true, isOpen: candidate.isOpen === true,
@@ -721,6 +740,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 +1013,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 +1761,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,13 +1770,20 @@ 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 byDirectory = { const byDirectory = {
...state.contextPanelByDirectory, ...state.contextPanelByDirectory,
[normalizedDirectory]: { [normalizedDirectory]: {
...current, ...current,
widthByMode: { widthByMode: {
...current.widthByMode, ...current.widthByMode,
[mode]: clampContextPanelWidth(width), [mode]: clampedWidth,
},
widthFractionByMode: {
...current.widthFractionByMode,
...(availableWidth && availableWidth > 0
? { [mode]: Math.min(1, Math.max(0, clampedWidth / availableWidth)) }
: {}),
}, },
}, },
}; };
@@ -2140,21 +2167,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: () => {