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 target = getMenuTargetWindow();
|
||||
// Zoom actions are consumed by the renderer's DOM listener. Sending them
|
||||
// through both the IPC bridge and the DOM event would invoke the handler
|
||||
// multiple times because preload fans the IPC event back into both paths.
|
||||
if (action === 'zoom-in' || action === 'zoom-out' || action === 'zoom-reset') {
|
||||
dispatchDomEventToWindow(target, 'openchamber:zoom', action);
|
||||
return;
|
||||
}
|
||||
emitToWindow(target, 'openchamber:menu-action', action);
|
||||
dispatchDomEventToWindow(target, 'openchamber:menu-action', action);
|
||||
};
|
||||
@@ -4864,6 +4871,10 @@ const buildMacMenu = () => {
|
||||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Zoom In', accelerator: 'CmdOrCtrl+=', click: () => dispatchAction('zoom-in') },
|
||||
{ label: 'Zoom Out', accelerator: 'CmdOrCtrl+-', click: () => dispatchAction('zoom-out') },
|
||||
{ label: 'Reset Zoom', accelerator: 'CmdOrCtrl+0', click: () => dispatchAction('zoom-reset') },
|
||||
{ type: 'separator' },
|
||||
{ role: 'close' },
|
||||
],
|
||||
},
|
||||
@@ -4977,6 +4988,9 @@ const buildAutoHiddenMenu = () => {
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
{ label: 'Zoom In', accelerator: 'Ctrl+=', click: () => dispatchAction('zoom-in') },
|
||||
{ label: 'Zoom Out', accelerator: 'Ctrl+-', click: () => dispatchAction('zoom-out') },
|
||||
{ label: 'Reset Zoom', accelerator: 'Ctrl+0', click: () => dispatchAction('zoom-reset') },
|
||||
{ role: 'togglefullscreen' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'close' },
|
||||
|
||||
@@ -111,6 +111,7 @@ const WebviewBrowser: React.FC<BrowserPaneProps> = ({ initialUrl, directory, tab
|
||||
const [isAnnotating, setIsAnnotating] = React.useState(false);
|
||||
const [isWaitingForServer, setIsWaitingForServer] = React.useState(false);
|
||||
const [zoomLevel, setZoomLevel] = React.useState(0);
|
||||
const zoomLevelRef = React.useRef(0);
|
||||
const [showDeviceBar, setShowDeviceBar] = React.useState(false);
|
||||
const [viewport, setViewport] = React.useState<BrowserViewport>(FILL_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 next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level));
|
||||
zoomLevelRef.current = next;
|
||||
setZoomLevel(next);
|
||||
try {
|
||||
webviewRef.current?.setZoomLevel(next);
|
||||
@@ -588,6 +590,20 @@ const WebviewBrowser: React.FC<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') => {
|
||||
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) {
|
||||
@@ -509,8 +520,7 @@ export const ContextPanel: React.FC = () => {
|
||||
const chatFrameSrcByTabIDRef = React.useRef<Map<string, EmbeddedSessionChatURLCacheEntry>>(new Map());
|
||||
const wasOpenRef = React.useRef(false);
|
||||
|
||||
// Tracks the panel area width so fraction-based surface defaults stay
|
||||
// proportional as the window resizes; manual widths remain fixed px.
|
||||
// Defaults and manually resized surfaces track the same available area.
|
||||
React.useLayoutEffect(() => {
|
||||
const parent = panelRef.current?.parentElement;
|
||||
if (!parent || typeof ResizeObserver === 'undefined') {
|
||||
@@ -592,6 +602,7 @@ export const ContextPanel: React.FC = () => {
|
||||
// Apply the final width once, letting the regular 200ms width transition
|
||||
// carry the panel to the release position.
|
||||
const finalWidth = clampWidthForDrag(resizingWidthRef.current ?? width);
|
||||
const availableWidth = resizeAvailableWidthRef.current;
|
||||
resizingWidthRef.current = null;
|
||||
resizeAvailableWidthRef.current = null;
|
||||
if (resizeFollowTimerRef.current !== null) {
|
||||
@@ -600,7 +611,7 @@ export const ContextPanel: React.FC = () => {
|
||||
}
|
||||
document.documentElement.style.cursor = '';
|
||||
if (directoryKey && activeModeForWidth) {
|
||||
setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth);
|
||||
setContextPanelWidth(directoryKey, activeModeForWidth, finalWidth, availableWidth ?? undefined);
|
||||
}
|
||||
setIsResizing(false);
|
||||
activeResizePointerIDRef.current = null;
|
||||
|
||||
@@ -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)';
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 { 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
|
||||
|
||||
@@ -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
|
||||
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`;
|
||||
`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
|
||||
`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: [] });
|
||||
@@ -180,6 +181,48 @@ 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: { 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', () => {
|
||||
const directory = '/repo';
|
||||
const persisted = {
|
||||
@@ -672,9 +715,30 @@ 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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { z } from 'zod';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import type { SidebarSection } from '@/constants/sidebar';
|
||||
import { createDeferredSafeJSONStorage } from './utils/safeStorage';
|
||||
@@ -16,7 +17,12 @@ import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
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 UserMessageRenderingMode = 'markdown' | 'plain';
|
||||
export type ChatRenderMode = 'sorted' | 'live';
|
||||
@@ -146,9 +152,12 @@ type ContextPanelDirectoryState = {
|
||||
expanded: boolean;
|
||||
tabs: ContextPanelTab[];
|
||||
activeTabId: string | null;
|
||||
// Manual per-surface widths (px), populated only by user resize; surfaces
|
||||
// without an entry fall back to their registry defaultWidthFraction.
|
||||
// Legacy pixel widths and the last resize value, used until the panel's
|
||||
// available area is known and a responsive ratio can be captured.
|
||||
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 +212,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 +522,7 @@ const touchContextPanelState = (prev?: ContextPanelDirectoryState): ContextPanel
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
widthByMode: {},
|
||||
widthFractionByMode: {},
|
||||
touchedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
@@ -703,16 +713,13 @@ const sanitizeContextPanelByDirectory = (
|
||||
// Legacy single `width` values are intentionally dropped: widths are now
|
||||
// per-surface, seeded from registry defaults until the user resizes.
|
||||
const widthByMode: Partial<Record<ContextPanelMode, number>> = {};
|
||||
if (candidate.widthByMode && typeof candidate.widthByMode === 'object') {
|
||||
for (const [mode, value] of Object.entries(candidate.widthByMode 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)
|
||||
) {
|
||||
widthByMode[mode] = clampContextPanelWidth(value);
|
||||
}
|
||||
}
|
||||
const widthFractionByMode: Partial<Record<ContextPanelMode, number>> = {};
|
||||
const savedWidths = persistedPanelWidthsSchema.parse(rawState);
|
||||
for (const mode of contextPanelModeSchema.options) {
|
||||
const pixels = savedWidths.widthByMode[mode];
|
||||
const fraction = savedWidths.widthFractionByMode[mode];
|
||||
if (pixels !== undefined) widthByMode[mode] = clampContextPanelWidth(pixels);
|
||||
if (fraction !== undefined) widthFractionByMode[mode] = fraction;
|
||||
}
|
||||
|
||||
next[directory] = {
|
||||
@@ -721,6 +728,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 +1001,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 +1749,7 @@ export const useUIStore = create<UIStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setContextPanelWidth: (directory, mode, width) => {
|
||||
setContextPanelWidth: (directory, mode, width, availableWidth) => {
|
||||
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
|
||||
if (!normalizedDirectory) {
|
||||
return;
|
||||
@@ -1750,14 +1758,22 @@ export const useUIStore = create<UIStore>()(
|
||||
set((state) => {
|
||||
const prev = state.contextPanelByDirectory[normalizedDirectory];
|
||||
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 = {
|
||||
...state.contextPanelByDirectory,
|
||||
[normalizedDirectory]: {
|
||||
...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]>;
|
||||
|
||||
// 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