fix(ui): keep layout responsive at every interface scale
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 |
@@ -2413,6 +2413,13 @@ const getMenuTargetWindow = () => {
|
||||
|
||||
const dispatchMenuAction = (action) => {
|
||||
const target = getMenuTargetWindow();
|
||||
// Zoom actions are consumed by the renderer's DOM listener. Sending them
|
||||
// through both the IPC bridge and the DOM event would invoke the handler
|
||||
// multiple times because preload fans the IPC event back into both paths.
|
||||
if (action === 'zoom-in' || action === 'zoom-out' || action === 'zoom-reset') {
|
||||
dispatchDomEventToWindow(target, 'openchamber:menu-action', action);
|
||||
return;
|
||||
}
|
||||
emitToWindow(target, 'openchamber:menu-action', action);
|
||||
dispatchDomEventToWindow(target, 'openchamber:menu-action', action);
|
||||
};
|
||||
@@ -4963,6 +4970,10 @@ const buildMacMenu = () => {
|
||||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Zoom In', accelerator: 'CmdOrCtrl+=', click: () => dispatchAction('zoom-in') },
|
||||
{ label: 'Zoom Out', accelerator: 'CmdOrCtrl+-', click: () => dispatchAction('zoom-out') },
|
||||
{ label: 'Reset Zoom', accelerator: 'CmdOrCtrl+0', click: () => dispatchAction('zoom-reset') },
|
||||
{ type: 'separator' },
|
||||
{ role: 'close' },
|
||||
],
|
||||
},
|
||||
@@ -5076,6 +5087,9 @@ const buildAutoHiddenMenu = () => {
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
{ label: 'Zoom In', accelerator: 'Ctrl+=', click: () => dispatchAction('zoom-in') },
|
||||
{ label: 'Zoom Out', accelerator: 'Ctrl+-', click: () => dispatchAction('zoom-out') },
|
||||
{ label: 'Reset Zoom', accelerator: 'Ctrl+0', click: () => dispatchAction('zoom-reset') },
|
||||
{ role: 'togglefullscreen' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'close' },
|
||||
|
||||
@@ -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') => {
|
||||
void invokeDesktopCommand('desktop_browser_clear_data', {
|
||||
partition: BROWSER_PARTITION,
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -310,7 +310,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{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} />}
|
||||
<Icon name="arrow-down-s" className="size-4 shrink-0 opacity-50" />
|
||||
</span>
|
||||
@@ -498,7 +498,7 @@ export function MobileDraftTargetTriggers(
|
||||
onClick={() => onOpenPicker('project')}
|
||||
>
|
||||
{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} />}
|
||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const activeModeForWidth = activeTab?.mode ?? null;
|
||||
const manualWidth = activeModeForWidth ? panelState?.widthByMode?.[activeModeForWidth] : undefined;
|
||||
const manualWidthFraction = activeModeForWidth ? panelState?.widthFractionByMode?.[activeModeForWidth] : undefined;
|
||||
const widthFraction = activeModeForWidth ? getContextSurfaceWidthFraction(activeModeForWidth) : 0.5;
|
||||
const widthFallbackBase = availablePanelAreaWidth
|
||||
?? (typeof window !== 'undefined' ? window.innerWidth : CONTEXT_PANEL_DEFAULT_WIDTH * 2);
|
||||
const width = clampWidth(manualWidth ?? Math.round(widthFraction * widthFallbackBase));
|
||||
const effectiveManualWidth = manualWidthFraction != null && availablePanelAreaWidth != null
|
||||
? Math.round(manualWidthFraction * availablePanelAreaWidth)
|
||||
: manualWidth;
|
||||
const width = clampWidth(effectiveManualWidth ?? Math.round(widthFraction * widthFallbackBase));
|
||||
|
||||
// Convert legacy pixel-only preferences to a ratio the first time the
|
||||
// available area is known, so existing users also get responsive sizing.
|
||||
React.useEffect(() => {
|
||||
if (!directoryKey || !activeModeForWidth || manualWidthFraction != null || manualWidth == null || availablePanelAreaWidth == null) return;
|
||||
setContextPanelWidth(directoryKey, activeModeForWidth, manualWidth, availablePanelAreaWidth);
|
||||
}, [activeModeForWidth, availablePanelAreaWidth, directoryKey, manualWidth, manualWidthFraction, setContextPanelWidth]);
|
||||
const chatSessionIDs = React.useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
for (const tab of tabs) {
|
||||
@@ -592,6 +603,7 @@ export const ContextPanel: React.FC = () => {
|
||||
// Apply the final width once, letting the regular 200ms width transition
|
||||
// carry the panel to the release position.
|
||||
const finalWidth = clampWidthForDrag(resizingWidthRef.current ?? width);
|
||||
const availableWidth = resizeAvailableWidthRef.current;
|
||||
resizingWidthRef.current = null;
|
||||
resizeAvailableWidthRef.current = null;
|
||||
if (resizeFollowTimerRef.current !== null) {
|
||||
@@ -600,7 +612,7 @@ export const ContextPanel: React.FC = () => {
|
||||
}
|
||||
document.documentElement.style.cursor = '';
|
||||
if (directoryKey && activeModeForWidth) {
|
||||
setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth);
|
||||
setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth, availableWidth ?? undefined);
|
||||
}
|
||||
setIsResizing(false);
|
||||
activeResizePointerIDRef.current = null;
|
||||
|
||||
@@ -1076,7 +1076,9 @@ export const Header: React.FC = () => {
|
||||
// `--oc-titlebar-left-inset` so the sidebar strip can mirror it.
|
||||
const titlebarLeftInset = React.useMemo(() => {
|
||||
if (isDesktopApp && isMacPlatform && !isDesktopWindowFullscreen) {
|
||||
return '5.5rem';
|
||||
// Native traffic lights have a fixed physical footprint. Keep this
|
||||
// clearance in pixels so shrinking the interface cannot overlap them.
|
||||
return '88px';
|
||||
}
|
||||
if (isTabletStandalonePwa) {
|
||||
return 'max(calc(0.75rem + var(--oc-wco-left-inset, 0px)), 5.5rem)';
|
||||
@@ -1170,8 +1172,10 @@ export const Header: React.FC = () => {
|
||||
// Left inset is handled by the no-drag spacer (see renderDesktop); only
|
||||
// the right inset / titlebar height are owned by the window-controls overlay.
|
||||
paddingRight: 'calc(0.75rem + var(--oc-wco-right-inset, 0px))',
|
||||
minHeight: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
|
||||
height: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
|
||||
// Keep the titlebar safe area in physical pixels. Interface zoom may
|
||||
// shrink rem content, but native macOS traffic lights must never overlap it.
|
||||
minHeight: 'max(56px, var(--oc-wco-titlebar-height, 0px))',
|
||||
height: 'max(56px, var(--oc-wco-titlebar-height, 0px))',
|
||||
};
|
||||
}, [isDesktopApp, isVSCode, usesFramelessChrome, windowControlsSide]);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -208,7 +208,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
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]);
|
||||
|
||||
@@ -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<ThemeProviderProps> = ({ 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}</>;
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
<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')}
|
||||
</span>
|
||||
</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" />}
|
||||
</span>
|
||||
) : 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}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1146,7 +1146,7 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
|
||||
{...(dragHandleProps?.listeners ?? {})}
|
||||
>
|
||||
<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 ? (
|
||||
<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">
|
||||
|
||||
@@ -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" />
|
||||
<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')}
|
||||
</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} />
|
||||
)}
|
||||
</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" />}
|
||||
</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>
|
||||
{section.key === 'chats' && props.onNewChat ? (
|
||||
<button
|
||||
|
||||
@@ -91,7 +91,10 @@ type MenuAction =
|
||||
| 'previous-project'
|
||||
| 'next-project'
|
||||
| 'help-dialog'
|
||||
| 'download-logs';
|
||||
| 'download-logs'
|
||||
| 'zoom-in'
|
||||
| 'zoom-out'
|
||||
| 'zoom-reset';
|
||||
|
||||
export const useMenuActions = (
|
||||
onToggleMemoryDebug?: () => void
|
||||
@@ -270,6 +273,12 @@ export const useMenuActions = (
|
||||
break;
|
||||
}
|
||||
|
||||
case 'zoom-in':
|
||||
case 'zoom-out':
|
||||
case 'zoom-reset':
|
||||
window.dispatchEvent(new CustomEvent('openchamber:zoom', { detail: action }));
|
||||
break;
|
||||
|
||||
case 'theme-light':
|
||||
setThemeMode('light');
|
||||
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
|
||||
does — they must not disappear while in use.
|
||||
- `defaultWidthFraction` is the panel width as a fraction of the content area,
|
||||
used until the user manually resizes that surface (manual widths are stored
|
||||
per mode in `useUIStore.contextPanelByDirectory[dir].widthByMode`).
|
||||
used until the user manually resizes that surface. Manual widths are stored
|
||||
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
|
||||
`useUIStore.contextRailOrder`; `sortContextSurfaces` applies it on top of the
|
||||
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 getTerminalTab = (directory: string) => getContextPanelTabs(directory).find((tab) => tab.mode === 'terminal');
|
||||
const originalPersistOptions = useUIStore.persist.getOptions();
|
||||
|
||||
beforeEach(() => {
|
||||
useUIStore.setState({ contextPanelByDirectory: {}, contextRailOrder: [] });
|
||||
@@ -173,6 +174,46 @@ describe('useUIStore context panel tabs', () => {
|
||||
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', () => {
|
||||
const directory = '/repo';
|
||||
const persisted = {
|
||||
@@ -665,9 +706,21 @@ describe('useUIStore per-surface panel widths', () => {
|
||||
|
||||
const state = useUIStore.getState().contextPanelByDirectory[directory];
|
||||
expect(state?.widthByMode.diff).toBe(700);
|
||||
expect(state?.widthByMode.git).toBe(380);
|
||||
expect(state?.widthByMode.git).toBe(320);
|
||||
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', () => {
|
||||
|
||||
@@ -149,6 +149,9 @@ type ContextPanelDirectoryState = {
|
||||
// Manual per-surface widths (px), populated only by user resize; surfaces
|
||||
// without an entry fall back to their registry defaultWidthFraction.
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -203,12 +206,12 @@ const isLegacyDefaultTemplates = (value: unknown): boolean => {
|
||||
};
|
||||
|
||||
const CONTEXT_PANEL_DEFAULT_WIDTH = 380;
|
||||
const CONTEXT_PANEL_MIN_WIDTH = 380;
|
||||
const CONTEXT_PANEL_MIN_WIDTH = 320;
|
||||
const CONTEXT_PANEL_MAX_WIDTH = 1400;
|
||||
/** Per surface, not per panel: see clampContextPanelTabs. */
|
||||
const CONTEXT_PANEL_MAX_TABS = 12;
|
||||
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. */
|
||||
let browserTabSequence = 0;
|
||||
|
||||
@@ -513,6 +516,7 @@ const touchContextPanelState = (prev?: ContextPanelDirectoryState): ContextPanel
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
widthByMode: {},
|
||||
widthFractionByMode: {},
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
@@ -671,6 +675,7 @@ const sanitizeContextPanelByDirectory = (
|
||||
tabs?: unknown;
|
||||
activeTabId?: unknown;
|
||||
widthByMode?: unknown;
|
||||
widthFractionByMode?: unknown;
|
||||
touchedAt?: unknown;
|
||||
mode?: 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] = {
|
||||
isOpen: candidate.isOpen === true,
|
||||
@@ -721,6 +740,7 @@ const sanitizeContextPanelByDirectory = (
|
||||
tabs: clampedTabs,
|
||||
activeTabId: resolveActiveContextPanelTabID(clampedTabs, resolvedActiveTabId),
|
||||
widthByMode,
|
||||
widthFractionByMode,
|
||||
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
|
||||
? candidate.touchedAt
|
||||
: Date.now(),
|
||||
@@ -993,7 +1013,7 @@ interface UIStore {
|
||||
closeContextPanelTabs: (directory: string, tabIds: readonly string[]) => void;
|
||||
closeContextPanel: (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;
|
||||
setWorkStatusSectionExpanded: (sectionId: string, expanded: boolean) => 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());
|
||||
if (!normalizedDirectory) {
|
||||
return;
|
||||
@@ -1750,13 +1770,20 @@ export const useUIStore = create<UIStore>()(
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
const current = touchContextPanelState(prev);
|
||||
const clampedWidth = clampContextPanelWidth(width);
|
||||
const byDirectory = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: {
|
||||
...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]>;
|
||||
|
||||
// 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) {
|
||||
root.style.removeProperty('font-size');
|
||||
for (const [key] of entries) {
|
||||
root.style.removeProperty(getTypographyVariable(key));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, baseValue] of entries) {
|
||||
const numericValue = parseFloat(baseValue);
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
continue;
|
||||
}
|
||||
root.style.setProperty(getTypographyVariable(key), `${numericValue * scale}rem`);
|
||||
}
|
||||
root.style.fontSize = `${scale * 100}%`;
|
||||
|
||||
// The variables remain authored in rem and inherit the root scale.
|
||||
for (const [key] of entries) root.style.removeProperty(getTypographyVariable(key));
|
||||
},
|
||||
|
||||
applyPadding: () => {
|
||||
|
||||
Reference in New Issue
Block a user